笔记01-Java基础01-泛型

# 擦除之后:桥方法、重载冲突与堆污染

约 20 分钟
目录

所属:Java 面试笔记 / 01-Java基础 / 01-泛型 | 学习计划:D1 前置:01-从getClass说起 | 后续:03-通配符与PECS(这篇讲擦除的补偿机制,那篇讲编译期怎么用通配符表达读写方向)


0. 本文要解决什么

上一篇讲的是擦除是什么:泛型只活在编译期,运行时 List<String>List<Integer> 都是 List

这一篇讲擦除之后怎么办。既然运行时没有泛型信息,那下面这些事编译器是怎么兜住的:

  • list.get(0) 返回的是 Object,为什么能直接赋给 String
  • 子类重写了泛型方法,多态为什么还能成立?
  • 为什么两个只差泛型参数的方法不能重载?
  • 堆污染的 ClassCastException 到底抛在哪里?
  • 泛型数组到底怎么创建才是对的?
  • 为什么 instanceof List<String> 编译不过?
  • 静态字段/静态方法为什么不能用类的泛型参数?

1. 擦除之后,编译器在背后插了 checkcast

static String first(List<String> list) {
return list.get(0);
}

擦除后 get 的签名是 Object get(int),那这一行凭什么能编译通过?

Terminal window
javac AfterErasureDemo.java
javap -c -p AfterErasureDemo
static java.lang.String first(java.util.List<java.lang.String>);
Code:
0: aload_0
1: iconst_0
2: invokeinterface #31, 2 // InterfaceMethod java/util/List.get:(I)Ljava/lang/Object;
7: checkcast #37 // class java/lang/String
10: areturn

(常量池编号随文件内容变化,看 checkcast 那一行即可。)

答案在 checkcast编译器在每个需要具体类型的位置自动插入类型检查指令

所以泛型的类型安全是两层:

做什么谁保证
编译期检查你写的类型对不对javac
运行期在取值处插入 checkcast 兜底编译器生成的字节码

这也解释了后面堆污染为什么”编译能过、运行才炸”——炸的就是这条 checkcast


2. 桥方法:多态为什么还能成立

2.1 问题从哪来

class Node<T> {
public T get() { return null; }
}
class StringNode extends Node<String> {
@Override
public String get() { return "s"; }
}

擦除后:

  • 父类方法:Object get()
  • 子类方法:String get()

这是两个签名不同的方法。那么 Node<String> n = new StringNode(); n.get(); 是怎么调到子类实现的?按方法表(vtable)找,父类那格存的是 get()Ljava/lang/Object;,而子类只提供了 get()Ljava/lang/String;,对不上,多态直接失效。

2.2 编译器的解法:合成桥方法

编译器在子类里额外生成一个方法,把调用转发给真正的方法:

class StringNode extends Node<String> {
public String get() { return "s"; }
// 编译器合成,源码里看不到,字节码里真实存在
// public Object get() { return this.get(); }
}

验证:

Terminal window
javap -c -p StringNode
class StringNode extends Node<java.lang.String> {
public java.lang.String get();
Code:
0: ldc #7 // String s
2: areturn
public java.lang.Object get();
Code:
0: aload_0
1: invokevirtual #9 // Method get:()Ljava/lang/String;
4: areturn
}

上面第二个 get()——参数是 Object、返回 Object 的那个——就是编译器合成的桥方法。

javap 默认不显示 ACC_BRIDGE 标志,加 -v 能看到 flags: (0x1041) ACC_PUBLIC, ACC_BRIDGE, ACC_SYNTHETIC

2.3 怎么在代码里识别

for (Method m : StringNode.class.getDeclaredMethods()) {
System.out.println(m + " bridge=" + m.isBridge() + " synthetic=" + m.isSynthetic());
}
public java.lang.String StringNode.get() bridge=false synthetic=false
public java.lang.Object StringNode.get() bridge=true synthetic=true

实用结论

  • 反射遍历方法时会多出一个桥方法,统计”类有几个方法”时别被它骗了。
  • getMethod("get") / getDeclaredMethod("get") 在桥方法存在时行为容易踩坑,框架里普遍用 Method.isBridge() 过滤,Spring 还有专门的 BridgeMethodResolver 来从桥方法找回原始方法。
  • 桥方法不是泛型独有的:协变返回类型Object clone()Person clone())同样会生成桥方法。

3. 重载冲突:擦除后签名撞车

void m(List<String> a) {}
void m(List<Integer> b) {}

编译错误:

name clash: m(java.util.List<java.lang.Integer>) in Demo and
m(java.util.List<java.lang.String>) in Demo have the same erasure

重载的判定依据是擦除后的签名(方法名 + 参数类型的擦除结果),上面两个擦除后都是 m(List),所以不构成重载,而是重复定义。

对比着记:

