目录
所属: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),那这一行凭什么能编译通过?
javac AfterErasureDemo.javajavap -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(); }}验证:
javap -c -p StringNodeclass 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=falsepublic 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 andm(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,不同想表达”同一件事、不同泛型参数”时,只能:
- 换方法名(
mList/mSet); - 换参数容器(
ListvsSet); - 换参数个数或位置,把泛型参数挤到别的地方去。
这也是为什么很多 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 警告不要关:
javac -Xlint:unchecked AfterErasureDemo.javaAfterErasureDemo.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 的边界
// 安全:只读这个数组@SafeVarargsstatic <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.asList、List.of这类)。 - 不能加:方法往数组里写、或者把数组交给别人去写。
编译器不会替你检查这个承诺——写了,运行时照样炸。
使用限制:只能标注在 static、final、private(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")。
这也解释了框架里那些”莫名其妙”的签名——为什么 TypeHandler、Converter、BaseMapper 都要你把 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>,这里拿到的是 TypeVariable(T),不是具体类——这也是 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三条理由:
- 静态成员属于类,不属于实例。
T是在”你拿到一个Box<String>引用”时才有意义的,静态上下文里没有这个信息。 - 擦除后每个类只有一份静态区。如果允许,
Box<String>.value和Box<Integer>.value会是同一块内存,类型根本没法保证。 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"; }}参考输出:
aspublic java.lang.String StringNode.get() bridge=false synthetic=falsepublic 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.Objectclass [Ljava.lang.String; xtrue编译期一共两条 unchecked 警告,都在声明/调用点而不是运行点:一条在 raw.add(123)(原始类型 List 的 add),一条在 broken(T...) 的声明处——“参数化 vararg 类型 T 的堆可能已受污染”。只读的 listOf(T...) 不报,这是编译器对可变参数泛型能给出的唯一提示。(上面第 3 行 ClassCastException 的完整信息在 JDK 9+ 会附带 module/loader 说明,不同版本尾注略有差异。)
再补两条命令:
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>.value和Box<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 -p找checkcast,javap -v找ACC_BRIDGE。 - 闭卷复述第 9 节 7 个问题。
- 写下一篇:
03-通配符与PECS(? extends/? super、PECS 原则、通配符捕获、Comparable<? super T>为什么长这样)。