Java 从入门到精通(七):泛型与类型系统——通配符、类型擦除与 PECS 原则

大部分人写泛型只停留在 List<String> 这一层,一旦 IDE 报出 capture of ?、name clash: ... have the same erasure、unchecked cast 就只能靠试。本文的目标是把这层窗户纸彻底捅破:擦除到底发生在哪一步、擦完之后字节码里还剩下什么、桥接方法是谁塞进去的、为什么 List<? extends T> 读得写不得。读完你应该能徒手画出泛型在编译期与运行期之间的那道分界线。

一、为什么需要泛型:Object 时代的三大问题

1.1 JDK 5 之前:一切皆 Object

2004 年 JDK 5 发布之前,Java 的集合框架只有一条路可走:容器内部统一用 Object 存储,取出来由调用方自己强转。下面这段代码在 JDK 1.4 时代是标准写法:

1
2
3
4
5
6
7
8
// JDK 1.4 写法:原始类型(raw type),编译器只知道里面装的是 Object
List names = new ArrayList();
names.add("张三");
names.add("李四");

// 取出来必须强转,编译器不做任何检查
String first = (String) names.get(0);
System.out.println(first.length());

这段代码看起来人畜无害,但它把三个致命问题一起带进了程序。

1.2 三大问题

问题一:运行期 ClassCastException。 转换的正确性完全依赖于”程序员记性”。只要有一个地方塞错了类型,编译能过、测试可能也能过,然后在生产环境某条冷门分支上炸掉:

1
2
3
4
5
6
7
8
9
10
11
List data = new ArrayList();
data.add("订单号-A1001");

// 某个不熟这块代码的人往同一个容器里塞了日期
data.add(new java.util.Date());

// 编译期完全静默,运行期直接 ClassCastException
for (int i = 0; i < data.size(); i++) {
String s = (String) data.get(i); // 第 2 个元素炸
System.out.println(s.toUpperCase());
}

更糟的是,这个异常抛出的位置离真正出错的位置(那个 add 调用)可能隔着十万八千里,排查成本极高。

问题二:没有编译期检查,类型信息只能写在注释里。 为了不踩坑,老项目的做法是靠 Javadoc 约定:”本 List 只放 User 对象,请勿放入其它类型”。约定没有强制力,编译器也不会帮你验证,等于把类型安全交给了人类自律。

问题三:代码意图不清晰,强制转换噪音大。 (String) list.get(i) 这种转换在业务代码里到处横行,读者必须自己去推断容器里到底装着什么,代码的自我描述能力几乎为零。

1.3 泛型带来的两个收益

泛型(Generics)在 JDK 5 引入,本质上是把类型本身变成了参数,它一次性解决了上面三个问题:

收益一:编译期类型安全。 编译器记住了容器里装的是什么,往里塞错类型直接编译失败,把运行期爆炸提前到编译期拒绝。

收益二:代码复用 + 消除强制转换。 一套 ArrayList<E> 的逻辑可以服务所有元素类型,而且取出来就是目标类型,无需手写转换。

1
2
3
4
5
// 泛型写法
List<String> names = new ArrayList<>();
names.add("张三");
// names.add(new Date()); // 编译错误:不兼容的类型
String first = names.get(0); // 无需强转,编译器保证它一定是 String

需要立刻澄清一个常见误解:泛型不是把强制转换消灭了,而是把强制转换从”你手写”变成了”编译器代劳”。第四节的字节码会证明,String first = names.get(0); 这一行编译后依然有一条 checkcast 指令,只是它由 javac 自动插入,永远不可能写错。

维度 JDK 5 之前(raw type) JDK 5 之后(泛型)
存储类型 统一 Object 类型参数 E
取出元素 手写 (T) 强转 自动插入 checkcast,源码无转换
错误暴露时机 运行期 ClassCastException 编译期 incompatible types
错误定位 离源头很远,难排查 直接定位到出错的 add 调用
类型信息载体 Javadoc 注释 / 口头约定 方法签名,编译器可校验
复用性 一套逻辑适用所有类型 同样适用,且保留类型信息

二、泛型的三种使用形态与类型推断

2.1 泛型类

类型参数声明在类名之后、尖括号内:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* 一个简单的泛型容器:T 是类型参数,在类体内可以当类型用
*/
public class Box<T> {
private T value;

public T get() { return value; }
public void set(T value) { this.value = value; }

// 类型参数也可以出现在参数、返回值、局部变量、异常声明中
public boolean isEqualTo(T other) {
return other != null && other.equals(this.value);
}
}

// 使用
Box<String> strBox = new Box<>(); // JDK 7+ 菱形运算符
strBox.set("hello");
String v = strBox.get();

2.2 泛型方法

泛型方法的关键特征是:类型参数声明在修饰符与返回类型之间,这个位置是它与泛型类的唯一语法区别。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class GenericMethods {

// <T> 出现在 static 与返回值之间 —— 这就是泛型方法的标志
public static <T> T pick(T a, T b) {
return Math.random() > 0.5 ? a : b;
}

// 多个类型参数,且可以有边界
public static <K, V> boolean sameKey(Map<K, V> m1, Map<K, V> m2) {
return m1.keySet().equals(m2.keySet());
}

// 边界:T 必须是 Number 的子类
public static <T extends Number> double sum(List<T> numbers) {
double total = 0.0;
for (T n : numbers) { total += n.doubleValue(); }
return total;
}

public static void main(String[] args) {
// 不需要写 GenericMethods.<String>pick(...),编译器会推断
String s = pick("a", "b"); // T 推断为 String
Integer i = pick(1, 2); // T 推断为 Integer
double d = sum(List.of(1, 2.5, 3L)); // T 推断为 Number & Comparable<...> 的交集
}
}

推断规则要点:编译器取所有实参类型的最小公共上界(lub, least upper bound)。上例中 Integer、Double、Long 的 lub 是 Number & Comparable<...>,所以 sum 能编译通过。如果推断结果不满足边界(比如传入 List<String>),编译报错。

