Skip to content
Go back

Java泛型类型擦除——编译器为什么要生成桥接方法?

泛型类型擦除:为什么子类需要一个”桥”?

一句话结论(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.ArrayListget(源码节选,注释为原文):

/**
 * 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——ArrayListget 在字节码里的真实签名是 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 按签名查找会认为没有重写,多态断裂。于是 javacStringList.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 和 List 是同一个 class。这样做的目的是让新代码能跑在没有泛型的老 JVM 上、老字节码不用重新编译。代价是运行期丢失类型信息、多态调用断裂需桥接方法修复。

追问 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 过滤,同一个逻辑会被处理两次,产生重复副作用或错误绑定。


Share this post on:

Previous Post
注解原理——@Override背后有一个动态代理
Next Post
Java序列化——反序列化为什么用Unsafe.allocateInstance绕过构造器?