泛型深度解析:类型擦除、通配符、上下界

写 Java 代码的时候,你一定用过 List<String>Map<K, V>Class<T> 这些泛型。但你有没有想过这些问题:

  • 泛型信息在运行时存在吗?为什么 List<String>List<Integer> 在运行时会变成同一个类型?
  • List<? extends Number>List<? super Integer> 有什么区别?
  • PECS 原则是什么?什么时候用 extends,什么时候用 super?

这些问题看起来很基础,但真正答对的候选人不多。今天我们就来把泛型彻底讲透。

一、真实面试场景

候选人小周在面试某大厂时,被问到这样一个问题:

"我们有一个方法,需要接收一个 List<Integer>,如果传入 List<Number> 会怎样?"

小周说:"应该可以吧,因为 Integer 是 Number 的子类..."

面试官又问:"那 List<int> 可以吗?泛型支持基本类型吗?"

小周说:"不支持,只能用 List<Integer>..."

面试官继续追问:"那为什么运行时 List<String>List<Integer> 都是 List?泛型信息去哪了?"

小周愣住了。

【面试官心理】 这道题考察的是候选人对泛型类型擦除的理解。很多同学会用泛型,但不知道泛型信息在编译后就消失了。这导致他们在写代码时会出现一些意外的行为,比如 instanceof 检查、反射操作等。理解类型擦除是理解泛型的关键。

二、为什么需要泛型?

2.1 没有泛型的问题

在没有泛型的时代,我们只能这样写代码:

// 没有泛型时,用 Object 代替
List list = new ArrayList();
list.add("hello");
list.add(123);  // 可以放入任意类型

// 取出来需要强制类型转换
String str = (String) list.get(0);  // 编译通过,运行时报 ClassCastException

问题:

  1. 类型不安全:可以在 list 中放入任何类型
  2. 需要强制转换:取出来时要手动 cast
  3. 运行时才报错:类型错误在运行时才暴露

2.2 泛型解决了什么问题

// 使用泛型
List<String> list = new ArrayList<>();
list.add("hello");
// list.add(123);  // 编译错误!类型检查在编译时

// 不需要强制转换
String str = list.get(0);  // 编译器自动知道类型

泛型的三大好处:

  1. 类型安全:编译时检查类型错误
  2. 消除强制转换:编译器自动插入 cast 代码
  3. 代码复用:可以用同一段代码操作不同类型

三、类型擦除:泛型的本质

3.1 什么是类型擦除?

类型擦除(Type Erasure)是 Java 泛型的核心机制:泛型信息只在编译时存在,运行时会全部被擦除

List<String> stringList = new ArrayList<>();
List<Integer> intList = new ArrayList<>();

// 运行时的类型
System.out.println(stringList.getClass() == intList.getClass());  // true!
// 都是 java.util.ArrayList

类型擦除的过程:

  1. 编译器检查泛型类型
  2. 编译器在字节码中插入类型转换代码
  3. 运行时,泛型信息被完全擦除

3.2 类型擦除的过程

// 源代码
public class Box<T> {
    private T value;
    
    public T getValue() {
        return value;
    }
    
    public void setValue(T value) {
        this.value = value;
    }
}

编译后的字节码(等价于):

public class Box {
    private Object value;  // T 被擦除为 Object
    
    public Object getValue() {
        return value;
    }
    
    public void setValue(Object value) {
        this.value = value;
    }
}

如果你声明的是 Box<Integer>,编译后实际是:

public class Box {
    private Object value;
    
    public Object getValue() {
        return value;
    }
    
    public void setValue(Object value) {
        this.value = value;
    }
}

编译器在调用 getValue() 时会插入 (Integer) 转换。

3.3 类型擦除的原因

为什么要设计成类型擦除?

向后兼容性:JDK 1.5 引入泛型时,已经存在大量没有泛型的代码(raw type)。如果泛型信息保留到运行时,这些旧代码就无法和新的泛型代码互操作。

// JDK 1.4 的代码
List list = new ArrayList();  // 没有泛型
list.add("hello");

// JDK 1.5+ 的代码
List<String> stringList = list;  // 需要兼容旧代码

类型擦除让新旧代码可以共存:旧代码用 raw type,新代码用泛型,编译器和运行时自动处理转换。

3.4 ❌ 错误理解:泛型在运行时存在

// 错误理解:运行时能区分 List<String> 和 List<Integer>
if (list instanceof List<String>) {  // 编译错误!类型信息在运行时不存在
    // ...
}

