Lesson 40 · Java 高级特性

泛型深入:擦除机制、通配符 PECS 与桥方法

高级·🔥 极高·#Java·#泛型

第 1 站

面试官:List<String> 和 List<Integer> 是同一个类型吗?

面试官微笑着问了一个看起来很简单的问题:

InterviewQuestion.java
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 面试中最有区分度的考点。

读完这篇你会明白:为什么 Java 选择了"擦除"而不是"具体化"泛型,? extends? super 到底怎么选,以及编译器偷偷生成的桥方法长什么样。

第 2 站

泛型基础:类型参数、有界类型与多重约束

泛型的核心思想很简单:把类型当作参数,让代码在编译期就获得类型安全检查。

GenericBasics.java · 三种泛型写法
// ① 无界类型参数 —— 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;
}
泛型约束层级:从无界到多重约束 <T> 无界 · 等价于 Object <T extends Comparable<T>> 有界 · 单接口约束 <T extends Number & ...> 多重约束 · 类 + 接口 更强 更强 约束越强 → 擦除后的上界越具体 → 生成的代码越高效 例:<T extends Number> 擦除后 T → Number(而非 Object),避免了多余的强制转型
图 1泛型约束从无界到多重约束,编译期检查逐渐增强
面试加分点

多重约束中,类必须写在第一个,接口写在后面。因为 Java 只支持单继承,所以类最多一个。如果你写了 <T extends Comparable & Number>(接口在前,类在后),编译器会报错。

第 3 站

擦除机制:泛型信息的"消失术"

Java 泛型是编译期机制。编译器在生成字节码时会把所有泛型信息擦掉,替换为原始类型(Raw Type)。这就是所谓的类型擦除(Type Erasure)

Erasure.java · 擦除前后对比
// ──── 你写的代码 ────
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(第一个边界,所以类要写在前面)

擦除带来了一系列限制,这些是面试高频考点:

ErasureLimits.java · 擦除的五大限制
// ❌ 限制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
}
编译期 vs 运行期:泛型信息的生命周期 编译期(源码) List<String> list 类型安全 · 编译检查 运行期(字节码) List list 擦除为原始类型 · 自动转型 擦除 为什么 Java 选择擦除?—— 向后兼容 Java 5 引入泛型时,必须保证旧代码(无泛型)和新代码(有泛型)能互相调用
图 2泛型信息在编译后被完全擦除,运行时只保留原始类型
绕过擦除:Super Type Token

虽然擦除让运行时丢失了泛型信息,但 Guava 的 TypeToken 利用匿名子类保留了具体类型——第 6 站会详细讲解。

第 4 站

通配符:? extends vs ? super 与 PECS 原则

泛型最让人困惑的部分不是擦除,而是通配符。面试中 80% 的泛型问题都围绕 ? extends? super 展开。

先看一个直觉陷阱:

WildcardTrap.java · 泛型不是协变的
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 引入了通配符:

Wildcard.java · 上界通配符 vs 下界通配符
// ═══ ? 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 用
}
PECS 原则
通配符角色能读?能写?口诀
? extends T生产者(Producer)读为 T不能写Producer Extends
? super T消费者(Consumer)读为 Object写 T 或其子类Consumer Super
无通配符 <T>既读又写读为 T写 T精确类型
PECS:Producer Extends · Consumer Super ? extends Number 可能是 List<Integer> 可能是 List<Double> 可能是 List<Number> → 只读,生产数据 ? super Integer 可能是 List<Integer> 可能是 List<Number> 可能是 List<Object> → 只写,消费数据
图 3上界通配符限制写入(生产者),下界通配符限制读取(消费者)
Collections.java · JDK 源码中的 PECS 经典 —— copy 方法
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 写
    }
}
记住 PECS 口诀:"Producer Extends, Consumer Super"。如果你发现自己记不住——就想想 Collections.copy:src 只读用 extends,dest 只写用 super。
第 5 站

桥方法:编译器偷偷生成的"替身"

擦除会破坏多态。考虑下面这个场景:

BridgeMethod.java · 桥方法的产生
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"。但问题来了:

擦除后的签名冲突
• 父类 NodesetData 擦除后 → setData(Object)
• 子类 StringNodesetData 擦除后 → setData(String)
• 参数类型不同 → 这不是覆盖,而是重载!

如果不做任何处理,多态就坏了:Node<String> node = new StringNode(); node.setData("hi"); 调用的会是父类的 setData(Object),而不是子类的 setData(String)

编译器的解决方案是生成一个桥方法(Bridge Method)

javap 反编译输出 · StringNode.class
// 你写的 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
桥方法的调用链:保持多态不被擦除破坏 node.setData("hi") 桥方法 setData(Object) 真正的方法 setData(String) 虚调用 checkcast + 转调 桥方法的特征(javap 中可见) ACC_BRIDGE + ACC_SYNTHETIC 标志 反射中 Method.isBridge() 可以判断是否为桥方法
图 4桥方法充当"中转站",保证擦除后多态仍然正确
面试追问:桥方法与反射

在反射中,Class.getDeclaredMethods() 会返回桥方法。如果你在做反射框架(如 Spring),必须用 method.isBridge() 过滤掉桥方法,否则会把同一个方法处理两次,导致参数类型不匹配。

第 6 站

高级用法:TypeToken、自限定类型与捕获转换

一、TypeToken —— 绕过擦除获取运行时类型

擦除让 List<String>.class 不合法,但有一个技巧可以保留运行时泛型信息:

TypeTokenDemo.java · Guava TypeToken 原理
// 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() 读取。

TypeToken 本质
匿名子类 → 泛型参数成为超类签名的一部分 → 字节码 Signature 属性保留完整类型信息 → 运行时反射读取

二、自限定类型(Self-Bounded Types)

SelfBounded.java · Comparable 的递归约束
// 经典的自限定类型: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)

CaptureConversion.java
// 通配符不能直接修改元素——但可以用捕获转换"捕获"通配符类型
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 中推荐的处理通配符限制的标准技巧。

第 7 站

总结:泛型核心规则与常见错误

核心规则速查表
主题规则面试关键词
擦除泛型信息编译后擦除为原始类型,List<String>List<Integer> 运行时是同一个类Type Erasure
擦除边界无界 <T> → Object;有界 <T extends Foo> → FooBound
通配符 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

常见错误清单

CommonMistakes.java · 面试中最容易犯的错误
// ❌ 错误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 属性保留运行时泛型信息