2.3 泛型接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 定义:接口名后带类型参数
public interface Repository<T, ID> {
T findById(ID id);
ID save(T entity);
void deleteById(ID id);
}

// 实现一:实现时直接指定具体类型,之后实现类不再是泛型类
public class UserRepository implements Repository<User, Long> {
@Override public User findById(Long id) { /* ... */ return null; }
@Override public Long save(User entity) { /* ... */ return 1L; }
@Override public void deleteById(Long id) { /* ... */ }
}

// 实现二:实现类继续保留类型参数,把决定权交给使用者
public abstract class AbstractRepository<T, ID> implements Repository<T, ID> {
protected abstract Class<T> getEntityClass(); // 见 3.4 的 Class<T> token 模式
}

2.4 类型参数命名约定

命名不是语法要求,但约定俗成,遵守它能让代码自解释:

符号 约定含义 典型出处
T Type,通用类型 List<T>、Box<T>
E Element,集合元素 Collection<E>
K Key,映射键 Map<K, V>
V Value,映射值 Map<K, V>
N Number,数值 EnumMap<K extends Enum<K>, V> 旁支
S / U / V 第二、三、四个类型参数 Stream<T>、Collector<T,A,R>
R Return,返回类型 Function<T,R>
? 通配符,未知类型 List<?>

2.5 泛型方法与可变参数、静态方法

静态方法不能使用类上声明的类型参数(因为类类型参数属于实例维度,静态成员属于类维度,见 3.3)。静态方法想要泛型,只能自己声明为泛型方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class VarargsDemo {

// 错误:静态方法不能用类的类型参数 T
// public static T bad(T a) { return a; } // 编译错误

// 正确:静态方法自己声明类型参数
public static <E> List<E> listOf(E... elements) {
List<E> result = new ArrayList<>();
for (E e : elements) { result.add(e); }
return result;
}

// 泛型可变参数会产生"堆污染"警告,JDK 7 起可用 @SafeVarargs 抑制
@SafeVarargs
public static <E> void printAll(E... elements) {
for (E e : elements) { System.out.println(e); }
}
}

@SafeVarargs 的语义是”我保证不会往这个数组里塞入与声明类型不符的元素”,因此它只能用于 static、final 或构造器这三种不可被重写的方法上(JDK 9 起允许私有实例方法)。

2.6 菱形运算符与目标类型推断

JDK 7 引入菱形运算符 <>,让构造器调用不必重复写类型参数:

1
2
3
4
// JDK 7 之前
Map<String, List<Integer>> map1 = new HashMap<String, List<Integer>>();
// JDK 7+
Map<String, List<Integer>> map2 = new HashMap<>();

JDK 8 进一步把推断能力从”赋值上下文”扩展到方法调用上下文(target typing),这是很多人以为 JDK 7 就有、其实没有的差异:

1
2
3
4
5
6
7
8
9
10
11
static void process(List<String> names) { /* ... */ }

// JDK 7:编译错误!JDK 7 只能从"赋值语句"推断,不会用形参类型去推断实参
// process(Collections.emptyList());

// JDK 8:OK,目标类型为 List<String>,emptyList 的 T 被推断为 String
process(Collections.emptyList());

// JDK 8 起,嵌套泛型方法调用也能推断
List<String> names = List.of("a", "b");
Stream<String> stream = names.stream().map(String::toUpperCase);

另外两个细节:JDK 9 起菱形运算符可以用于匿名内部类(new ArrayList<>() {} 此前非法);var(JDK 10)与菱形运算符配合时,推断结果可能不是你想要的(会退化为 Object),需要显式给出类型参数。

2.7 显式类型见证(Type Witness)

当推断失效或推断结果不对时,可以在方法名前用 .<T> 显式指定,这就是类型见证:

1
2
3
4
5
6
7
8
9
10
11
// 推断不出来:实参都是 null,缺少推断依据
List<String> empty1 = Collections.<String>emptyList();

// 推断结果不是我想要的:想构造 List<Object>,但根据实参推断成了 List<String>
List<Object> objs = Collections.<Object>unmodifiableList(new ArrayList<String>());

// 链式调用中锁定类型
Optional<String> opt = Optional.<String>ofNullable(null);

// 类型见证也常用于流管道的收口
Stream<String> s = Stream.<String>of("a", "b", "c").map(x -> x);

判断何时需要类型见证的经验法则:当方法的所有实参都无法体现类型参数,或者实参类型存在多个相互冲突的候选时,编译器要么报错、要么推断出一个你不想要的类型。此时直接 .<T> 显式指定,一行解决,比反复调整实参类型划算得多。

三、类型擦除:规则、动因与四大限制

3.1 三条擦除规则

Java 的泛型是编译期特性,运行期不存在泛型。编译器按以下规则把类型参数从字节码中抹掉:

  1. 无界类型参数 <T> → 擦除为 Object;
  2. 有界类型参数 <T extends A> → 擦除为边界 A;
  3. 多边界 <T extends A & B & C> → 擦除为最左边的第一个边界 A。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Erasure<T> {                       // 无界:T -> Object
private T value;
public T get() { return value; }
}

class Bounded<T extends Number> { // 有界:T -> Number
private T value;
public double toDouble() { return value.doubleValue(); } // 调用擦除为 Number.doubleValue()
}

class Multi<T extends Comparable<T> & java.io.Serializable> { // 多边界:T -> Comparable
private T value;
public int cmp(T o) { return value.compareTo(o); } // 调用 Comparable.compareTo(Object)
}

注意第三条的实际影响:Multi<T> 擦除后是 Comparable,编译器会在所有用到 Serializable 方法的地方插入 checkcast。所以把最通用的接口放左边、把标记型接口(如 Serializable)放右边,可以减少运行期转换开销。

3.2 为什么 Java 选择擦除

答案是两个字:兼容。

