Lesson 40 · Java 高级特性
泛型深入:擦除机制、通配符 PECS 与桥方法
面试官:List<String> 和 List<Integer> 是同一个类型吗?
面试官微笑着问了一个看起来很简单的问题:
List<String> strList = new ArrayList<>();
List<Integer> intList = new ArrayList<>();
// 问:下面这行代码能编译通过吗?
boolean same = strList.getClass() == intList.getClass();
答案是:能编译通过,而且结果是 true。
大多数人第一次看到这个结论会愣住——List<String> 和 List<Integer> 在编译器眼里是两个截然不同的类型,但在 JVM 眼里,它们都是 java.util.ArrayList,没有任何区别。
读完这篇你会明白:为什么 Java 选择了"擦除"而不是"具体化"泛型,? extends 和 ? super 到底怎么选,以及编译器偷偷生成的桥方法长什么样。
泛型基础:类型参数、有界类型与多重约束
泛型的核心思想很简单:把类型当作参数,让代码在编译期就获得类型安全检查。
// ① 无界类型参数 —— T 可以是任何引用类型
public class Box<T> {
private T value;
public T get() { return value; }
public void set(T value) { this.value = value; }
}
// ② 有界类型参数 —— T 必须是 Comparable 的子类型
public class SortedBox<T extends Comparable<T>> {
private T value;
public int compare(T other) {
return value.compareTo(other); // 编译器确认 compareTo 存在
}
}
// ③ 多重约束 —— T 必须同时满足多个条件(最多一个类 + 多个接口)
public class Tagged<T extends Number & Comparable<T> & Serializable> {
private T value;
}
多重约束中,类必须写在第一个,接口写在后面。因为 Java 只支持单继承,所以类最多一个。如果你写了 <T extends Comparable & Number>(接口在前,类在后),编译器会报错。
擦除机制:泛型信息的"消失术"
Java 泛型是编译期机制。编译器在生成字节码时会把所有泛型信息擦掉,替换为原始类型(Raw Type)。这就是所谓的类型擦除(Type Erasure)。
// ──── 你写的代码 ────
List<String> list = new ArrayList<>();
list.add("hello");
String s = list.get(0);
// ──── 编译器生成的字节码(擦除后) ────
List list = new ArrayList(); // <String> 被擦掉了
list.add("hello");
String s = (String) list.get(0); // 编译器自动插入强制转型
擦除的规则很简单:
• 无界类型参数
<T> → 擦除为 Object• 有界类型参数
<T extends Foo> → 擦除为 Foo(第一个边界)• 多重约束
<T extends Foo & Bar> → 擦除为 Foo(第一个边界,所以类要写在前面)
擦除带来了一系列限制,这些是面试高频考点:
// ❌ 限制1:不能创建泛型类型的数组
T[] arr = new T[10]; // 编译错误!T 已被擦除,JVM 不知道数组元素类型
// ❌ 限制2:不能对泛型类型使用 instanceof
if (obj instanceof List<String>) // 编译错误!运行时 List<String> 和 List<Integer> 无法区分
// ❌ 限制3:不能使用基本类型作为类型参数
List<int> nums; // 编译错误!只能用 List<Integer>(自动装箱)
// ❌ 限制4:不能创建泛型类型的实例
T obj = new T(); // 编译错误!不知道 T 的无参构造器是否存在
// ❌ 限制5:泛型类的静态成员不能使用类的类型参数
public class Box<T> {
static T sharedValue; // 编译错误!静态成员属于类本身,不属于某个实例化的 T
}
虽然擦除让运行时丢失了泛型信息,但 Guava 的 TypeToken 利用匿名子类保留了具体类型——第 6 站会详细讲解。
通配符:? extends vs ? super 与 PECS 原则
泛型最让人困惑的部分不是擦除,而是通配符。面试中 80% 的泛型问题都围绕 ? extends 和 ? super 展开。
先看一个直觉陷阱:
List<Integer> intList = new ArrayList<>();
List<Number> numList = intList; // ❌ 编译错误!
// Integer 是 Number 的子类,但 List<Integer> 不是 List<Number> 的子类
// 如果允许赋值,下面这行就会破坏类型安全:
numList.add(3.14); // 往 Integer 列表里塞了 Double!
Java 的泛型是不变的(invariant):List<Integer> 和 List<Number> 之间没有继承关系。为了让泛型在一定条件下具备灵活性,Java 引入了通配符:
// ═══ ? extends T(上界通配符)═══ "Producer Extends"
// 含义:列表里装的是 T 或 T 的某个子类 → 只能读,不能写
public static double sum(List<? extends Number> list) {
double total = 0;
for (Number n : list) { // ✅ 读出来一定是 Number 或其子类,安全
total += n.doubleValue();
}
// list.add(42); // ❌ 编译错误!不知道具体是 Integer 还是 Double
return total;
}
// ═══ ? super T(下界通配符)═══ "Consumer Super"
// 含义:列表里装的是 T 或 T 的某个父类 → 只能写,读出来是 Object
public static void addNumbers(List<? super Integer> list) {
list.add(1); // ✅ 写入 Integer 一定安全(父类都能装 Integer)
list.add(2);
// Integer val = list.get(0); // ❌ 编译错误!读出来可能是 Number 或 Object
Object obj = list.get(0); // ✅ 只能当 Object 用
}
| 通配符 | 角色 | 能读? | 能写? | 口诀 |
|---|---|---|---|---|
? extends T | 生产者(Producer) | 读为 T | 不能写 | Producer Extends |
? super T | 消费者(Consumer) | 读为 Object | 写 T 或其子类 | Consumer Super |
无通配符 <T> | 既读又写 | 读为 T | 写 T | 精确类型 |
public static <T> void copy(List<? super T> dest, List<? extends T> src) {
// src 是生产者(extends):只读取
// dest 是消费者(super):只写入
ListIterator<? super T> di = dest.listIterator();
ListIterator<? extends T> si = src.listIterator();
while (si.hasNext()) {
di.next();
di.set(si.next()); // 从 src 读,往 dest 写
}
}
桥方法:编译器偷偷生成的"替身"
擦除会破坏多态。考虑下面这个场景:
public class Node<T> {
public T data;
public void setData(T data) { this.data = data; }
}
public class StringNode extends Node<String> {
@Override
public void setData(String data) { this.data = data; }
}
你写了 @Override,意思是"覆盖父类的 setData"。但问题来了:
• 父类
Node 的 setData 擦除后 → setData(Object)• 子类
StringNode 的 setData 擦除后 → setData(String)• 参数类型不同 → 这不是覆盖,而是重载!
如果不做任何处理,多态就坏了:Node<String> node = new StringNode(); node.setData("hi"); 调用的会是父类的 setData(Object),而不是子类的 setData(String)。
编译器的解决方案是生成一个桥方法(Bridge Method):
// 你写的 setData(String) —— 真正的方法
public void setData(java.lang.String);
Code:
0: aload_0
1: aload_1
2: putfield #2 // Field data:Ljava/lang/String;
5: return
// 编译器生成的桥方法 setData(Object) —— 用于保持多态
public void setData(java.lang.Object); // ← 参数类型匹配父类
Code:
0: aload_0
1: aload_1
2: checkcast #3 // class java/lang/String ← 强制转型
5: invokevirtual #4 // Method setData(String) ← 转调真正的方法
8: return
在反射中,Class.getDeclaredMethods() 会返回桥方法。如果你在做反射框架(如 Spring),必须用 method.isBridge() 过滤掉桥方法,否则会把同一个方法处理两次,导致参数类型不匹配。
高级用法:TypeToken、自限定类型与捕获转换
一、TypeToken —— 绕过擦除获取运行时类型
擦除让 List<String>.class 不合法,但有一个技巧可以保留运行时泛型信息:
// Guava 的 TypeToken(或 Gson 的 TypeToken)
TypeToken<List<String>> token = new TypeToken<List<String>>() {};
// 匿名子类 ↑↑↑
Type type = token.getType();
// → java.util.List<java.lang.String> 泛型信息完整保留!
原理:当你创建匿名子类 new TypeToken<List<String>>() {} 时,父类的泛型参数会被记录在字节码的 Signature 属性中(不受擦除影响),运行时可以通过反射的 getGenericSuperclass() 读取。
匿名子类 → 泛型参数成为超类签名的一部分 → 字节码 Signature 属性保留完整类型信息 → 运行时反射读取
二、自限定类型(Self-Bounded Types)
// 经典的自限定类型:Comparable<T> 要求 T 能与自身比较
public class Student implements Comparable<Student> {
private int score;
@Override
public int compareTo(Student other) {
return Integer.compare(this.score, other.score);
}
}
// 泛型工具方法 —— 要求 T 能与自身比较
public static <T extends Comparable<T>> T max(T a, T b) {
return a.compareTo(b) >= 0 ? a : b;
}
// 调用
Student winner = max(student1, student2); // 编译期就保证类型安全
三、捕获转换(Capture Conversion)
// 通配符不能直接修改元素——但可以用捕获转换"捕获"通配符类型
public static void swap(List<?> list, int i, int j) {
// list.set(i, list.set(j, list.get(i))); // ❌ 通配符不允许 set
swapHelper(list, i, j); // 通过泛型方法捕获类型
}
private static <T> void swapHelper(List<T> list, int i, int j) {
list.set(i, list.set(j, list.get(i))); // ✅ T 是确定类型,可以读写
}
捕获转换的本质:把通配符 ? 转化为一个具体的类型参数 T,让编译器能进行类型推断。这是 Effective Java 中推荐的处理通配符限制的标准技巧。
总结:泛型核心规则与常见错误
| 主题 | 规则 | 面试关键词 |
|---|---|---|
| 擦除 | 泛型信息编译后擦除为原始类型,List<String> 和 List<Integer> 运行时是同一个类 | Type Erasure |
| 擦除边界 | 无界 <T> → Object;有界 <T extends Foo> → Foo | Bound |
| 通配符 extends | ? extends T:只读不写(Producer Extends) | 上界通配符 |
| 通配符 super | ? super T:只写,读出 Object(Consumer Super) | 下界通配符 |
| 桥方法 | 编译器自动生成的方法,保持擦除后的多态性,带 ACC_BRIDGE 标志 | Bridge Method |
| TypeToken | 利用匿名子类 + Signature 属性绕过擦除,获取运行时泛型信息 | Super Type Token |
| 自限定类型 | <T extends Comparable<T>> 保证类型能与自身比较 | Self-Bounded |
| 捕获转换 | 把通配符 ? 转化为类型参数 T,解决通配符不能写入的限制 | Capture Conversion |
常见错误清单
// ❌ 错误1:把泛型集合赋值给更宽泛的泛型集合
List<Integer> ints = new ArrayList<>();
List<Number> nums = ints; // 编译错误!用 List<? extends Number>
// ❌ 错误2:在 ? extends 通配符集合上调用 add
List<? extends Number> list = ...;
list.add(42); // 编译错误!extends 只读
// ❌ 错误3:对泛型类型使用 instanceof
if (obj instanceof List<String>) // 编译错误!运行时无法区分
// ❌ 错误4:创建泛型数组
T[] arr = new T[10]; // 编译错误!用 Object[] 或 Array.newInstance
// ❌ 错误5:反射时忘记过滤桥方法
for (Method m : clazz.getDeclaredMethods()) {
if (!m.isBridge()) { ... } // 必须排除桥方法
}
本篇核心知识点回顾
- 类型擦除:泛型是编译期机制,运行时
List<String>和List<Integer>是同一个类 - 擦除后果:不能创建泛型数组、不能用 instanceof、不能用基本类型、不能 new T()
- PECS 原则:
? extends T只读(生产者),? super T只写(消费者) - 桥方法:编译器自动生成的 synthetic 方法,用 checkcast + invokevirtual 保持多态
- TypeToken:利用匿名子类 + Signature 属性绕过擦除,Gson/Jackson 反序列化的核心原理
- 自限定类型:
<T extends Comparable<T>>是递归泛型约束的经典模式 - 捕获转换:通过泛型方法把通配符
?转化为具体类型参数T
1. 擦除本质:Java 泛型是编译期检查,运行时泛型信息被擦除为原始类型,
List<String> 和 List<Integer> 运行时 getClass() 相同2. 设计原因:Java 5 引入泛型时要向后兼容,擦除方案让新旧代码互操作
3. 通配符 PECS:? extends 只读(生产者),? super 只写(消费者),JDK 的 Collections.copy 是经典用例
4. 桥方法:编译器生成桥方法保持擦除后的多态,javap 可见 ACC_BRIDGE 标志
5. 绕过擦除:TypeToken 利用匿名子类的 Signature 属性保留运行时泛型信息