1. 引言
泛型(Generics)是 Java 语言中非常重要但又容易让人困惑的特性。很多开发者在使用 List<String>、Map<String, Integer> 时得心应手,但一旦遇到类型擦除、通配符 ? extends / ? super,以及 PECS 原则,就开始犯迷糊。
这篇文章将围绕三个核心问题展开:
- 类型擦除到底是什么?它如何影响我们的代码?
extends和super通配符到底该怎么选?- PECS 原则(Producer Extends, Consumer Super)如何帮助我们写出更健壮的泛型代码?
最后,我会给出几道常见的泛型面试题和可运行的代码示例,帮助你彻底掌握这些概念。
2. 类型擦除是什么
2.1 泛型的本质:编译期检查
Java 泛型是在 JDK 5 中引入的。它的核心设计思想是:在编译期进行类型检查,在运行期擦除类型信息。
也就是说,泛型类型参数(如 T、E、K、V)在编译完成后会被擦除为它的上界(通常是 Object),或者被替换为具体的类型(当使用 extends 限定上界时)。
2.2 擦除的规则
类型擦除遵循以下规则:
- 若类型参数无上界(如
<T>),则擦除为Object。 - 若类型参数有上界(如
<T extends Number>),则擦除为该上界类型。 - 泛型方法、泛型类的类型参数都会被擦除。
- 擦除后,编译器会插入必要的类型转换(cast)来保证类型安全。
2.3 一个直观的例子
java
// 编译前的代码
public class Box<T> {
private T value;
public void set(T value) {
this.value = value;
}
public T get() {
return value;
}
}
编译后,T 被擦除为 Object,等价于:
java
// 编译后的代码(概念上等价)
public class Box {
private Object value;
public void set(Object value) {
this.value = value;
}
public Object get() {
return value;
}
}
当你在使用 Box<String> 时,编译器会在调用 get() 后自动插入 (String) 的强制类型转换。
2.4 为什么需要类型擦除
类型擦除的设计主要有两个原因:
- 向后兼容:Java 泛型是在 JDK 5 才引入的,为了让旧代码(JDK 1.4 及之前)无需修改就能与泛型代码共存,JVM 层面并不感知泛型,泛型只是编译器的"把戏"。
- 避免运行时开销:如果 JVM 为每种泛型类型都生成一份字节码,会导致类数量爆炸,增加内存开销。
2.5 类型擦除带来的限制
类型擦除带来了一些众所周知的限制:
- 不能
new T()(无法创建泛型类型的实例)。 - 不能创建泛型数组:
new T[10]是不允许的。 - 不能使用
instanceof判断泛型类型:if (obj instanceof List<String>)编译报错。 - 静态字段不能使用类型参数:
static T field;编译报错。 - 泛型类不能继承
Throwable及其子类。
java
// 编译报错的例子
public class GenericArray<T> {
// 错误:不能创建泛型数组
// private T[] array = new T[10];
// 错误:不能使用 instanceof 判断泛型类型
public boolean isStringList(Object obj) {
// return obj instanceof List<String>; // 编译错误
return obj instanceof List<?>; // 正确写法
}
}
3. extends 和 super 怎么选
3.1 通配符(Wildcard)简介
通配符用 ? 表示,用于表示未知类型。它有三种形式:
?:无界通配符,表示任意类型。? extends T:有上界通配符,表示T或T的子类型。? super T:有下界通配符,表示T或T的父类型。
3.2 为什么需要通配符
先看一个经典问题。假设我们有两个方法:
java
public static double sum(List<Number> numbers) {
double total = 0;
for (Number n : numbers) {
total += n.doubleValue();
}
return total;
}
如果我们传入 List<Integer>,会编译报错。因为 List<Integer> 并不是 List<Number> 的子类型------泛型是不变的(invariant)。
这时就需要通配符来放宽限制:
java
public static double sum(List<? extends Number> numbers) {
double total = 0;
for (Number n : numbers) {
total += n.doubleValue();
}
return total;
}
现在 List<Integer>、List<Double>、List<Number> 都可以传入。
3.3 什么时候用 extends
? extends T 适用于只读场景,即我们只想从集合中读取元素,而不想往里写入。
原因:当我们把集合声明为 List<? extends Number> 时,编译器只知道集合中的元素是 Number 的某个子类型,但具体是 Integer 还是 Double 并不知道。因此,不能向其中添加任何元素 (除了 null),否则可能破坏类型安全。
java
List<? extends Number> list = new ArrayList<Integer>();
// list.add(1); // 编译错误:无法确定具体类型
// list.add(3.14); // 编译错误
Number n = list.get(0); // 可以读取,安全
3.4 什么时候用 super
? super T 适用于只写场景,即我们主要想向集合中添加元素。
原因:当我们把集合声明为 List<? super Integer> 时,编译器知道集合中的元素是 Integer 或其某个父类型(如 Number、Object)。向其中添加 Integer 是安全的,因为 Integer 一定是该集合元素类型的子类型。
java
List<? super Integer> list = new ArrayList<Number>();
list.add(1); // 可以写入,安全
list.add(2); // 可以写入
// Integer i = list.get(0); // 编译错误:读取到的可能是 Object
Object obj = list.get(0); // 只能以 Object 读取
3.5 无界通配符 ?
? 表示未知类型,常用于:
- 只使用
Object提供的方法。 - 泛型类中不依赖类型参数的方法。
java
public static void printList(List<?> list) {
for (Object obj : list) {
System.out.println(obj);
}
}
4. PECS 原则:Producer Extends, Consumer Super
4.1 PECS 是什么
PECS 是 Producer Extends, Consumer Super 的缩写,由 Joshua Bloch 在《Effective Java》中提出。它是一条指导我们选择通配符的经验法则:
- Producer Extends :如果参数化类型表示一个"生产者"(只往外提供数据,即我们只读取),使用
? extends T。 - Consumer Super :如果参数化类型表示一个"消费者"(只往里接收数据,即我们只写入),使用
? super T。
4.2 一个经典例子:Collections.copy
java.util.Collections.copy 方法的签名完美诠释了 PECS:
java
public static <T> void copy(List<? super T> dest, List<? extends T> src)
src是生产者,我们从中读取元素,所以用? extends T。dest是消费者,我们向其中写入元素,所以用? super T。
java
List<Integer> src = Arrays.asList(1, 2, 3);
List<Number> dest = new ArrayList<>(Arrays.asList(0.5, 0.6, 0.7));
Collections.copy(dest, src);
System.out.println(dest); // 输出 [1, 2, 3]
4.3 另一个例子:自定义 copy 方法
我们自己实现一个类似的拷贝方法:
java
public static <T> void copyElements(List<? super T> dest, List<? extends T> src) {
for (T item : src) {
dest.add(item);
}
}
调用时,src 可以是 List<Integer>,dest 可以是 List<Number>:
java
List<Integer> src = Arrays.asList(1, 2, 3);
List<Number> dest = new ArrayList<>();
copyElements(dest, src);
System.out.println(dest); // 输出 [1, 2, 3]
4.4 记忆技巧
一个简单的记忆方法:
- 读取(Read)→
extends→ 上界通配符。 - 写入(Write)→
super→ 下界通配符。
或者记住口诀:"读 extends,写 super"。
4.5 什么时候不需要通配符
如果方法内部既读又写,且类型完全一致,就不需要通配符,直接使用类型参数即可:
java
public static <T> void swap(List<T> list, int i, int j) {
T temp = list.get(i);
list.set(i, list.get(j));
list.set(j, temp);
}
5. 常见泛型面试题与代码示例
5.1 面试题 1:List<Object> 和 List<String> 有什么关系?
答案 :它们没有任何父子关系。泛型是不变的(invariant),List<String> 不是 List<Object> 的子类型,反之亦然。
java
List<String> strings = new ArrayList<>();
// List<Object> objects = strings; // 编译错误
5.2 面试题 2:List<?> 和 List<Object> 有什么区别?
答案:
List<?>是未知类型的列表,可以接收List<String>、List<Integer>等任何类型的列表,但只能读取,不能写入(除null外)。List<Object>只能接收List<Object>,但可以写入任意Object及其子类。
java
List<?> wildcardList = new ArrayList<String>();
List<Object> objectList = new ArrayList<>();
// wildcardList.add("hello"); // 编译错误
objectList.add("hello"); // 正确
5.3 面试题 3:如何创建一个泛型数组?
答案 :不能直接创建泛型数组,但可以通过 Array.newInstance 或类型转换的方式间接实现。
java
// 方式一:通过反射创建
@SuppressWarnings("unchecked")
public static <T> T[] createArray(Class<T> clazz, int size) {
return (T[]) java.lang.reflect.Array.newInstance(clazz, size);
}
// 使用
String[] arr = createArray(String.class, 5);
5.4 面试题 4:什么是桥方法(Bridge Method)?
答案:当泛型类在继承或实现时发生类型擦除,编译器会自动生成桥方法来保持多态性。
java
class Parent<T> {
public T get() {
return null;
}
}
class Child extends Parent<String> {
@Override
public String get() {
return "child";
}
}
编译后,Child 类中会生成一个桥方法 Object get(),它调用 String get(),从而保证多态正常工作。
5.5 面试题 5:泛型方法中的类型推断
java
public static <T> T pick(T a, T b) {
return b;
}
// 类型推断
String result = pick("a", "b"); // 推断为 String
Number num = pick(1, 2.0); // 推断为 Number(共同父类型)
5.6 面试题 6:PECS 实战------合并两个列表
java
public static <T> void merge(List<? super T> target, List<? extends T> source) {
target.addAll(source);
}
// 使用
List<Integer> source = Arrays.asList(1, 2, 3);
List<Number> target = new ArrayList<>();
merge(target, source);
System.out.println(target); // 输出 [1, 2, 3]
6. 总结
本文围绕泛型的三个核心主题进行了深入讲解:
-
类型擦除 :Java 泛型在编译期做类型检查,运行期擦除类型信息,这是为了向后兼容和减少运行时开销。擦除带来了一些限制,如不能创建泛型数组、不能使用
instanceof判断泛型类型等。 -
extends 和 super 的选择 :
? extends T用于只读场景(生产者),? super T用于只写场景(消费者),?用于不关心具体类型的场景。 -
PECS 原则 :Producer Extends, Consumer Super。读取用
extends,写入用super。这是编写健壮泛型代码的核心指导原则。
掌握这些概念,不仅能帮助你写出更优雅、更安全的泛型代码,还能在面试中从容应对相关提问。希望这篇文章能帮你彻底理解 Java 泛型的精髓。