void m(List<String> a) {}
void m(Collection<String> b) {} // 合法:擦除后是 List 和 Collection,不同

想表达”同一件事、不同泛型参数”时,只能:

  1. 换方法名(mList / mSet);
  2. 换参数容器(List vs Set);
  3. 换参数个数或位置,把泛型参数挤到别的地方去。

这也是为什么很多 API 里会出现 of / ofList 这种看起来多余的命名——不是作者懒,是签名撞了。


4. 堆污染:异常抛在”读”的地方,不是”写”的地方

01 里只说了”堆污染”这个词,这里做完整实验。

4.1 最小复现

static void testHeapPollution() {
List<String> strings = new ArrayList<>();
List raw = strings; // 原始类型,绕开编译期检查
raw.add(123); // ① 写入成功,这里不报错
String s = strings.get(0); // ② ClassCastException 在这里抛
System.out.println(s);
}
Exception in thread "main" java.lang.ClassCastException:
class java.lang.Integer cannot be cast to class java.lang.String

关键点:污染发生在 ①,但异常抛在 ②。因为 add 的擦除签名是 add(Object),放什么进去 JVM 都不管;真正做类型检查的是编译器插在 get 之后的那条 checkcast(见 §1)。

所以 -Xlint:unchecked 警告不要关:

Terminal window
javac -Xlint:unchecked AfterErasureDemo.java
AfterErasureDemo.java:43: 警告: [unchecked] 对作为原始类型List的成员的add(E)的调用未经过检查
raw.add(123); // ① 成功,这里不报错

(第 8 节的完整代码不再用 @SuppressWarnings 把警告压掉,所以读者照抄命令就能看到同一条警告;示例里只把 broken(T...) 那条 vararg 警告留在第 8 节说明。)

4.2 换个马甲:方法参数传原始类型

static void fill(List<String> list) {
((List) list).add(123); // 编译警告,但编译通过
}

调用方完全看不出 fill 会往 List<String> 里塞 Integer,坑就是这么埋下的。

4.3 可变参数与 @SafeVarargs 的边界

// 安全:只读这个数组
@SafeVarargs
static <T> List<T> listOf(T... items) {
return new ArrayList<>(Arrays.asList(items)); // 转发给另一个 @SafeVarargs 方法,不写入
}
// 不安全:往数组里写了 "不是 T" 的东西
static <T> void broken(T... items) {
Object[] raw = items; // 合法:数组协变
raw[0] = new Object(); // 调用点传 String 字面量时,这里 ArrayStoreException
}
broken("a", "b"); // 调用点创建的是 String[],不是 Object[]

T... 底层是数组,而且这个数组是调用点创建的,运行时类型由调用点推断:传 String 字面量得到 String[]

所以 @SafeVarargs 不是编译器的验证结论,而是一个承诺:

  • 能加:方法只读这个数组(转发给 Arrays.asListList.of 这类)。
  • 不能加:方法往数组里写、或者把数组交给别人去写。

编译器不会替你检查这个承诺——写了,运行时照样炸。

使用限制:只能标注在 staticfinalprivate(JDK 9+)方法或构造器上,因为否则子类可以重写并破坏承诺。


5. 泛型数组:正确写法与它为什么带警告

5.1 直接 new T[] 不行,(T[]) new Object[n] 是骗自己

T[] a = new T[10]; // 编译错误:generic array creation
T[] b = (T[]) new Object[10]; // 编译警告,运行时元素类型还是 Object

第二种的运行时类型是 Object[]。你把它当 String[] 用,一旦有人往里放别的类型,异常还是抛在读取处

Integer[] ints = (Integer[]) new Object[]{"a", "b"}; // 读的时候就炸

5.2 唯一正确的方式:显式传 Class

import java.lang.reflect.Array;
@SuppressWarnings("unchecked")
static <T> T[] newArray(Class<T> type, int n) {
return (T[]) Array.newInstance(type, n);
}
String[] a = newArray(String.class, 3);
System.out.println(a.getClass()); // class [Ljava.lang.String;

为什么这条是安全的:Array.newInstance(type, n) 出来的数组元素类型真的是 T,不存在堆污染,只是编译器不知道,所以需要一个 @SuppressWarnings("unchecked")

这也解释了框架里那些”莫名其妙”的签名——为什么 TypeHandlerConverterBaseMapper 都要你把 Class<T> 传进去:运行时唯一的类型来源就是你显式给的那个 Class 对象

5.3 集合转数组的惯用法

List<String> list = new ArrayList<>();
String[] a1 = list.toArray(new String[0]); // 老写法
String[] a2 = list.toArray(String[]::new); // JDK 11+,等价且更清晰

toArray(T[]) 的泛型参数在这里的作用是”借你用一下元素类型”,JDK 内部也是走反射创建数组。传 new String[0] 不会白创建——JDK 会检查数组长度不够就另建一个,够用就直接用。


