泛型类型擦除:为什么子类需要一个”桥”?
一句话结论(30s)
泛型擦除的本质是 Java 5 引入泛型时为兼容老 JVM 的历史妥协,因为类型参数在字节码中被替换为 Object/上界,导致子类重写父类泛型方法时签名不一致、多态调用断裂,所以编译器必须自动生成 synthetic 桥接方法作为 JVM 与泛型之间的「翻译层」修复调用。权衡:以运行期丢失类型信息为代价,换来源码级类型安全与对无泛型老 JVM 的兼容。
核心原理(2min)
主流程:List<String> 与 List<Integer> 编译后擦除成同一个 List,类型参数替换为 Object 或上界;Child extends Parent<String> 重写 String get(),但父类擦除后是 Object get(),JVM 按方法签名查找认为子类未重写、多态断裂;于是编译器在子类生成 Object get(){ return this.get(); } 的桥接方法(ACC_SYNTHETIC | ACC_BRIDGE)转发调用,协变返回类型同理。声明期的泛型信息仍保留在 class 的 Signature 属性中可反射读取,反射遍历方法时需用 isBridge() 过滤,否则业务逻辑被执行两次。
底层深入(5-10min)
泛型只存在于编译期
List<String> list1 = new ArrayList<>();
List<Integer> list2 = new ArrayList<>();
// 编译后,两者都是:
List list1 = new ArrayList();
List list2 = new ArrayList();
System.out.println(list1.getClass() == list2.getClass()); // true!
Java 的泛型是纯编译期特性——运行期 List<String> 和 List<Integer> 是同一个 class。所有泛型类型参数在字节码中被替换为 Object(未指定上界时)或上界类型。
💭 思考:为什么 Java 选「擦除」而不是像 C# 那样保留运行时泛型?这是历史妥协——Java 5 引入泛型时,为了兼容海量已编译的、没有泛型的老字节码和老 JVM,只能让泛型在编译后「消失」。代价是运行期丢类型信息、还得靠桥接方法补洞。
擦除后多态调用断裂
class Parent<T> {
public T get() { return null; }
}
class Child extends Parent<String> {
@Override
public String get() { return "child"; }
}
擦除后:
Parent: public Object get() ← 擦除后的签名
Child: public String get() ← 不同的签名!
JVM 多态查找基于方法签名。Parent 期望 Object get(),Child 写了 String get()——不是同一个方法,JVM 认为 Child 没有重写 Parent 的 get(),多态断了。
💭 思考:为什么擦除会破坏多态?因为 JVM 按「方法签名」做多态查找,签名包含返回类型;父类擦除后是 Object get(),子类写的是 String get(),两个方法签名不同,JVM 就认为子类没重写父类方法。桥接方法就是来「补」这个签名差的。
真实 JDK 源码:ArrayList 的 get(int)
JDK 里「擦除后签名变成 Object」的典型就是 java.util.ArrayList 的 get(源码节选,注释为原文):
/**
* Returns the element at the specified position in this list.
*
* @param index index of the element to return
* @return the element at the specified position in this list
* @throws IndexOutOfBoundsException {@inheritDoc}
*/
public E get(int index) {
Objects.checkIndex(index, size);
return elementData(index);
}
源码里写的是泛型 E,但编译后 E 被擦除成 Object——ArrayList 的 get 在字节码里的真实签名是 Object get(int)。这就是「泛型只存在于编译期」的直接证据:运行期没有任何 E,返回值靠编译期自动插入的 checkcast 转型完成。
编译器生成的桥接方法(编译期生成,非 JDK 手写源码)
假如你写一个 StringList extends ArrayList<String> 并重写 get:
class StringList extends ArrayList<String> {
@Override
public String get(int index) { // 返回值从 E 收敛为 String(协变返回)
return super.get(index);
}
}
擦除后,父类 ArrayList 的签名是 Object get(int),而 StringList 手写的是 String get(int)——JVM 按签名查找会认为没有重写,多态断裂。于是 javac 在 StringList.class 里自动生成一个 synthetic 桥接方法(下面的代码是编译器生成的结果,不是 ArrayList.java 或任何 JDK 源码里的手写代码):
💭 思考:桥接方法为什么要生成一个 Object get() 来转发?因为外部调用者通过父类引用调的是 Object get(),JVM 会分派到子类里那个 Object get();桥接方法内部再调真实的 String get(),把返回的 String 向上转型成 Object。两条签名就这样被「接通」了。
// 编译器在 StringList.class 中自动生成(ACC_SYNTHETIC | ACC_BRIDGE)
public Object get(int index) {
return this.get(index); // 转发调用真实的 String get(int),返回值向上转型为 Object
}
用 javap -p StringList.class 反编译能直接看到这条签名,字节码形如(示意):
// StringList.class 反编译示意(方法描述符 ()Ljava/lang/Object; 即 Object get(int))
// public bridge synthetic java.lang.Object get(int);
// Code:
// 0: aload_0
// 1: iload_1
// 2: invokevirtual #xx // StringList.get:(I)Ljava/lang/String;
// 5: areturn
桥接方法的标志是 ACC_SYNTHETIC | ACC_BRIDGE。反射 getDeclaredMethods() 会把它一起返回,所以需要用 method.isBridge() 过滤,否则同一业务逻辑会被执行两次(一次是桥接方法转发调真实方法,一次是真实方法本身被反射调用)。
💭 思考:为什么反射遍历方法要 isBridge() 过滤?因为 getDeclaredMethods 会把桥接方法一起返回,桥接方法只是转发、不包含业务逻辑;不过滤的话,你的框架按方法名/参数扫描时会把同一个业务方法处理两遍。
协变返回类型也需要桥接
class Parent {
public Parent clone() { return null; }
}
class Child extends Parent {
@Override
public Child clone() { return null; } // 返回类型协变
}
同样的问题——擦除后父类是 Parent clone(),子类是 Child clone()。编译器生成桥接 Parent clone() 内部调用 Child clone()。
泛型在运行时的痕迹
虽然类型参数被擦除,但泛型的某些信息可以通过反射获取:
// 子类声明时的泛型参数信息保留在 class 文件的 Signature 属性中
class StringList extends ArrayList<String> {}
// 通过反射获取
Type superClass = StringList.class.getGenericSuperclass();
ParameterizedType pType = (ParameterizedType) superClass;
Type actualType = pType.getActualTypeArguments()[0];
System.out.println(actualType); // class java.lang.String
类型擦除不是”信息完全消失”——声明时的泛型类型仍保留在 class 的属性表中,可以通过反射读取。但实例化时的泛型参数(new ArrayList<String>() 中的 String)完全消失。
💭 思考:为什么「声明期」泛型能反射拿到、而「实例化期」泛型拿不到?因为声明期的
extends ArrayList<String>被写进了 class 的 Signature 属性,而new ArrayList<String>()里的 String 只是编译期检查用,字节码里根本没有这个信息——new 出来的对象不知道自己是 String 的。
总结
类型擦除是 Java 5 引入泛型时的历史妥协——兼容没有泛型的老 JVM。代价是擦除后多态调用断裂,需要编译器生成桥接方法修复。桥接方法是 JVM 世界和 Java 泛型世界之间的”翻译层”。
章末提问
追问 1:什么是类型擦除?为什么 Java 要这么做?
结论先行:类型擦除是 Java 5 引入泛型时、为兼容老 JVM 的历史妥协——类型参数在编译后被替换为 Object 或上界。
因为:泛型是纯编译期特性,运行期 List
追问 2:桥接方法是怎么产生的?解决什么问题?
结论先行:子类重写父类泛型方法时,擦除后父类是 Object get()、子类是 String get(),签名不同导致多态断裂,编译器自动生成 Object get(){ return this.get(); } 的桥接方法(ACC_SYNTHETIC | ACC_BRIDGE)转发。
因为:JVM 按方法签名(含返回类型)做多态分派,签名不一致就认为没重写;桥接方法在子类里补出与父类一致的 Object get() 签名,内部调用真实的 String get() 并向上转型,把 JVM 世界和泛型世界「接通」。协变返回类型同理。
追问 3:反射遍历方法时为什么要用 isBridge() 过滤?
结论先行:因为桥接方法是编译器生成的 synthetic 方法、不含业务逻辑,getDeclaredMethods 会把它一并返回,不过滤会导致业务逻辑被执行两遍。
因为:桥接方法只是转发调用真实方法,按方法名/参数反射扫描时它会和真实方法一起被命中;框架(如序列化、AOP、JSON 绑定)若不对 isBridge 过滤,同一个逻辑会被处理两次,产生重复副作用或错误绑定。