← 返回博客
2026-10-13 08:00:01

泛型擦除后桥方法是怎么补回来的

泛型擦除后桥方法是怎么补回来的

手把手实操

先写一段能同时踩到泛型、反射、序列化、初始化的代码,跑一遍看现象。

public class Box<T> implements Comparable<Box<T>> {
    private T value;
    public Box(T value) { this.value = value; }
    public T get() { return value; }
    @Override public int compareTo(Box<T> o) { return 0; }
}

编译后执行 javap -c -p -v Box.class,重点看三处:get() 的签名是 ()Ljava/lang/Object;,compareTo 有两个版本——一个参数是 Box,一个参数是 Object,后者就是编译器生成的桥方法,方法体只有一句 checkcast 加转发调用。

再看泛型擦除的后果:

List<String> a = new ArrayList<>();
List<Integer> b = new ArrayList<>();
System.out.println(a.getClass() == b.getClass()); // true

两个泛型参数不同的 List,运行时是同一个 Class。这就是擦除:泛型只在编译期做检查,运行期没有 List 这个东西。所以 new T()、new T[]、T.class 都写不出来,instanceof List 也编译不过。

反射绕过检查:

List<String> list = new ArrayList<>();
Method m = List.class.getMethod("add", Object.class);
m.invoke(list, 123);          // 塞进去一个 Integer
System.out.println(list.get(0).getClass()); // class java.lang.Integer

后面 String s = list.get(0) 会在赋值处抛 ClassCastException,因为编译器插入的 checkcast 才做检查。关键点:擦除把类型检查从运行期挪到了编译期,反射是唯一能撕开这个口子的入口,代价是错误延后到取值那一刻才炸。

看源码及解析

桥方法为什么必须存在。 Comparable> 擦除后接口方法是 compareTo(Object),而你的实现是 compareTo(Box)。JVM 的方法分派按描述符匹配,两者描述符不同,等于没实现接口。编译器于是合成一个 compareTo(Object),内部 checkcast 成 Box 再调你的方法。这就是桥方法,javap 里带 ACC_BRIDGE 和 ACC_SYNTHETIC 标志。它同时解决了协变返回值和泛型重写两个问题。

PECS 的推导。 List 只能读不能写,因为编译器不知道真实类型是 T 的哪个子类,写进去可能破坏容器;List 只能写不能读(读出来是 Object),因为不知道真实类型是 T 的哪个父类。记住 Producer Extends Consumer Super:生产者用 extends,消费者用 super。Collections.copy(List dest, List src) 就是标准范例。

异常与 finally 的覆盖关系。 这条最容易在面试里翻车:

static int f() {
    try { return 1; }
    finally { return 2; }
}

返回 2。原因在字节码:return 1 先把 1 存进局部变量槽,然后执行 finally 块,finally 里的 return 2 直接覆盖了返回值并 ireturn。如果 finally 里没有 return,只是改局部变量,返回值不受影响——因为返回值在进入 finally 前已经存好了。finally 里抛异常同理,会吞掉 try 里的异常。

try-with-resources 的抑制异常。 资源类实现 AutoCloseable 即可。关闭顺序是声明的逆序。如果 try 块抛 A、close 抛 B,最终抛出的是 A,B 被 addSuppressed 挂到 A 上,用 getSuppressed() 取。手写 finally 关闭做不到这点,B 会直接覆盖 A。

反射的性能代价。 Method.invoke 每次都要做访问权限检查、参数装箱成 Object[]、拆箱。setAccessible(true) 关掉权限检查能省一截,但真正的差距在 JIT:反射调用早期走 native 的 MethodAccessor,调用次数超过阈值(JDK 里由 sun.reflect.inflationThreshold 控制,默认 15)后会膨胀成动态生成的字节码版本,性能才追上来。所以"反射慢"要分冷热路径说,别一概而论。

序列化。 serialVersionUID 不显式声明时,JVM 按类结构算一个哈希值,改一个字段就变,反序列化直接 InvalidClassException。transient 字段不参与序列化,反序列化后是默认值。Externalizable 完全自己控制读写。注意 readObject 是反序列化的攻击面,反序列化不可信数据前要做白名单校验。

clone 与深浅拷贝。 Object.clone 是浅拷贝,逐字段复制引用。要深拷贝得自己递归或走序列化。Cloneable 是个空标记接口,不实现它调 clone 会抛 CloneNotSupportedException。实践中更推荐拷贝构造器,省掉这套别扭的协议。

枚举。 编译后是继承 java.lang.Enum 的 final 类,每个常量是 static final 实例,构造器私有。天然线程安全、天然单例、天然可序列化(反序列化走 readResolve 保证还是同一个实例)。switch 支持枚举是因为编译器生成了 $SwitchMap 数组做序号映射。

lambda 捕获。 只能捕获 effectively final 的局部变量,因为 lambda 编译后是方法里的一个对象,捕获的变量被复制进对象的字段。如果变量可变,复制和原变量就会不一致,所以语言层面直接禁止。成员变量不受限,因为它是通过 this 访问的。

类初始化顺序。 父类静态字段和静态块(按源码顺序)→ 子类静态字段和静态块 → 父类实例字段和实例块 → 父类构造器 → 子类实例字段和实例块 → 子类构造器。静态块只执行一次,在类首次主动使用时触发。

抽象类还是接口。 需要维护状态(字段)或需要构造逻辑,用抽象类;只是定义能力契约,用接口,因为一个类只能继承一个父类但能实现多个接口。JDK 8 的 default 方法让接口能带实现,但接口不能有实例字段。两个接口的 default 方法签名冲突时,实现类必须重写,否则编译不过;可以用 InterfaceA.super.method() 显式指定。

验证方法

跑 javap -v 看桥方法的 ACC_BRIDGE 标志,确认自己看懂了合成逻辑。

写一个 finally 里 return 的例子,观察返回值被覆盖;再写一个 finally 里只改局部变量的例子,观察返回值不变。两个对比跑通,这条就稳了。

写一个 AutoCloseable 实现,让 try 块和 close 都抛异常,打印 getSuppressed() 的长度,看到 1 就说明抑制异常生效了。

反射性能验证:同一个 Method.invoke 循环调用几万次,前后各打一次时间戳,能看到前几十次明显慢、后面趋于稳定,这就是 inflation 的效果。

面试速答

本文关键词:类型擦除、桥方法、PECS、try-with-resources、反射膨胀

核心收获:泛型的类型检查被挪到编译期,运行期的所有"补丁"——桥方法、checkcast、抑制异常——都是编译器替你补的。下一步,拿 javap -v 把自己项目里一个带泛型的类反编译一遍,找出所有 ACC_BRIDGE 方法。