6. 为什么不能 instanceof List<String>

Object o = new ArrayList<>();
if (o instanceof List<String>) { } // 编译错误:illegal generic type for instanceof

原因很直接:运行时 ArrayList<String>ArrayList<Integer> 是同一个类,这个判断没有可判定的运行时语义。编译器宁可报错也不让你写出一个永远为真(或永远为假)的条件。

能写的是无界通配符:

if (o instanceof List<?>) { } // 合法:只判断"是不是 List"

如果真需要拿到泛型实参,只能回到声明处,用反射(01 §7 讲过):

Field f = Demo.class.getDeclaredField("names");
Type t = f.getGenericType();
if (t instanceof ParameterizedType pt) {
Type arg = pt.getActualTypeArguments()[0];
System.out.println(arg); // class java.lang.String
}

注意这读的是字段声明,不是实例。而且如果声明写的是 List<T>,这里拿到的是 TypeVariableT),不是具体类——这也是 Spring ResolvableType 存在的原因:它得沿着父类、接口一层层把 T 解出来。


7. 静态成员为什么不能用类的泛型参数

class Box<T> {
static T value; // 编译错误
static T get() { return null; } // 编译错误
}
error: non-static type variable T cannot be referenced from a static context

三条理由:

  1. 静态成员属于类,不属于实例T 是在”你拿到一个 Box<String> 引用”时才有意义的,静态上下文里没有这个信息。
  2. 擦除后每个类只有一份静态区。如果允许,Box<String>.valueBox<Integer>.value 会是同一块内存,类型根本没法保证。
  3. static 方法不能调用实例的泛型参数,因为它在类初始化阶段就可能被调用,此时连实例都没有。

正确写法:静态方法自己声明类型参数

class Box {
static <T> T get(Class<T> type) { return null; } // T 是方法自己的,与任何实例无关
}
class Box2<T> {
static <U> U pick(U a, U b) { return a; } // U 与方法绑定
}

区分清楚:<T> 写在类上叫类泛型参数,写在方法上叫方法泛型参数。静态方法想要泛型,只能走后者。


8. 完整验证代码

import java.lang.reflect.Array;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
public class AfterErasureDemo {
public static void main(String[] args) {
testCheckcast();
testBridge();
testHeapPollution();
testVarargs();
testArray();
testInstanceof(new ArrayList<String>());
}
// 1) checkcast:用 javap -c -p 看字节码
static String first(List<String> list) {
return list.get(0); // 编译后,get 指令后面跟着一条 checkcast java/lang/String
}
static void testCheckcast() {
System.out.println(first(Arrays.asList("a", "b")));
}
// 2) 桥方法
static void testBridge() {
Node<String> n = new StringNode();
System.out.println(n.get()); // s——靠桥方法维持多态
// getDeclaredMethods 的顺序不保证,看内容即可
for (Method m : StringNode.class.getDeclaredMethods()) {
System.out.println(m + " bridge=" + m.isBridge() + " synthetic=" + m.isSynthetic());
}
}
// 3) 堆污染:写入不报错,读取才炸
static void testHeapPollution() {
List<String> strings = new ArrayList<>();
List raw = strings;
raw.add(123); // ① 成功,这里不报错
try {
String s = strings.get(0); // ② 异常在这里抛
System.out.println(s);
} catch (ClassCastException e) {
System.out.println("读的时候才炸: " + e.getMessage());
}
}
// 4) 可变参数:数组由调用点创建
@SafeVarargs
static <T> List<T> listOf(T... items) {
return new ArrayList<>(Arrays.asList(items)); // 只读,安全
}
static <T> void broken(T... items) {
Object[] raw = items; // 合法:数组协变
raw[0] = new Object(); // 调用点传 String 字面量时 → ArrayStoreException
}
static void testVarargs() {
System.out.println(listOf("a", "b"));
try {
broken("a", "b");
} catch (ArrayStoreException e) {
System.out.println("变参数组被写脏: " + e.getMessage());
}
}
// 5) 泛型数组:显式传 Class
@SuppressWarnings("unchecked")
static <T> T[] newArray(Class<T> type, int n) {
return (T[]) Array.newInstance(type, n);
}
static void testArray() {
String[] a = newArray(String.class, 2);
a[0] = "x";
System.out.println(a.getClass() + " " + a[0]); // class [Ljava.lang.String; x
}
// 6) instanceof:只能无界通配符
static void testInstanceof(Object o) {
System.out.println(o instanceof List<?>); // true
// o instanceof List<String> // 编译错误:illegal generic type for instanceof
}
}
// 桥方法用到的两个类:放成顶层类,javap StringNode 的输出才干净
class Node<T> {
public T get() { return null; }
}
class StringNode extends Node<String> {
@Override
public String get() { return "s"; }
}

