Java 从入门到精通(七):泛型与类型系统——通配符、类型擦除与 PECS 原则

Java 从入门到精通(七):泛型与类型系统——通配符、类型擦除与 PECS 原则
神经蛙大部分人写泛型只停留在 List<String> 这一层,一旦 IDE 报出 capture of ?、name clash: ... have the same erasure、unchecked cast 就只能靠试。本文的目标是把这层窗户纸彻底捅破:擦除到底发生在哪一步、擦完之后字节码里还剩下什么、桥接方法是谁塞进去的、为什么 List<? extends T> 读得写不得。读完你应该能徒手画出泛型在编译期与运行期之间的那道分界线。
一、为什么需要泛型:Object 时代的三大问题
1.1 JDK 5 之前:一切皆 Object
2004 年 JDK 5 发布之前,Java 的集合框架只有一条路可走:容器内部统一用 Object 存储,取出来由调用方自己强转。下面这段代码在 JDK 1.4 时代是标准写法:
1 | // JDK 1.4 写法:原始类型(raw type),编译器只知道里面装的是 Object |
这段代码看起来人畜无害,但它把三个致命问题一起带进了程序。
1.2 三大问题
问题一:运行期 ClassCastException。 转换的正确性完全依赖于”程序员记性”。只要有一个地方塞错了类型,编译能过、测试可能也能过,然后在生产环境某条冷门分支上炸掉:
1 | List data = new ArrayList(); |
更糟的是,这个异常抛出的位置离真正出错的位置(那个 add 调用)可能隔着十万八千里,排查成本极高。
问题二:没有编译期检查,类型信息只能写在注释里。 为了不踩坑,老项目的做法是靠 Javadoc 约定:”本 List 只放 User 对象,请勿放入其它类型”。约定没有强制力,编译器也不会帮你验证,等于把类型安全交给了人类自律。
问题三:代码意图不清晰,强制转换噪音大。 (String) list.get(i) 这种转换在业务代码里到处横行,读者必须自己去推断容器里到底装着什么,代码的自我描述能力几乎为零。
1.3 泛型带来的两个收益
泛型(Generics)在 JDK 5 引入,本质上是把类型本身变成了参数,它一次性解决了上面三个问题:
收益一:编译期类型安全。 编译器记住了容器里装的是什么,往里塞错类型直接编译失败,把运行期爆炸提前到编译期拒绝。
收益二:代码复用 + 消除强制转换。 一套 ArrayList<E> 的逻辑可以服务所有元素类型,而且取出来就是目标类型,无需手写转换。
1 | // 泛型写法 |
需要立刻澄清一个常见误解:泛型不是把强制转换消灭了,而是把强制转换从”你手写”变成了”编译器代劳”。第四节的字节码会证明,String first = names.get(0); 这一行编译后依然有一条 checkcast 指令,只是它由 javac 自动插入,永远不可能写错。
| 维度 | JDK 5 之前(raw type) | JDK 5 之后(泛型) |
|---|---|---|
| 存储类型 | 统一 Object |
类型参数 E |
| 取出元素 | 手写 (T) 强转 |
自动插入 checkcast,源码无转换 |
| 错误暴露时机 | 运行期 ClassCastException |
编译期 incompatible types |
| 错误定位 | 离源头很远,难排查 | 直接定位到出错的 add 调用 |
| 类型信息载体 | Javadoc 注释 / 口头约定 | 方法签名,编译器可校验 |
| 复用性 | 一套逻辑适用所有类型 | 同样适用,且保留类型信息 |
二、泛型的三种使用形态与类型推断
2.1 泛型类
类型参数声明在类名之后、尖括号内:
1 | /** |
2.2 泛型方法
泛型方法的关键特征是:类型参数声明在修饰符与返回类型之间,这个位置是它与泛型类的唯一语法区别。
1 | public class GenericMethods { |
推断规则要点:编译器取所有实参类型的最小公共上界(lub, least upper bound)。上例中 Integer、Double、Long 的 lub 是 Number & Comparable<...>,所以 sum 能编译通过。如果推断结果不满足边界(比如传入 List<String>),编译报错。
2.3 泛型接口
1 | // 定义:接口名后带类型参数 |
2.4 类型参数命名约定
命名不是语法要求,但约定俗成,遵守它能让代码自解释:
| 符号 | 约定含义 | 典型出处 |
|---|---|---|
T |
Type,通用类型 | List<T>、Box<T> |
E |
Element,集合元素 | Collection<E> |
K |
Key,映射键 | Map<K, V> |
V |
Value,映射值 | Map<K, V> |
N |
Number,数值 | EnumMap<K extends Enum<K>, V> 旁支 |
S / U / V |
第二、三、四个类型参数 | Stream<T>、Collector<T,A,R> |
R |
Return,返回类型 | Function<T,R> |
? |
通配符,未知类型 | List<?> |
2.5 泛型方法与可变参数、静态方法
静态方法不能使用类上声明的类型参数(因为类类型参数属于实例维度,静态成员属于类维度,见 3.3)。静态方法想要泛型,只能自己声明为泛型方法:
1 | public class VarargsDemo { |
@SafeVarargs 的语义是”我保证不会往这个数组里塞入与声明类型不符的元素”,因此它只能用于 static、final 或构造器这三种不可被重写的方法上(JDK 9 起允许私有实例方法)。
2.6 菱形运算符与目标类型推断
JDK 7 引入菱形运算符 <>,让构造器调用不必重复写类型参数:
1 | // JDK 7 之前 |
JDK 8 进一步把推断能力从”赋值上下文”扩展到方法调用上下文(target typing),这是很多人以为 JDK 7 就有、其实没有的差异:
1 | static void process(List<String> names) { /* ... */ } |
另外两个细节:JDK 9 起菱形运算符可以用于匿名内部类(new ArrayList<>() {} 此前非法);var(JDK 10)与菱形运算符配合时,推断结果可能不是你想要的(会退化为 Object),需要显式给出类型参数。
2.7 显式类型见证(Type Witness)
当推断失效或推断结果不对时,可以在方法名前用 .<T> 显式指定,这就是类型见证:
1 | // 推断不出来:实参都是 null,缺少推断依据 |
判断何时需要类型见证的经验法则:当方法的所有实参都无法体现类型参数,或者实参类型存在多个相互冲突的候选时,编译器要么报错、要么推断出一个你不想要的类型。此时直接 .<T> 显式指定,一行解决,比反复调整实参类型划算得多。
三、类型擦除:规则、动因与四大限制
3.1 三条擦除规则
Java 的泛型是编译期特性,运行期不存在泛型。编译器按以下规则把类型参数从字节码中抹掉:
- 无界类型参数
<T>→ 擦除为Object; - 有界类型参数
<T extends A>→ 擦除为边界A; - 多边界
<T extends A & B & C>→ 擦除为最左边的第一个边界A。
1 | class Erasure<T> { // 无界:T -> Object |
注意第三条的实际影响:Multi<T> 擦除后是 Comparable,编译器会在所有用到 Serializable 方法的地方插入 checkcast。所以把最通用的接口放左边、把标记型接口(如 Serializable)放右边,可以减少运行期转换开销。
3.2 为什么 Java 选择擦除
答案是两个字:兼容。
JDK 5 发布时,全世界已经有海量的 Java 代码和字节码,JVM 规范里的类型系统、字节码指令集、以及所有已编译的 .class 文件都不认识泛型。如果要让 List<String> 和 List<Integer> 在运行期真正成为两个不同的类(即”C++ 模板式”的具体化泛型 reified generics),就必须改动 JVM 与字节码格式,让老版本的 JVM 无法运行新代码。
擦除式泛型(erasure-based generics)的设计让泛型只存在于源码与编译器的检查中,编译出的字节码与 JDK 1.4 时代完全同构。带来的直接好处是:
- 二进制兼容:JDK 5 编译的
ArrayList.class能被 JDK 1.4 的 JVM 加载(理论上); - 迁移兼容:老代码
List list = new ArrayList();与新代码List<String> l = list;可以混用,只会产生unchecked警告而非编译错误,允许项目渐进式迁移; - 无额外内存开销:不会像 C++ 模板那样为每套类型参数生成一份代码副本(代码膨胀)。
代价就是我们下面要吃的那些亏:运行期拿不到 T 的真实类型、instanceof 失效、数组创建受限等。
3.3 擦除带来的限制
1 | public class Limits<T> { |
限制四的根因很值得说清楚:类的类型参数 T 是在 new Limits<String>() 时确定的,属于”实例维度”;而静态成员属于”类维度”,被所有实例共享。一个 Limits<String> 和一个 Limits<Integer> 在 JVM 里是同一个 Limits.class,静态字段只有一份,它根本不可能同时是 String 类型和 Integer 类型。
3.4 绕过的四种手段
| 限制 | 绕过方案 | 核心思路 |
|---|---|---|
不能 new T() |
Supplier<T> 工厂 / Class<T>.newInstance() |
把”构造行为”作为参数传进来 |
不能 new T[] |
Array.newInstance(Class, n) + 强转 |
反射在运行期拿到真实元素类型 |
不能 instanceof T |
传入 Class<T> token 后用 isInstance() |
用 Class 对象代替类型名 |
反射拿不到 T |
ParameterizedType(见 7.5) |
从父类的泛型签名里反查 |
1 | import java.lang.reflect.Array; |
四、字节码证据:Signature、checkcast 与桥接方法
前面说的都是”编译器会这么做”,这一节把 javap 的输出摆出来,眼见为实。
4.1 Signature 属性:擦除后残留的”类型化石”
泛型信息没有完全消失,而是被放进了 class 文件的 Signature 属性里。它是 JLS 规定的可选属性,JVM 执行时完全忽略它,只有编译器、IDE 和反射 API 会读。
1 | // 源文件 Box.java |
执行 javac Box.java && javap -v -p Box.class,关键输出如下:
1 | // ===== javap -v -p Box.class(节选,省略常量池与无关行)===== |
这份输出说明了三件事:
descriptor(JVM 真正使用的签名)里T一律变成了Object—— 擦除确实发生了;Signature属性里保留了<T:Ljava/lang/Object;>、()TT;等完整泛型信息 —— 信息没丢,只是降级为元数据;get()方法体里没有checkcast,因为擦成Object后返回Object是合法的 —— 转换被推迟到了调用方。
4.2 checkcast 的插入位置
1 | // 源文件 UseBox.java |
1 | // ===== javap -c UseBox.class(main 方法)===== |
结论:checkcast 不是插在被泛型类的方法内部,而是插在调用方拿到返回值之后、赋值给具体类型变量之前。这也解释了 1.3 节那句话——泛型没有消灭强制转换,只是把转换的书写责任从程序员转移给了编译器。
顺带解释了另一个现象:box.get().length() 能编译通过、((Object) box.get()).length() 不行,都是因为 checkcast 插入位置决定了表达式的静态类型。
4.3 桥接方法的生成过程
桥接方法(bridge method)是擦除机制为了保住多态而打的补丁。看这个例子:
1 | import java.util.ArrayList; |
问题来了:ArrayList<E> 擦除后的方法是 boolean add(Object)。如果有人这样调用:
1 | List rawOrObjectList = new MyList(); |
JVM 在做虚方法分派时,只认识 add(Object)。而 MyList 里只有 add(String),它并没有真正覆盖 ArrayList.add(Object)。如果不做处理,MyList 的多态就断了——通过父类引用调用时会直接掉进 ArrayList 的原始实现。
编译器因此自动生成一个桥接方法:
1 | // ===== javap -p -c MyList.class(节选)===== |
三个观察点:
flags里的ACC_BRIDGE和ACC_SYNTHETIC是桥接方法的身份证,源码里看不到它,反射时可以用Method.isBridge()过滤掉;- 桥接方法体的三板斧是
checkcast→ 转发调用 → 返回。这也意味着通过原始类型往List<String>里塞错误元素时,ClassCastException是在桥接方法里抛出的; Comparable<T>是另一个高发区:
1 | class MyInt implements Comparable<MyInt> { |
没有这个桥接方法,Collections.sort(list) 里对所有 Comparable 的调用(擦除后是 compareTo(Object))就找不到 MyInt 的实现。
4.4 桥接方法引发的重载冲突
桥接方法是编译器”偷偷”加的,它可能与你手写的某个方法擦除后撞车,这就是经典的 name clash:
1 | import java.util.List; |
规避方案:改名(最实用)、或者让两个方法有不同的参数个数/非泛型参数类型,从根上错开擦除后的签名。
桥接方法还有一个实战副作用:用反射遍历方法时会拿到两份 add / compareTo。很多人写过的”通过反射找 setter”的代码突然匹配到了 ACC_SYNTHETIC 的桥接方法,导致转换失败。正确做法是在遍历时加一句 if (m.isBridge() || m.isSynthetic()) continue;。
五、通配符体系:无界、上界与下界
5.1 出发点:泛型是不变的
先看一个必须接受的事实:
1 | List<String> strings = new ArrayList<>(); |
String 是 Object 的子类,但 List<String> 不是 List<Object> 的子类——这叫泛型的不变性(invariance)。原因很直白:如果允许,objects.add(new Object()) 就能往一个只该装 String 的容器里塞进 Object,类型安全当场崩塌。
但不变性太僵硬了:我就是想写一个”能接受任何 List<Number 子类> 的方法”,怎么办?通配符就是答案。
5.2 无界通配符 <?> 与 List<Object> 的区别
这是面试重灾区,也是很多人写错的原因:
| 类型 | 含义 | list.add("x") |
Object o = list.get(0) |
类型安全 |
|---|---|---|---|---|
List (原始类型) |
关闭泛型检查,完全裸奔 | 允许(无检查) | 允许 | 无,会产生 unchecked 警告 |
List<Object> |
明确装 Object 的容器 |
允许 | 允许 | 有,但只能装 Object |
List<?> |
装某种未知类型的容器 | 编译错误 | 允许 | 有,且最灵活 |
1 | List<?> unknown = new ArrayList<String>(); |
为什么 add 会被拒?因为 unknown 可能是 List<String>、也可能是 List<Integer>,编译器无法确认你塞进去的东西合法,索性一律拒绝(除 null)。
List<?> 的正确使用场景是:只做与元素类型无关的操作(size()、clear()、判空遍历当 Object 用),或者用于 Class<?> 这种”类型本身不确定”的场合。
5.3 上界 <? extends T>:只能读
1 | List<? extends Number> nums = new ArrayList<Integer>(); // 任何 Number 的子类型的 List |
为什么写不进去:nums 的实际类型可能是 List<Double>。如果 add(Integer) 被允许,一个 List<Double> 里就混进了 Integer,类型安全破产。所以编译器干脆禁止一切写入——它无法在编译期确定”实际元素类型”是什么。
5.4 下界 <? super T>:只能写(读就只剩 Object)
1 | List<? super Integer> ints = new ArrayList<Number>(); // Integer 或其任一父类型的 List |
为什么能写:下界保证了实际元素类型一定是 Integer 或它的某个父类,所以放 Integer(子类实例)进去永远合法(里氏替换)。
为什么读不出来:编译器只知道”元素类型是 Integer 的某个父类”,但不知道具体是哪一个,只能保守地当成 Object。
5.5 通配符捕获与 helper 方法
List<?> 的元素类型虽然未知,但对同一次调用而言,它是一个确定的类型。编译器把它记为一个内部记号,报错信息里写作 capture of ?。你可以原样取出来再放回去吗?不行,因为两次出现的 ? 被认为是不同的捕获:
1 | public static void swapFirstTwo(List<?> list) { |
解决办法是通配符捕获(wildcard capture):用一个私有的泛型 helper 方法把 ? 捕获成一个具名类型参数 E,在 helper 内部 E 就是确定的,读写都合法。
1 | public class Swap { |
这个技巧来自《Effective Java》,是处理”通配符列表内部交换/重排”的标准套路,JDK 自身的 Collections.reverse、Collections.shuffle 也用了同样的思想(虽然它们用的是精确类型参数)。
5.6 通配符不该出现在返回值上
1 | // 反面教材 |
原因很简单:返回值是调用方唯一能拿到类型信息的入口。返回通配符等于把”不能写”的枷锁强加给所有调用者,而且调用方还得在自己的代码里到处写通配符,API 的复杂度会传染。这条规则在《Effective Java》第 31 条里被明确写为”do not use wildcard types as return types”(不要把通配符类型用作返回类型)。
六、PECS 原则:从推理到 API 设计
6.1 PECS 是什么
PECS = Producer Extends, Consumer Super。一句话记忆:
- 如果一个泛型容器产出(producer)数据给你用(你从中读取
T)→ 用<? extends T>; - 如果一个泛型容器消费(consumer)你给的数据(你往里写入
T)→ 用<? super T>; - 既产出又消费 → 别用通配符,用精确类型
T。
6.2 推理过程(而不是死记)
推理的起点永远是那句自问:“这个变量的实际类型可能是哪些?当前操作对每一种可能都安全吗?”
对于 List<? extends Number> nums:实际类型可能是 List<Integer>、List<Double>、List<Long>……
- 读:不管哪一种,取出来的元素都一定是
Number→ 对每一种可能都安全 → 允许; - 写:
add(Integer)对List<Double>不安全 → 存在反例 → 禁止。
对于 List<? super Integer> ints:实际类型可能是 List<Integer>、List<Number>、List<Object>……
- 写:
add(Integer)对这三种都安全(Integer 是 Integer/Number/Object 的子类型)→ 允许; - 读:
List<Object>里取出来的未必是Integer→ 存在反例 → 只能赋给Object。
1 | import java.util.*; |
6.3 Collections.copy 源码里的 PECS
JDK 自己的 java.util.Collections.copy 是这个原则最标准的示范:
1 | public static <T> void copy(List<? super T> dest, List<? extends T> src) { |
如果写成 copy(List<T> dest, List<T> src),那么”把 List<Integer> 拷进 List<Number>“这个完全合理的需求就实现不了——T 无法同时是 Integer 和 Number。PECS 泛型签名把它变成了合法调用。
Collections.max 的签名演进同样体现了这一点,这个签名常被称作”泛型签名设计的天花板”:
1 | // JDK 早期:过于严格,要求 T 必须直接实现 Comparable<T> |
6.4 什么时候用精确类型参数而不是通配符
《Effective Java》第 31 条的完整表述是”用有限制通配符来提升 API 的灵活性”,但它同时给了三条刹车:
- 返回值不用通配符(见 5.6);
- 类型参数在方法签名中出现多次且需要互相关联时,必须用精确类型参数。例如
<T> void swap(List<T> list, int i, int j),如果写成void swap(List<?> list, int i, int j),两次?是不同捕获,方法体根本没法写——5.5 节的 helper 就是为了让签名回到精确类型参数; - 通配符会让方法体变复杂时,权衡收益。通配符的好处是”调用方灵活”,代价是”实现方难受”。如果 API 是内部使用的,精确类型参数往往更好读。
一个判断口诀:先看类型参数在签名里出现了几次、有没有”两个位置必须是同一个类型”的约束。有约束 → 精确 T;无约束且只读 → ? extends;无约束且只写 → ? super。
七、常见坑与实战技巧
7.1 泛型数组的创建
new T[]、new List<String>[10] 都是编译错误(数组是协变的、且运行期需要知道元素类型,与擦除天然冲突)。可行的三种写法:
1 | import java.lang.reflect.Array; |
List<String>[] 这个类型是合法的(可以作为变量类型、参数类型、返回值类型),只是不能用 new 直接创建。它最大的坑在于与 Object[] 的互操作:
1 | List<String>[] a = (List<String>[]) new List<?>[1]; |
ArrayStoreException 是 JVM 数组协变留下的最后一个安全阀,但泛型擦除让它检查不到元素内部的泛型参数——这也是 7.1 里”方案三不安全”的真正含义。
7.2 泛型与重载 / 重写的冲突
1 | class Base<T> { |
重写时的判断标准是擦除后的签名是否一致,而不是源码里写的是否一致。IDE 生成的 @Override 注解能通过,就说明编译器判定它是重写。
7.3 自限定类型与递归类型边界
JDK 里最著名的自限定类型(self-bounded type)是 Enum 的声明:
1 | // java.lang.Enum 的真实声明(JDK 17) |
E extends Enum<E> 这种”类型参数约束到自己”的写法叫递归类型边界(recursive type bound)。它解决的是:让 compareTo、getDeclaringClass 的签名自动适配到具体的枚举子类,从而保证 Color.RED.compareTo(Color.GREEN) 合法,而 Color.RED.compareTo(Size.BIG) 编译不过——即同类型之间才可比较。
自己写递归边界的典型场景是”链式 setter 的父类”:
1 | abstract class Node<Self extends Node<Self>> { |
7.4 泛型异常:能 throws,不能 catch
1 | public class GenericException { |
为什么 catch (T e) 不行?因为 catch 的多路匹配是运行期行为,JVM 需要真实的类来做异常表匹配,而 T 在运行期已经消失了。这个限制也直接导致了 7.5 里”用 lambda 包装受检异常”这类变通方案的出现。
7.5 泛型与反射:ParameterizedType 与 TypeReference
擦除让 list.getClass() 永远返回 java.util.ArrayList,拿不到 String。但 4.1 节说过,泛型信息保留在 Signature 属性里,反射可以把它读出来——这正是 JSON 库反序列化泛型对象的技术基础。
1 | import java.lang.reflect.*; |
关键点:new TypeReference<List<User>>() {} 后面的那对花括号绝不是可省略的装饰。只有创建匿名子类,才会生成一个真实 class 文件,把它父类的完整泛型签名写进 Signature 属性,反射才有东西可读。直接 new TypeReference<List<User>>() 则拿不到任何信息。
这也是为什么 Gson.fromJson(json, new TypeToken<List<User>>(){}.getType()) 必须带 {}。
八、高频面试题
| # | 问题 | 要点答案 |
|---|---|---|
| 1 | 类型擦除发生在什么时候? | 编译期(javac 生成字节码时)。JVM 加载类时完全看不到泛型,泛型信息只以 Signature 属性形式存在,供编译器与反射读取 |
| 2 | 擦除的三条规则是什么? | 无界→Object;有界→边界类型;多边界 T extends A & B → 最左边的 A |
| 3 | 为什么 Java 用擦除而不是像 C++ 那样具体化泛型? | 向后兼容:不改动 JVM 与字节码格式,让 JDK 5 之前的代码与类库无需重编译即可共存;同时避免模板式的代码膨胀 |
| 4 | 为什么不能 new T()? |
擦除后变成 new Object(),语义完全不对。绕过方式:Supplier<T> 工厂或 Class<T>.newInstance() |
| 5 | 为什么不能创建泛型数组 new T[] / new List<String>[10]? |
数组是协变的且运行期需要确切的元素类型做 ArrayStoreException 检查,与擦除冲突。绕过:Array.newInstance 或 (List<String>[]) new List<?>[n] |
| 6 | checkcast 插在哪里? |
插在调用方,即泛型方法返回之后、赋值给具体类型变量之前。泛型类内部的方法体里没有转换 |
| 7 | 桥接方法为什么必须存在? | 擦除让子类方法的签名与父类擦除后的签名不一致,若不生成 ACC_BRIDGE + ACC_SYNTHETIC 的转发方法,通过父类/接口引用调用时多态会失效(Comparable、ArrayList 子类的重写都是典型场景) |
| 8 | PECS 是什么?怎么理解? | Producer Extends, Consumer Super。只读(产出)用 ? extends,只写(消费)用 ? super,既读又写用精确类型参数 T |
| 9 | 为什么 List<? extends T> 不能 add? |
它的实际类型可能是 T 的任意子类型的 List,写入 T(或其子类)对其中某些实际类型不安全,编译器无法证明安全故一律拒绝,null 除外 |
| 10 | List<Object> 与 List<?> 的区别? |
List<Object> 是装 Object 的容器,可以 add(new Object());List<?> 是装”某种未知类型”的容器,除 null 外不能写入,但能接受任何 List<X> 赋值 |
| 11 | List<String> 能赋值给 List<Object> 吗? |
不能。泛型是不变的(invariant),若允许则可通过 List<Object> 引用往里塞任意 Object,破坏类型安全 |
| 12 | 通配符捕获是什么? | List<?> 的未知类型在一次调用中被编译器记为 capture of ?。要对其做写入操作,需用私有泛型 helper 方法 static <E> void helper(List<E> l) 把 ? 捕获为具名 E |
| 13 | 静态方法能使用类的类型参数吗? | 不能。类类型参数属于实例维度,静态成员属于类维度,所有实例共享一份静态成员。解决方案是把静态方法自己声明为泛型方法 |
| 14 | instanceof 能用泛型吗? |
o instanceof List<String> 非法(非 reifiable 类型),o instanceof List<?> 合法。需要判断元素类型时传入 Class<T> token 用 isInstance() |
| 15 | 反射如何拿到运行期丢失的泛型信息? | 通过 Field.getGenericType()、Method.getGenericReturnType()、Class.getGenericSuperclass() 得到 ParameterizedType,再取 getActualTypeArguments()。TypeReference/TypeToken 的匿名子类({})就是靠 getGenericSuperclass() 实现 |
| 16 | 为什么 catch (T e) 非法而 throws T 合法? |
throws 是编译期声明,可以用类型变量;catch 需要运行期按真实类匹配异常表,而 T 在运行期已消失 |
| 17 | Enum<E extends Enum<E>> 为什么要这么写? |
递归类型边界,让 compareTo、getDeclaringClass 的签名自动绑定到具体枚举类型,保证只有同类型枚举之间可比较 |
| 18 | 什么是堆污染(heap pollution)? | 参数化类型的变量引用了非该类型的对象,典型来源是泛型可变参数 T...(擦除后是 Object[])。用 @SafeVarargs 标注并避免数组写入可缓解 |
最后给一条工程建议:新代码里永远不要使用原始类型(raw type)。List 而不是 List<?> 会彻底关闭泛型检查,让 1.2 节里的三个老问题原封不动地回来。偶发的 unchecked 警告如果确认安全,用 @SuppressWarnings("unchecked") 配合注释说明原因,而不是放任警告堆积——当警告多到没人看时,真正危险的那一处也就藏住了。
到这里,泛型的编译期世界与运行期世界之间那条分界线应该已经很清楚了:编译器在源码层面做全套类型检查并生成桥接方法与 checkcast,然后亲手把类型参数从字节码的 descriptor 里抹掉,只把原始签名留在 Signature 属性里当化石。理解了这一点,capture of ?、name clash、unchecked cast 这些报错就不再是玄学,而是擦除机制的必然产物。下一篇进入 Java 8 的函数式世界,届时你会发现 lambda 与泛型、类型推断之间的关系远比想象中紧密。

















