大部分人写泛型只停留在 这一层,一旦 IDE 报出 、、 就只能靠试。本文的目标是把这层窗户纸彻底捅破:擦除到底发生在哪一步、擦完之后字节码里还剩下什么、桥接方法是谁塞进去的、为什么 读得写不得。读完你应该能徒手画出泛型在编译期与运行期之间的那道分界线。
一、为什么需要泛型:Object 时代的三大问题
1.1 JDK 5 之前:一切皆 Object
2004 年 JDK 5 发布之前,Java 的集合框架只有一条路可走:容器内部统一用 Object 存储,取出来由调用方自己强转。下面这段代码在 JDK 1.4 时代是标准写法:
// 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。 转换的正确性完全依赖于"程序员记性"。只要有一个地方塞错了类型,编译能过、测试可能也能过,然后在生产环境某条冷门分支上炸掉:
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> 的逻辑可以服务所有元素类型,而且取出来就是目标类型,无需手写转换。
// 泛型写法
List<String> names = new ArrayList<>();
names.add("张三");
// names.add(new Date()); // 编译错误:不兼容的类型
String first = names.get(0); // 无需强转,编译器保证它一定是 String
需要立刻澄清一个常见误解:泛型不是把强制转换消灭了,而是把强制转换从"你手写"变成了"编译器代劳"。第四节的字节码会证明, 这一行编译后依然有一条 指令,只是它由 自动插入,永远不可能写错。
| 维度 | JDK 5 之前(raw type) | JDK 5 之后(泛型) |
|---|---|---|
| 存储类型 | 统一 Object |
类型参数 E |
| 取出元素 | 手写 (T) 强转 |
自动插入 checkcast,源码无转换 |
| 错误暴露时机 | 运行期 ClassCastException |
编译期 incompatible types |
| 错误定位 | 离源头很远,难排查 | 直接定位到出错的 add 调用 |
| 类型信息载体 | Javadoc 注释 / 口头约定 | 方法签名,编译器可校验 |
| 复用性 | 一套逻辑适用所有类型 | 同样适用,且保留类型信息 |
二、泛型的三种使用形态与类型推断
2.1 泛型类
类型参数声明在类名之后、尖括号内:
/**
* 一个简单的泛型容器: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 泛型方法
泛型方法的关键特征是:类型参数声明在修饰符与返回类型之间,这个位置是它与泛型类的唯一语法区别。
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 泛型接口
// 定义:接口名后带类型参数
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)。静态方法想要泛型,只能自己声明为泛型方法:
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 引入菱形运算符 <>,让构造器调用不必重复写类型参数:
// 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 就有、其实没有的差异:
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> 显式指定,这就是类型见证:
// 推断不出来:实参都是 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);
判断何时需要类型见证的经验法则:当方法的所有实参都无法体现类型参数,或者实参类型存在多个相互冲突的候选时,编译器要么报错、要么推断出一个你不想要的类型。此时直接 显式指定,一行解决,比反复调整实参类型划算得多。
三、类型擦除:规则、动因与四大限制
3.1 三条擦除规则
Java 的泛型是编译期特性,运行期不存在泛型。编译器按以下规则把类型参数从字节码中抹掉:
- 无界类型参数
<T>→ 擦除为Object; - 有界类型参数
<T extends A>→ 擦除为边界A; - 多边界
<T extends A & B & C>→ 擦除为最左边的第一个边界A。
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 擦除带来的限制
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) |
从父类的泛型签名里反查 |
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 会读。
// 源文件 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,关键输出如下:
// ===== 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
}
这份输出说明了三件事:
descriptor(JVM 真正使用的签名)里T一律变成了Object—— 擦除确实发生了;Signature属性里保留了<T:Ljava/lang/Object;>、()TT;等完整泛型信息 —— 信息没丢,只是降级为元数据;get()方法体里没有checkcast,因为擦成Object后返回Object是合法的 —— 转换被推迟到了调用方。
4.2 checkcast 的插入位置
// 源文件 UseBox.java
public class UseBox {
public static void main(String[] args) {
Box<String> box = new Box<>();
box.set("hello");
String s = box.get(); // 这一行,编译器替我们插了 checkcast
}
}
// ===== 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)是擦除机制为了保住多态而打的补丁。看这个例子:
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)。如果有人这样调用:
List rawOrObjectList = new MyList();
rawOrObjectList.add(new Object()); // 按擦除签名,调用的是 add(Object)
JVM 在做虚方法分派时,只认识 add(Object)。而 MyList 里只有 add(String),它并没有真正覆盖 ArrayList.add(Object)。如果不做处理,MyList 的多态就断了——通过父类引用调用时会直接掉进 ArrayList 的原始实现。
编译器因此自动生成一个桥接方法:
// ===== 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>是另一个高发区:
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:
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
规避方案:改名(最实用)、或者让两个方法有不同的参数个数/非泛型参数类型,从根上错开擦除后的签名。
桥接方法还有一个实战副作用:用反射遍历方法时会拿到两份 / 。很多人写过的"通过反射找 setter"的代码突然匹配到了 的桥接方法,导致转换失败。正确做法是在遍历时加一句 。
五、通配符体系:无界、上界与下界
5.1 出发点:泛型是不变的
先看一个必须接受的事实:
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<?> |
装某种未知类型的容器 | 编译错误 | 允许 | 有,且最灵活 |
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>:只能读
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)
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 ?。你可以原样取出来再放回去吗?不行,因为两次出现的 ? 被认为是不同的捕获:
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 就是确定的,读写都合法。
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 通配符不该出现在返回值上
// 反面教材
// 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。
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 是这个原则最标准的示范:
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 的签名演进同样体现了这一点,这个签名常被称作"泛型签名设计的天花板":
// 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 的灵活性",但它同时给了三条刹车:
- 返回值不用通配符(见 5.6);
- 类型参数在方法签名中出现多次且需要互相关联时,必须用精确类型参数。例如
<T> void swap(List<T> list, int i, int j),如果写成void swap(List<?> list, int i, int j),两次?是不同捕获,方法体根本没法写——5.5 节的 helper 就是为了让签名回到精确类型参数; - 通配符会让方法体变复杂时,权衡收益。通配符的好处是"调用方灵活",代价是"实现方难受"。如果 API 是内部使用的,精确类型参数往往更好读。
一个判断口诀:先看类型参数在签名里出现了几次、有没有"两个位置必须是同一个类型"的约束。有约束 → 精确 T;无约束且只读 → ? extends;无约束且只写 → ? super。
七、常见坑与实战技巧
7.1 泛型数组的创建
new T[]、new List<String>[10] 都是编译错误(数组是协变的、且运行期需要知道元素类型,与擦除天然冲突)。可行的三种写法:
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[] 的互操作:
List<String>[] a = (List<String>[]) new List<?>[1];
Object[] objs = a; // 数组协变,编译通过
// objs[0] = new ArrayList<Integer>(); // 编译通过,运行期 ArrayStoreException
ArrayStoreException 是 JVM 数组协变留下的最后一个安全阀,但泛型擦除让它检查不到元素内部的泛型参数——这也是 7.1 里"方案三不安全"的真正含义。
7.2 泛型与重载 / 重写的冲突
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 的声明:
// 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 的父类":
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
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 库反序列化泛型对象的技术基础。
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)。 而不是 会彻底关闭泛型检查,让 1.2 节里的三个老问题原封不动地回来。偶发的 警告如果确认安全,用 配合注释说明原因,而不是放任警告堆积——当警告多到没人看时,真正危险的那一处也就藏住了。
到这里,泛型的编译期世界与运行期世界之间那条分界线应该已经很清楚了:编译器在源码层面做全套类型检查并生成桥接方法与 checkcast,然后亲手把类型参数从字节码的 descriptor 里抹掉,只把原始签名留在 Signature 属性里当化石。理解了这一点,capture of ?、name clash、unchecked cast 这些报错就不再是玄学,而是擦除机制的必然产物。下一篇进入 Java 8 的函数式世界,届时你会发现 lambda 与泛型、类型推断之间的关系远比想象中紧密。