参考输出:

a
s
public java.lang.String StringNode.get() bridge=false synthetic=false
public java.lang.Object StringNode.get() bridge=true synthetic=true
读的时候才炸: class java.lang.Integer cannot be cast to class java.lang.String (java.lang.Integer and java.lang.String are in module java.base of loader 'bootstrap')
[a, b]
变参数组被写脏: java.lang.Object
class [Ljava.lang.String; x
true

编译期一共两条 unchecked 警告,都在声明/调用点而不是运行点:一条在 raw.add(123)(原始类型 Listadd),一条在 broken(T...) 的声明处——“参数化 vararg 类型 T 的堆可能已受污染”。只读的 listOf(T...) 不报,这是编译器对可变参数泛型能给出的唯一提示。(上面第 3 行 ClassCastException 的完整信息在 JDK 9+ 会附带 module/loader 说明,不同版本尾注略有差异。)

再补两条命令:

Terminal window
javap -c -p AfterErasureDemo | grep -A2 checkcast # 看编译器插的类型检查
javap -v StringNode | grep -A3 ACC_BRIDGE # 看桥方法的标志位(输出 flags: (0x1041) ACC_PUBLIC, ACC_BRIDGE, ACC_SYNTHETIC)

9. 面试问法

Q1:擦除之后 list.get(0) 凭什么能直接赋给 String

编译器在取值处自动插入了 checkcast 指令。泛型的类型安全是两层:编译期由 javac 检查你写的类型,运行期由编译器生成的 checkcast 兜底。堆污染的异常就是抛在这条指令上。

Q2:桥方法是什么?为什么需要它?

子类重写泛型方法后,擦除让父类方法签名变成 Object get()、子类方法是 String get(),两者签名不一致。为了让父类引用调用时仍能命中子类实现,编译器在子类合成一个 Object get() 的桥方法,把调用转发给 String get()。识别方式:Method.isBridge() / isSynthetic();协变返回类型的重写也会生成桥方法。

Q3:为什么两个只差泛型参数的方法不能重载?

重载的判定依据是擦除后的签名。m(List<String>)m(List<Integer>) 擦除后都是 m(List),编译器报 name clash ... have the same erasure。只能换方法名,或者换参数容器(List / Collection)。

Q4:堆污染是什么?为什么异常不在写入时抛?

往”名义上有泛型、实际擦除了”的容器里塞进类型不符的元素。写入时 add 的擦除签名是 add(Object),JVM 不检查;真正检查的是编译器插在读取处赋值点checkcast,所以异常抛在 String s = list.get(0) 那一行。这也是 -Xlint:unchecked 警告不能关的原因。

Q5:泛型数组怎么创建才是对的?

Array.newInstance(clazz, n) 并显式传入 Class<T>,因为运行时唯一的类型来源就是 Class 对象。(T[]) new Object[n] 只是把问题推迟成堆污染。集合转数组用 list.toArray(new T[0]) 或 JDK 11+ 的 list.toArray(T[]::new)

Q6:为什么不能 instanceof List<String>

运行时 List<String>List<Integer> 是同一个类,这个判断没有可判定的运行时语义,编译器直接报 illegal generic type for instanceof。只能写 instanceof List<?>;想拿泛型实参必须回到字段/方法/父类的声明处用反射读 getGenericType(),而且声明若是 List<T>,拿到的还是 TypeVariable

Q7:静态方法为什么不能用类的泛型参数?

静态成员属于类而不是实例,T 只在拿到具体实例(Box<String>)时才有意义;擦除后每个类只有一份静态区,Box<String>.valueBox<Integer>.value 会是同一块内存。静态方法要泛型,必须自己声明方法级类型参数:static <T> T get(Class<T> type)


10. 小结

  • 擦除之后,所有类型安全靠两个机制补:编译期检查 + 编译器插入的 checkcast
  • 桥方法(ACC_BRIDGE)是”擦除后多态仍然成立”的代价;反射里要记得过滤桥方法。
  • 重载看擦除后的签名,泛型参数不同不算重载。
  • 堆污染的异常抛在读取处,写入处静默通过。
  • 泛型数组只能 Array.newInstance(clazz, n)@SafeVarargs 是承诺,不是验证。
  • instanceof 不能带泛型实参,只能 List<?>
  • 静态成员不能用类泛型参数,静态泛型方法要自己声明 <T>

记忆钩子:“能擦的都擦了,擦不掉的靠 checkcast 和桥方法兜住。”


11. 下一步

  • 手跑第 8 节代码,对照参考输出。
  • javap -c -pcheckcastjavap -vACC_BRIDGE
  • 闭卷复述第 9 节 7 个问题。
  • 写下一篇:03-通配符与PECS? extends / ? super、PECS 原则、通配符捕获、Comparable<? super T> 为什么长这样)。

相邻笔记