正确理解:

// 只能检查原始类型
if (list instanceof List) {  // OK
    // ...
}

3.5 【直观类比】泛型就像"编辑时批注"

想象你写论文时用红笔做的批注:

  • 批注只在编辑时存在(编译器看到)
  • 论文正式出版时,批注全部被擦除(运行时看不到)
  • 但批注影响了排版(编译器插入 cast 代码)

四、通配符:灵活的类型约束

4.1 三种通配符

// 无边界通配符:可以表示任何类型
List<?> list;

// 上界通配符:只能读取,不能写入
List<? extends Number> list;

// 下界通配符:只能写入,不能读取
List<? super Integer> list;

4.2 上界通配符 ? extends T

// 可以读取为 Number 或其子类
List<? extends Number> list = new ArrayList<Integer>();
Number num = list.get(0);  // OK,可以读取

// 不能写入(除了 null)
list.add(new Integer(1));   // 编译错误!
list.add(new Double(1.0)); // 编译错误!
list.add(null);             // OK,可以写入 null

原因:list 可能是 List<Integer>List<Double>,写入操作无法确定类型。

4.3 下界通配符 ? super T

// 可以写入 Integer 或其父类
List<? super Integer> list = new ArrayList<Number>();
list.add(new Integer(1));  // OK,可以写入 Integer
list.add(new Double(1.0)); // 编译错误!Double 不是 Integer 的子类

// 只能读取为 Object
Object obj = list.get(0);  // OK,只能读取为 Object

原因:list 可能是 List<Integer>List<Number>List<Object>,读取无法确定具体类型。

4.4 无边界通配符 ?

// 可以是任何类型,但不知道具体类型
List<?> list = new ArrayList<String>();
Object obj = list.get(0);  // OK,只能读取为 Object

// 不能写入(除了 null)
list.add("hello");  // 编译错误!
list.add(null);     // OK

4.5 PECS 原则

PECS:Producer Extends, Consumer Super

// 如果你需要从集合中读取数据(生产者),用 extends
public double sumOfList(List<? extends Number> list) {
    double sum = 0.0;
    for (Number n : list) {  // 可以安全读取
        sum += n.doubleValue();
    }
    return sum;
}

// 如果你需要往集合中写入数据(消费者),用 super
public void addNumbers(List<? super Integer> list) {
    list.add(1);      // OK,可以写入 Integer
    list.add(2);
    // list.add(2.0); // 编译错误,Double 不是 Integer
}

五、泛型方法

5.1 泛型方法的基本形式

public static <T> T getFirst(List<T> list) {
    if (list == null || list.isEmpty()) {
        return null;
    }
    return list.get(0);
}

调用:

String first = getFirst(Arrays.asList("a", "b", "c"));
Integer firstNum = getFirst(Arrays.asList(1, 2, 3));

5.2 泛型方法与类型推断

// 泛型方法会自动推断类型
List<String> list = Arrays.asList("a", "b", "c");
String first = getFirst(list);  // 编译器推断 T=String

5.3 多个类型参数

public class Pair<K, V> {
    private K key;
    private V value;
    
    public Pair(K key, V value) {
        this.key = key;
        this.value = value;
    }
    
    public K getKey() { return key; }
    public V getValue() { return value; }
}

// 使用
Pair<String, Integer> pair = new Pair<>("年龄", 25);

六、桥接方法:类型擦除的补偿

6.1 为什么需要桥接方法?

public class Parent {
    public void doSomething(String value) {}
}

public class Child extends Parent {
    @Override
    public void doSomething(String value) {}  // 正常重写
}

但如果是泛型呢?

public class Parent<T> {
    public void doSomething(T value) {}
}

public class Child extends Parent<String> {
    @Override
    public void doSomething(String value) {}  // 这是重写还是重载?
}

6.2 编译器生成的桥接方法

为了保持多态性,编译器会生成桥接方法:

// 编译前的 Child 类
public class Child extends Parent<String> {
    @Override
    public void doSomething(String value) {}
}

// 编译后的 Child 类(实际生成的)
public class Child extends Parent<String> {
    // 用户写的方法
    public void doSomething(String value) {}
    
    // 编译器生成的桥接方法
    public void doSomething(Object value) {
        // 调用用户写的方法
        this.doSomething((String) value);
    }
}

doSomething(Object) 是桥接方法,它负责兼容类型擦除后的父类方法签名,然后调用用户写的 doSomething(String)

6.3 桥接方法的影响

Child child = new Child();

