泛型深度解析:类型擦除、通配符、上下界
写 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
问题:
- 类型不安全:可以在 list 中放入任何类型
- 需要强制转换:取出来时要手动 cast
- 运行时才报错:类型错误在运行时才暴露
2.2 泛型解决了什么问题
// 使用泛型
List<String> list = new ArrayList<>();
list.add("hello");
// list.add(123); // 编译错误!类型检查在编译时
// 不需要强制转换
String str = list.get(0); // 编译器自动知道类型
泛型的三大好处:
- 类型安全:编译时检查类型错误
- 消除强制转换:编译器自动插入 cast 代码
- 代码复用:可以用同一段代码操作不同类型
三、类型擦除:泛型的本质
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
类型擦除的过程:
- 编译器检查泛型类型
- 编译器在字节码中插入类型转换代码
- 运行时,泛型信息被完全擦除
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