JDK 5 发布时,全世界已经有海量的 Java 代码和字节码,JVM 规范里的类型系统、字节码指令集、以及所有已编译的 .class 文件都不认识泛型。如果要让 List<String> 和 List<Integer> 在运行期真正成为两个不同的类(即”C++ 模板式”的具体化泛型 reified generics),就必须改动 JVM 与字节码格式,让老版本的 JVM 无法运行新代码。

擦除式泛型(erasure-based generics)的设计让泛型只存在于源码与编译器的检查中,编译出的字节码与 JDK 1.4 时代完全同构。带来的直接好处是:

  • 二进制兼容:JDK 5 编译的 ArrayList.class 能被 JDK 1.4 的 JVM 加载(理论上);
  • 迁移兼容:老代码 List list = new ArrayList(); 与新代码 List<String> l = list; 可以混用,只会产生 unchecked 警告而非编译错误,允许项目渐进式迁移;
  • 无额外内存开销:不会像 C++ 模板那样为每套类型参数生成一份代码副本(代码膨胀)。

代价就是我们下面要吃的那些亏:运行期拿不到 T 的真实类型、instanceof 失效、数组创建受限等。

3.3 擦除带来的限制

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class Limits<T> {

// 限制一:不能 new T() —— 擦除后变成 new Object(),不是你要的
// T create() { return new T(); } // 编译错误

// 限制二:不能 new T[] —— 数组需要在运行期知道元素类型
// T[] createArray(int n) { return new T[n]; } // 编译错误:generic array creation

// 限制三:不能 instanceof T / 不能对精确泛型类型做 instanceof
boolean isString(Object o) {
// return o instanceof T; // 编译错误
// return o instanceof List<String>; // 编译错误:illegal generic type for instanceof
return o instanceof List<?> ; // 只有无界通配符合法
}

// 限制四:静态成员不能使用类的类型参数
// private static T cached; // 编译错误
// public static T identity(T t) { return t; } // 编译错误(应为静态泛型方法,见 2.5)

// 限制五:不能仅靠类型参数不同来重载
void overload(List<String> a) { }
// void overload(List<Integer> a) { } // 编译错误:have the same erasure
}

限制四的根因很值得说清楚:类的类型参数 T 是在 new Limits<String>() 时确定的,属于”实例维度”;而静态成员属于”类维度”,被所有实例共享。一个 Limits<String> 和一个 Limits<Integer> 在 JVM 里是同一个 Limits.class,静态字段只有一份,它根本不可能同时是 String 类型和 Integer 类型。

3.4 绕过的四种手段

限制 绕过方案 核心思路
不能 new T() Supplier<T> 工厂 / Class<T>.newInstance() 把”构造行为”作为参数传进来
不能 new T[] Array.newInstance(Class, n) + 强转 反射在运行期拿到真实元素类型
不能 instanceof T 传入 Class<T> token 后用 isInstance() 用 Class 对象代替类型名
反射拿不到 T ParameterizedType(见 7.5) 从父类的泛型签名里反查
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import java.lang.reflect.Array;
import java.util.function.Supplier;

public class Workarounds<T> {
private final Class<T> typeToken; // Class<T> token 模式
private final Supplier<T> factory; // 工厂模式

// 构造时把"类型信息"和"构造行为"一起注入,弥补擦除
public Workarounds(Class<T> typeToken, Supplier<T> factory) {
this.typeToken = typeToken;
this.factory = factory;
}

// 绕过限制一:用 Supplier 代替 new T()
public T create() {
return factory.get();
}

// 绕过限制二:反射创建真实类型的数组(元素是 null,不需要强转元素本身)
@SuppressWarnings("unchecked")
public T[] createArray(int length) {
return (T[]) Array.newInstance(typeToken, length);
}

// 绕过限制三:用 Class.isInstance 代替 instanceof
public boolean isOfType(Object o) {
return typeToken.isInstance(o);
}

public static void main(String[] args) {
var w = new Workarounds<>(String.class, String::new);
String s = w.create(); // ""
String[] arr = w.createArray(3); // 真正的 String[],不是 Object[]
System.out.println(arr.getClass()); // class [Ljava.lang.String;
System.out.println(w.isOfType("abc")); // true
System.out.println(w.isOfType(123)); // false
}
}

四、字节码证据:Signature、checkcast 与桥接方法

前面说的都是”编译器会这么做”,这一节把 javap 的输出摆出来,眼见为实。

4.1 Signature 属性:擦除后残留的”类型化石”

泛型信息没有完全消失,而是被放进了 class 文件的 Signature 属性里。它是 JLS 规定的可选属性,JVM 执行时完全忽略它,只有编译器、IDE 和反射 API 会读。

1
2
3
4
5
6
// 源文件 Box.java
public class Box<T> {
private T value;
public T get() { return value; }
public void set(T value) { this.value = value; }
}

执行 javac Box.java && javap -v -p Box.class,关键输出如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// ===== javap -v -p Box.class(节选,省略常量池与无关行)=====
// 类级别的 Signature:泛型声明被保存在这里,descriptor 里没有
// Signature: #13 // <T:Ljava/lang/Object;>Ljava/lang/Object;

{
private T value;
descriptor: Ljava/lang/Object; // <-- 字段描述符:T 已被擦成 Object
flags: (0x0002) ACC_PRIVATE
Signature: #8 // TT; <-- 泛型信息仅存于 Signature

public T get();
descriptor: ()Ljava/lang/Object; // <-- 返回值擦成 Object
flags: (0x0001) ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: getfield #7 // Field value:Ljava/lang/Object;
4: areturn
Signature: #12 // ()TT; <-- 真实的泛型签名

public void set(T);
descriptor: (Ljava/lang/Object;)V // <-- 参数擦成 Object
flags: (0x0001) ACC_PUBLIC
Code:
stack=2, locals=2, args_size=2
0: aload_0
1: aload_1
2: putfield #7 // Field value:Ljava/lang/Object;
5: return
Signature: #17 // (TT;)V
}