// 调用用户写的方法
child.doSomething("test");  // 调用 String 版本

// 调用桥接方法
Method method = Child.class.getMethod("doSomething", Object.class);
method.invoke(child, "test");  // 调用桥接方法,最终执行 String 版本

七、泛型的限制

7.1 不能用基本类型

List<int> list;      // 编译错误!
List<Integer> list;  // OK

原因:类型擦除后是 Object,基本类型不是对象。

7.2 不能创建泛型数组

new T[10];           // 编译错误!
new List<String>[10];  // 编译错误!

但可以用 T[] 作为数组参数:

public class Container<T> {
    T[] array;
    
    @SuppressWarnings("unchecked")
    public Container(int size) {
        // 需要强制转换
        this.array = (T[]) new Object[size];
    }
}

7.3 不能直接实例化类型参数

public T create() {
    return new T();  // 编译错误!
}

// 正确做法
public T create(Class<T> clazz) {
    return clazz.newInstance();
}

7.4 不能使用 instanceof

if (obj instanceof List<String>) {  // 编译错误!
    // ...
}

// 只能检查原始类型
if (obj instanceof List) {  // OK
    // ...
}

八、生产场景与避坑

8.1 ❌ 错误示范:泛型类型参数不能用于 static 方法

public class Container<T> {
    private T value;
    
    // 错误!static 方法不能使用类的泛型参数
    public static T getValue() {
        return new T();  // 编译错误!
    }
    
    // 正确:static 方法需要自己的泛型参数
    public static <T> T create() {
        return new T();  // 还是编译错误
    }
    
    // 如果确实需要,可以用 Class<T>
    public static <T> T create(Class<T> clazz) {
        return clazz.newInstance();
    }
}

8.2 ✅ 正确示范:泛型与继承

// 继承时保留泛型
class StringList extends ArrayList<String> {
    // 重写方法时不需要额外处理
    @Override
    public boolean add(String e) {
        // ...
        return super.add(e);
    }
}

8.3 泛型和反射的配合

类型擦除后,泛型信息丢失。但通过反射可以获取泛型信息(需要继承 TypeReference):

// 利用子类继承获取泛型信息
public abstract class TypeReference<T> {
    private final Type type;
    
    public TypeReference() {
        Type superClass = getClass().getGenericSuperclass();
        type = ((ParameterizedType) superClass).getActualTypeArguments()[0];
    }
    
    public Type getType() {
        return type;
    }
}

// 使用
TypeReference<String> typeRef = new TypeReference<String>() {};
String type = (Class<String>) typeRef.getType();  // 获取到 String 类型

九、面试追问链

第一层:基础概念

面试官问:"什么是泛型?泛型有什么好处?"

标准回答:泛型是参数化类型的技术,让代码可以操作不同类型的数据而不用重复写代码。好处是类型安全(编译时检查)、消除强制转换、代码复用。

第二层:类型擦除

面试官问:"泛型信息在运行时存在吗?什么是类型擦除?"

标准回答:泛型信息只在编译时存在,运行时会全部被擦除。这是 Java 为了向后兼容而做的设计。类型擦除后,泛型参数会被替换为上限(默认是 Object),编译器会插入必要的类型转换代码。

第三层:通配符

面试官问:"? extends Number? super Integer 有什么区别?"

标准回答:? extends Number 可以读取(作为 Number),但不能写入(除了 null),因为不知道具体类型;? super Integer 可以写入(Integer 或其子类),但只能读取为 Object,因为不知道上限。

第四层:PECS

面试官追问:"PECS 原则是什么?什么时候用 extends,什么时候用 super?"

标准回答:PECS = Producer Extends, Consumer Super。从集合读取数据时用 extends(生产者),往集合写入数据时用 super(消费者)。

【面试官心理】 泛型是 Java 中较难理解的概念之一。我问他类型擦除,是想看他有没有深入理解泛型的本质;问他通配符和 PECS,是想看他有没有实际使用泛型的经验。很多候选人只知道"泛型让代码更安全",但不知道背后的设计原因。

【学习小结】

  • 泛型提供编译时类型检查、消除强制转换、代码复用
  • 类型擦除:泛型信息在编译后被擦除,运行时不存在
  • ? extends T:只读(生产者),可以用在返回值
  • ? super T:只写(消费者),可以作为参数
  • PECS:Producer Extends, Consumer Super
  • 编译器会生成桥接方法保持多态性
  • 泛型有限制:不能用基本类型、不能创建泛型数组、不能用 instanceof