这份输出说明了三件事:

  1. descriptor(JVM 真正使用的签名)里 T 一律变成了 Object —— 擦除确实发生了;
  2. Signature 属性里保留了 <T:Ljava/lang/Object;>、()TT; 等完整泛型信息 —— 信息没丢,只是降级为元数据;
  3. get() 方法体里没有 checkcast,因为擦成 Object 后返回 Object 是合法的 —— 转换被推迟到了调用方。

4.2 checkcast 的插入位置

1
2
3
4
5
6
7
8
// 源文件 UseBox.java
public class UseBox {
public static void main(String[] args) {
Box<String> box = new Box<>();
box.set("hello");
String s = box.get(); // 这一行,编译器替我们插了 checkcast
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// ===== javap -c UseBox.class(main 方法)=====
public static void main(java.lang.String[]);
Code:
0: new #2 // class Box
3: dup
4: invokespecial #3 // Method Box."<init>":()V
7: astore_1
8: aload_1
9: ldc #4 // String hello
11: invokevirtual #5 // Method Box.set:(Ljava/lang/Object;)V <-- 按擦除签名调用
14: aload_1
15: invokevirtual #6 // Method Box.get:()Ljava/lang/Object;
18: checkcast #7 // class java/lang/String <-- 自动插入!
21: astore_2
22: return

结论:checkcast 不是插在被泛型类的方法内部,而是插在调用方拿到返回值之后、赋值给具体类型变量之前。这也解释了 1.3 节那句话——泛型没有消灭强制转换,只是把转换的书写责任从程序员转移给了编译器。

顺带解释了另一个现象:box.get().length() 能编译通过、((Object) box.get()).length() 不行,都是因为 checkcast 插入位置决定了表达式的静态类型。

4.3 桥接方法的生成过程

桥接方法(bridge method)是擦除机制为了保住多态而打的补丁。看这个例子:

1
2
3
4
5
6
7
8
9
10
import java.util.ArrayList;

// 源码:重写了 ArrayList<String> 的 add(String)
class MyList extends ArrayList<String> {
@Override
public boolean add(String s) {
System.out.println("adding: " + s);
return super.add(s);
}
}

问题来了:ArrayList<E> 擦除后的方法是 boolean add(Object)。如果有人这样调用:

1
2
List rawOrObjectList = new MyList();
rawOrObjectList.add(new Object()); // 按擦除签名,调用的是 add(Object)

JVM 在做虚方法分派时,只认识 add(Object)。而 MyList 里只有 add(String),它并没有真正覆盖 ArrayList.add(Object)。如果不做处理,MyList 的多态就断了——通过父类引用调用时会直接掉进 ArrayList 的原始实现。

编译器因此自动生成一个桥接方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// ===== javap -p -c MyList.class(节选)=====
class MyList extends java.util.ArrayList<java.lang.String> {
// ① 程序员写的真实方法
public boolean add(java.lang.String);
descriptor: (Ljava/lang/String;)Z
flags: (0x0001) ACC_PUBLIC
Code:
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: aload_1
4: invokevirtual #3 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
...
14: aload_0
15: aload_1
16: invokespecial #5 // Method java/util/ArrayList.add:(Ljava/lang/Object;)Z
19: ireturn

// ② 编译器生成的桥接方法:ACC_BRIDGE + ACC_SYNTHETIC
public boolean add(java.lang.Object);
descriptor: (Ljava/lang/Object;)Z
flags: (0x1041) ACC_PUBLIC, ACC_BRIDGE, ACC_SYNTHETIC
Code:
0: aload_0
1: aload_1
2: checkcast #6 // class java/lang/String <-- 先做类型检查
5: invokevirtual #7 // Method add:(Ljava/lang/String;)Z <-- 再转发给真实方法
8: ireturn
}

三个观察点:

  • flags 里的 ACC_BRIDGE 和 ACC_SYNTHETIC 是桥接方法的身份证,源码里看不到它,反射时可以用 Method.isBridge() 过滤掉;
  • 桥接方法体的三板斧是 checkcast → 转发调用 → 返回。这也意味着通过原始类型往 List<String> 里塞错误元素时,ClassCastException 是在桥接方法里抛出的;
  • Comparable<T> 是另一个高发区:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class MyInt implements Comparable<MyInt> {
private final int value;
MyInt(int value) { this.value = value; }
@Override public int compareTo(MyInt o) { return Integer.compare(value, o.value); }
}

// ===== javap -p -c MyInt.class(节选)=====
// public int compareTo(MyInt);
// descriptor: (LMyInt;)I
//
// public int compareTo(java.lang.Object);
// descriptor: (Ljava/lang/Object;)I
// flags: (0x1041) ACC_PUBLIC, ACC_BRIDGE, ACC_SYNTHETIC
// Code:
// 0: aload_0
// 1: aload_1
// 2: checkcast #5 // class MyInt
// 5: invokevirtual #6 // Method compareTo:(LMyInt;)I
// 8: ireturn

没有这个桥接方法,Collections.sort(list) 里对所有 Comparable 的调用(擦除后是 compareTo(Object))就找不到 MyInt 的实现。

4.4 桥接方法引发的重载冲突

桥接方法是编译器”偷偷”加的,它可能与你手写的某个方法擦除后撞车,这就是经典的 name clash:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import java.util.List;
import java.util.ArrayList;

class Clash1 {
// 两个方法擦除后都是 void m(List),签名冲突
public void m(List<String> list) { }
// public void m(List<Integer> list) { }
// 编译错误:name clash: m(List<Integer>) and m(List<String>) have the same erasure
}

class Clash2 extends ArrayList<String> {
// ArrayList<String> 擦除后有 add(Object),编译器还要为它生成桥接方法 add(Object)
// 而这里手写的 add(Object) 擦除后同样是 add(Object),且并不构成重写
// public boolean add(Object o) { return true; }
// 编译错误:name clash: add(Object) in Clash2 and add(E) in ArrayList have the same erasure,
// yet neither overrides the other
}

interface A { void f(List<String> x); }
interface B { void f(List<Integer> x); }
// class C implements A, B { }
// 编译错误:types A and B are incompatible; both define f(List), but with unrelated return types / erasure

规避方案:改名(最实用)、或者让两个方法有不同的参数个数/非泛型参数类型,从根上错开擦除后的签名。

桥接方法还有一个实战副作用:用反射遍历方法时会拿到两份 add / compareTo。很多人写过的”通过反射找 setter”的代码突然匹配到了 ACC_SYNTHETIC 的桥接方法,导致转换失败。正确做法是在遍历时加一句 if (m.isBridge() || m.isSynthetic()) continue;。

五、通配符体系:无界、上界与下界

5.1 出发点:泛型是不变的

先看一个必须接受的事实:

1
2
List<String> strings = new ArrayList<>();
// List<Object> objects = strings; // 编译错误:incompatible types

String 是 Object 的子类,但 List<String> 不是 List<Object> 的子类——这叫泛型的不变性(invariance)。原因很直白:如果允许,objects.add(new Object()) 就能往一个只该装 String 的容器里塞进 Object,类型安全当场崩塌。

但不变性太僵硬了:我就是想写一个”能接受任何 List<Number 子类> 的方法”,怎么办?通配符就是答案。

5.2 无界通配符 <?> 与 List<Object> 的区别

这是面试重灾区,也是很多人写错的原因:

类型 含义 list.add("x") Object o = list.get(0) 类型安全
List (原始类型) 关闭泛型检查,完全裸奔 允许(无检查) 允许 无,会产生 unchecked 警告
List<Object> 明确装 Object 的容器 允许 允许 有,但只能装 Object
List<?> 装某种未知类型的容器 编译错误 允许 有,且最灵活
1
2
3
4
5
List<?> unknown = new ArrayList<String>();
// unknown.add("abc"); // 编译错误:add(capture of ?) 无法接受任何值(除 null)
unknown.add(null); // 唯一例外:null 没有类型,可以加
Object o = unknown.get(0); // 只能当 Object 用
int size = unknown.size(); // size() 不依赖元素类型,正常可用

为什么 add 会被拒?因为 unknown 可能是 List<String>、也可能是 List<Integer>,编译器无法确认你塞进去的东西合法,索性一律拒绝(除 null)。

List<?> 的正确使用场景是:只做与元素类型无关的操作(size()、clear()、判空遍历当 Object 用),或者用于 Class<?> 这种”类型本身不确定”的场合。

5.3 上界 <? extends T>:只能读

1
2
3
4
5
6
List<? extends Number> nums = new ArrayList<Integer>();   // 任何 Number 的子类型的 List

Number n = nums.get(0); // OK:读出来的东西"至少是个 Number"
// nums.add(1); // 编译错误
// nums.add(2.0); // 编译错误
// nums.add(null); // 唯一例外

为什么写不进去:nums 的实际类型可能是 List<Double>。如果 add(Integer) 被允许,一个 List<Double> 里就混进了 Integer,类型安全破产。所以编译器干脆禁止一切写入——它无法在编译期确定”实际元素类型”是什么。

5.4 下界 <? super T>:只能写(读就只剩 Object)

1
2
3
4
5
6
7
List<? super Integer> ints = new ArrayList<Number>();   // Integer 或其任一父类型的 List

ints.add(1); // OK:无论实际是 List<Integer>/List<Number>/List<Object>,
// 放一个 Integer 进去都合法
// ints.add(2.0); // 编译错误:如果实际是 List<Integer>,Double 就非法
Object o = ints.get(0); // 只能当 Object 用
// Integer i = ints.get(0); // 编译错误:实际可能是 List<Number>,取出来未必是 Integer

为什么能写:下界保证了实际元素类型一定是 Integer 或它的某个父类,所以放 Integer(子类实例)进去永远合法(里氏替换)。

为什么读不出来:编译器只知道”元素类型是 Integer 的某个父类”,但不知道具体是哪一个,只能保守地当成 Object。

5.5 通配符捕获与 helper 方法

List<?> 的元素类型虽然未知,但对同一次调用而言,它是一个确定的类型。编译器把它记为一个内部记号,报错信息里写作 capture of ?。你可以原样取出来再放回去吗?不行,因为两次出现的 ? 被认为是不同的捕获:

1
2
3
4
5
public static void swapFirstTwo(List<?> list) {
// list.set(0, list.set(1, list.get(0)));
// 编译错误:The method set(int, capture#1-of ?) is not applicable
// for the arguments (int, capture#2-of ?)
}

解决办法是通配符捕获(wildcard capture):用一个私有的泛型 helper 方法把 ? 捕获成一个具名类型参数 E,在 helper 内部 E 就是确定的,读写都合法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Swap {
// 公开 API 用通配符保持灵活
public static void swap(List<?> list, int i, int j) {
swapHelper(list, i, j); // 捕获发生在这里:? 被绑定到某个具体的 E
}

// 私有 helper:类型参数 E 捕获了通配符代表的那个未知类型
private static <E> void swapHelper(List<E> list, int i, int j) {
list.set(i, list.set(j, list.get(i))); // 合法:前后都是同一个 E
}

public static void main(String[] args) {
List<String> l = new ArrayList<>(List.of("a", "b"));
swap(l, 0, 1);
System.out.println(l); // [b, a]
}
}

这个技巧来自《Effective Java》,是处理”通配符列表内部交换/重排”的标准套路,JDK 自身的 Collections.reverse、Collections.shuffle 也用了同样的思想(虽然它们用的是精确类型参数)。

5.6 通配符不该出现在返回值上

1
2
3
4
5
6
7
8
9
10
11
12
// 反面教材
// public static List<? extends Number> getNumbers() { ... }
// 调用方被迫写成:
// List<? extends Number> r = getNumbers();
// 之后既不能 add,也不能干净地声明变量

// 正面做法:返回值用精确类型参数或具体类型
public static <T extends Number> List<T> getNumbers(Class<T> type) {
return new ArrayList<>();
}

List<Integer> ints2 = getNumbers(Integer.class); // 干净

原因很简单:返回值是调用方唯一能拿到类型信息的入口。返回通配符等于把”不能写”的枷锁强加给所有调用者,而且调用方还得在自己的代码里到处写通配符,API 的复杂度会传染。这条规则在《Effective Java》第 31 条里被明确写为”do not use wildcard types as return types”(不要把通配符类型用作返回类型)。

六、PECS 原则:从推理到 API 设计

6.1 PECS 是什么

PECS = Producer Extends, Consumer Super。一句话记忆:

  • 如果一个泛型容器产出(producer)数据给你用(你从中读取 T)→ 用 <? extends T>;
  • 如果一个泛型容器消费(consumer)你给的数据(你往里写入 T)→ 用 <? super T>;
  • 既产出又消费 → 别用通配符,用精确类型 T。

6.2 推理过程(而不是死记)

推理的起点永远是那句自问:“这个变量的实际类型可能是哪些?当前操作对每一种可能都安全吗?”

对于 List<? extends Number> nums:实际类型可能是 List<Integer>、List<Double>、List<Long>……

  • 读:不管哪一种,取出来的元素都一定是 Number → 对每一种可能都安全 → 允许;
  • 写:add(Integer) 对 List<Double> 不安全 → 存在反例 → 禁止。

对于 List<? super Integer> ints:实际类型可能是 List<Integer>、List<Number>、List<Object>……

  • 写:add(Integer) 对这三种都安全(Integer 是 Integer/Number/Object 的子类型)→ 允许;
  • 读:List<Object> 里取出来的未必是 Integer → 存在反例 → 只能赋给 Object。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import java.util.*;

public class PecsDemo {
static double sumOf(List<? extends Number> nums) { // nums 是生产者 -> extends
double total = 0;
for (Number n : nums) { total += n.doubleValue(); }
// nums.add(1); // 编译错误:required: capture of ? extends Number
return total;
}

static void fillWithOnes(List<? super Integer> ints) { // ints 是消费者 -> super
for (int i = 0; i < 5; i++) {
ints.add(1); // OK
}
// ints.add(2.5); // 编译错误:required: capture of ? super Integer
// Integer x = ints.get(0); // 编译错误:Object 无法转 Integer
Object x = ints.get(0); // 只能这样
}

static <T> void copyTo(List<? super T> dest, List<? extends T> src) { // 一个消费一个生产
for (int i = 0; i < src.size(); i++) {
dest.set(i, src.get(i)); // src 产、dest 吃,方向完全对得上
}
}

public static void main(String[] args) {
System.out.println(sumOf(List.of(1, 2, 3))); // Integer -> 6.0
System.out.println(sumOf(List.of(1.5, 2.5))); // Double -> 4.0

List<Number> sink = new ArrayList<>();
fillWithOnes(sink); // 可以喂 List<Number>
List<Object> objSink = new ArrayList<>();
fillWithOnes(objSink); // 也可以喂 List<Object>
}
}

6.3 Collections.copy 源码里的 PECS

JDK 自己的 java.util.Collections.copy 是这个原则最标准的示范:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public static <T> void copy(List<? super T> dest, List<? extends T> src) {
int srcSize = src.size();
if (srcSize > dest.size())
throw new IndexOutOfBoundsException("Source does not fit in dest");

if (srcSize < COPY_THRESHOLD ||
(src instanceof RandomAccess && dest instanceof RandomAccess)) {
for (int i = 0; i < srcSize; i++)
dest.set(i, src.get(i)); // src 读(extends),dest 写(super)
} else {
ListIterator<? super T> di = dest.listIterator(); // 迭代器也遵循同样的边界
ListIterator<? extends T> si = src.listIterator();
for (int i = 0; i < srcSize; i++) {
di.next();
di.set(si.next());
}
}
}

如果写成 copy(List<T> dest, List<T> src),那么”把 List<Integer> 拷进 List<Number>“这个完全合理的需求就实现不了——T 无法同时是 Integer 和 Number。PECS 泛型签名把它变成了合法调用。

Collections.max 的签名演进同样体现了这一点,这个签名常被称作”泛型签名设计的天花板”:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// JDK 早期:过于严格,要求 T 必须直接实现 Comparable<T>
// public static <T extends Comparable<T>> T max(Collection<T> coll)

// JDK 现在的签名:两处放宽
// 1) Comparable<? super T>:允许 T 的比较逻辑继承自父类
// 2) Collection<? extends T>:允许传入 T 子类型的集合
public static <T extends Comparable<? super T>> T max(Collection<? extends T> coll) {
Iterator<? extends T> i = coll.iterator();
T candidate = i.next();
while (i.hasNext()) {
T next = i.next();
if (next.compareTo(candidate) > 0) candidate = next;
}
return candidate;
}

6.4 什么时候用精确类型参数而不是通配符

《Effective Java》第 31 条的完整表述是”用有限制通配符来提升 API 的灵活性”,但它同时给了三条刹车:

  1. 返回值不用通配符(见 5.6);
  2. 类型参数在方法签名中出现多次且需要互相关联时,必须用精确类型参数。例如 <T> void swap(List<T> list, int i, int j),如果写成 void swap(List<?> list, int i, int j),两次 ? 是不同捕获,方法体根本没法写——5.5 节的 helper 就是为了让签名回到精确类型参数;
  3. 通配符会让方法体变复杂时,权衡收益。通配符的好处是”调用方灵活”,代价是”实现方难受”。如果 API 是内部使用的,精确类型参数往往更好读。

一个判断口诀:先看类型参数在签名里出现了几次、有没有”两个位置必须是同一个类型”的约束。有约束 → 精确 T;无约束且只读 → ? extends;无约束且只写 → ? super。

七、常见坑与实战技巧

7.1 泛型数组的创建

new T[]、new List<String>[10] 都是编译错误(数组是协变的、且运行期需要知道元素类型,与擦除天然冲突)。可行的三种写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import java.lang.reflect.Array;
import java.util.*;

public class GenericArray {
// 方案一(推荐):用 List 代替数组
static <T> List<T> asList(T... elements) {
return new ArrayList<>(Arrays.asList(elements));
}

// 方案二:反射 Array.newInstance,得到真实运行期类型的数组
@SuppressWarnings("unchecked")
static <T> T[] newArray(Class<T> componentType, int length) {
return (T[]) Array.newInstance(componentType, length);
}

// 方案三:先创建无界通配符数组再强转(最常用,但要清楚它是不安全的)
@SuppressWarnings("unchecked")
static List<String>[] newStringListArray(int length) {
return (List<String>[]) new List<?>[length]; // 元素仍是 null,需逐个初始化
}

public static void main(String[] args) {
String[] arr = newArray(String.class, 3);
System.out.println(arr.getClass()); // class [Ljava.lang.String;

List<String>[] lists = newStringListArray(2);
lists[0] = new ArrayList<>(List.of("a"));
lists[1] = new ArrayList<>(List.of("b"));
System.out.println(Arrays.toString(lists)); // [[a], [b]]
}
}

List<String>[] 这个类型是合法的(可以作为变量类型、参数类型、返回值类型),只是不能用 new 直接创建。它最大的坑在于与 Object[] 的互操作:

1
2
3
List<String>[] a = (List<String>[]) new List<?>[1];
Object[] objs = a; // 数组协变,编译通过
// objs[0] = new ArrayList<Integer>(); // 编译通过,运行期 ArrayStoreException

ArrayStoreException 是 JVM 数组协变留下的最后一个安全阀,但泛型擦除让它检查不到元素内部的泛型参数——这也是 7.1 里”方案三不安全”的真正含义。

7.2 泛型与重载 / 重写的冲突

1
2
3
4
5
6
7
8
9
10
11
12
class Base<T> {
void set(T t) { }
}
class Sub extends Base<String> {
// 正确:这是重写,编译器会为它生成 set(Object) 桥接方法
@Override void set(String s) { }

// 错误:与桥接方法擦除冲突
// void set(Object o) { }
// 编译错误:name clash: set(Object) in Sub and set(T) in Base have the same erasure,
// yet neither overrides the other
}

重写时的判断标准是擦除后的签名是否一致,而不是源码里写的是否一致。IDE 生成的 @Override 注解能通过,就说明编译器判定它是重写。

7.3 自限定类型与递归类型边界

JDK 里最著名的自限定类型(self-bounded type)是 Enum 的声明:

1
2
3
4
5
6
7
8
9
10
11
// java.lang.Enum 的真实声明(JDK 17)
public abstract class Enum<E extends Enum<E>> implements Comparable<E>, Serializable {
private final String name;
private final int ordinal;
protected Enum(String name, int ordinal) { this.name = name; this.ordinal = ordinal; }
public final int compareTo(E o) { ... }
public final Class<E> getDeclaringClass() { ... }
}

// 使用时:Color 把自己作为类型参数传给 Enum
enum Color { RED, GREEN, BLUE } // 编译器实际生成:final class Color extends Enum<Color>

E extends Enum<E> 这种”类型参数约束到自己”的写法叫递归类型边界(recursive type bound)。它解决的是:让 compareTo、getDeclaringClass 的签名自动适配到具体的枚举子类,从而保证 Color.RED.compareTo(Color.GREEN) 合法,而 Color.RED.compareTo(Size.BIG) 编译不过——即同类型之间才可比较。

自己写递归边界的典型场景是”链式 setter 的父类”:

1
2
3
4
5
6
7
8
9
10
11
12
13
abstract class Node<Self extends Node<Self>> {
private Self next;
@SuppressWarnings("unchecked")
protected Self self() { return (Self) this; }

@SuppressWarnings("unchecked")
public Self next(Self n) { this.next = n; return self(); }
}
final class TextNode extends Node<TextNode> {
private String text;
public TextNode text(String t) { this.text = t; return this; }
}
// 用法:new TextNode().text("hi").next(otherTextNode)

7.4 泛型异常:能 throws,不能 catch

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class GenericException {
// 合法:类型参数可以作为 throws 子句的类型变量(要求 T extends Throwable)
static <T extends Throwable> void throwAs(Class<T> type, String msg) throws T {
try {
T t = type.getConstructor(String.class).newInstance(msg);
throw t;
} catch (ReflectiveOperationException e) {
throw new RuntimeException(e);
}
}

static void demo() throws java.io.IOException {
throwAs(java.io.IOException.class, "磁盘读失败");
}

// 非法:catch 子句不能使用类型参数
// static <T extends Throwable> void badCatch() {
// try { } catch (T e) { } // 编译错误:cannot use type parameter in catch clause
// }

// 非法:不能泛型化 Throwable 的子类
// class MyException<T> extends Exception { } // 编译错误
}

为什么 catch (T e) 不行?因为 catch 的多路匹配是运行期行为,JVM 需要真实的类来做异常表匹配,而 T 在运行期已经消失了。这个限制也直接导致了 7.5 里”用 lambda 包装受检异常”这类变通方案的出现。

7.5 泛型与反射:ParameterizedType 与 TypeReference

擦除让 list.getClass() 永远返回 java.util.ArrayList,拿不到 String。但 4.1 节说过,泛型信息保留在 Signature 属性里,反射可以把它读出来——这正是 JSON 库反序列化泛型对象的技术基础。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import java.lang.reflect.*;
import java.util.*;

// 模拟 Jackson / Fastjson2 的 TypeReference 思路
abstract class TypeReference<T> {
private final Type type;
protected TypeReference() {
// 拿到 "TypeReference<List<User>>" 这个父类签名
Type superclass = getClass().getGenericSuperclass();
if (!(superclass instanceof ParameterizedType)) {
throw new IllegalArgumentException("缺少泛型参数");
}
// 取出第一个实际类型参数,即 List<User>
this.type = ((ParameterizedType) superclass).getActualTypeArguments()[0];
}
public Type getType() { return type; }
}

class User { public String name; }

public class ReflectionDemo {
// 字段上的泛型也能通过 Field.getGenericType() 拿到
static class Holder {
Map<String, List<User>> data;
}

public static void main(String[] args) throws Exception {
// 匿名内部类会生成一个真实子类,其父类签名里带着 <List<User>>
TypeReference<List<User>> ref = new TypeReference<List<User>>() {};
System.out.println(ref.getType());
// 输出:java.util.List<ReflectionDemo$User>

ParameterizedType pt = (ParameterizedType) ref.getType();
System.out.println(pt.getRawType()); // interface java.util.List
System.out.println(pt.getActualTypeArguments()[0]); // class ReflectionDemo$User

// 字段维度
Field f = Holder.class.getDeclaredField("data");
System.out.println(f.getGenericType()); // java.util.Map<java.lang.String, java.util.List<...$User>>
}
}

关键点:new TypeReference<List<User>>() {} 后面的那对花括号绝不是可省略的装饰。只有创建匿名子类,才会生成一个真实 class 文件,把它父类的完整泛型签名写进 Signature 属性,反射才有东西可读。直接 new TypeReference<List<User>>() 则拿不到任何信息。

这也是为什么 Gson.fromJson(json, new TypeToken<List<User>>(){}.getType()) 必须带 {}。

八、高频面试题

# 问题 要点答案
1 类型擦除发生在什么时候? 编译期(javac 生成字节码时)。JVM 加载类时完全看不到泛型,泛型信息只以 Signature 属性形式存在,供编译器与反射读取
2 擦除的三条规则是什么? 无界→Object;有界→边界类型;多边界 T extends A & B → 最左边的 A
3 为什么 Java 用擦除而不是像 C++ 那样具体化泛型? 向后兼容:不改动 JVM 与字节码格式,让 JDK 5 之前的代码与类库无需重编译即可共存;同时避免模板式的代码膨胀
4 为什么不能 new T()? 擦除后变成 new Object(),语义完全不对。绕过方式:Supplier<T> 工厂或 Class<T>.newInstance()
5 为什么不能创建泛型数组 new T[] / new List<String>[10]? 数组是协变的且运行期需要确切的元素类型做 ArrayStoreException 检查,与擦除冲突。绕过:Array.newInstance 或 (List<String>[]) new List<?>[n]
6 checkcast 插在哪里? 插在调用方,即泛型方法返回之后、赋值给具体类型变量之前。泛型类内部的方法体里没有转换
7 桥接方法为什么必须存在? 擦除让子类方法的签名与父类擦除后的签名不一致,若不生成 ACC_BRIDGE + ACC_SYNTHETIC 的转发方法,通过父类/接口引用调用时多态会失效(Comparable、ArrayList 子类的重写都是典型场景)
8 PECS 是什么?怎么理解? Producer Extends, Consumer Super。只读(产出)用 ? extends,只写(消费)用 ? super,既读又写用精确类型参数 T
9 为什么 List<? extends T> 不能 add? 它的实际类型可能是 T 的任意子类型的 List,写入 T(或其子类)对其中某些实际类型不安全,编译器无法证明安全故一律拒绝,null 除外
10 List<Object> 与 List<?> 的区别? List<Object> 是装 Object 的容器,可以 add(new Object());List<?> 是装”某种未知类型”的容器,除 null 外不能写入,但能接受任何 List<X> 赋值
11 List<String> 能赋值给 List<Object> 吗? 不能。泛型是不变的(invariant),若允许则可通过 List<Object> 引用往里塞任意 Object,破坏类型安全
12 通配符捕获是什么? List<?> 的未知类型在一次调用中被编译器记为 capture of ?。要对其做写入操作,需用私有泛型 helper 方法 static <E> void helper(List<E> l) 把 ? 捕获为具名 E
13 静态方法能使用类的类型参数吗? 不能。类类型参数属于实例维度,静态成员属于类维度,所有实例共享一份静态成员。解决方案是把静态方法自己声明为泛型方法
14 instanceof 能用泛型吗? o instanceof List<String> 非法(非 reifiable 类型),o instanceof List<?> 合法。需要判断元素类型时传入 Class<T> token 用 isInstance()
15 反射如何拿到运行期丢失的泛型信息? 通过 Field.getGenericType()、Method.getGenericReturnType()、Class.getGenericSuperclass() 得到 ParameterizedType,再取 getActualTypeArguments()。TypeReference/TypeToken 的匿名子类({})就是靠 getGenericSuperclass() 实现
16 为什么 catch (T e) 非法而 throws T 合法? throws 是编译期声明,可以用类型变量;catch 需要运行期按真实类匹配异常表,而 T 在运行期已消失
17 Enum<E extends Enum<E>> 为什么要这么写? 递归类型边界,让 compareTo、getDeclaringClass 的签名自动绑定到具体枚举类型,保证只有同类型枚举之间可比较
18 什么是堆污染(heap pollution)? 参数化类型的变量引用了非该类型的对象,典型来源是泛型可变参数 T...(擦除后是 Object[])。用 @SafeVarargs 标注并避免数组写入可缓解

最后给一条工程建议:新代码里永远不要使用原始类型(raw type)。List 而不是 List<?> 会彻底关闭泛型检查,让 1.2 节里的三个老问题原封不动地回来。偶发的 unchecked 警告如果确认安全,用 @SuppressWarnings("unchecked") 配合注释说明原因,而不是放任警告堆积——当警告多到没人看时,真正危险的那一处也就藏住了。


到这里,泛型的编译期世界与运行期世界之间那条分界线应该已经很清楚了:编译器在源码层面做全套类型检查并生成桥接方法与 checkcast,然后亲手把类型参数从字节码的 descriptor 里抹掉,只把原始签名留在 Signature 属性里当化石。理解了这一点,capture of ?、name clash、unchecked cast 这些报错就不再是玄学,而是擦除机制的必然产物。下一篇进入 Java 8 的函数式世界,届时你会发现 lambda 与泛型、类型推断之间的关系远比想象中紧密。