Java 泛型:从语法到类型擦除
泛型(Generics)让类型成为类、接口和方法的参数。List<String> 表示“元素类型为 String 的列表”,List<Integer> 表示“元素类型为 Integer 的列表”。它的直接价值是把错误提前到编译期、消除重复的强制转换;它的深层规则则由类型擦除(type erasure)决定。
本文以 Java 25 的常用语法说明。泛型的核心机制自 Java 5 引入后没有变化,Java 25 仍以类型擦除实现泛型,因此绝大多数内容同样适用于较早版本。
代码示例省略了
java.util、java.lang.reflect与java.util.function的导入。标有“编译错误”的行用于说明规则,不应直接复制执行。
泛型解决的问题
Java 5 之前,集合只能把元素视为 Object。写入时没有约束,读取时必须由调用方强制转换:
1 | |
真正的错误是把字符串放进了“预期为整数”的列表,却在另一个位置才暴露。改用参数化类型后,编译器知道元素类型:
1 | |
泛型带来两项编译期保证。
- 写入检查:只能放入与类型实参兼容的值。
- 读取检查:读取结果已是声明的类型,无需调用方转换。
泛型并不在运行期为 List<String> 和 List<Integer> 生成两个类,也不让对象自行记住类型实参。Java 用类型擦除保持了与 Java 5 前字节码的兼容性;这一点会在后文解释。
基础语法:类型参数和类型实参
<T> 是类型参数(type parameter),类似方法的普通参数;String、Integer 是调用者传入的类型实参(type argument)。下面的 Box<T> 可以保存任何一种指定类型的值:
1 | |
使用时,T 会被替换为具体类型:
1 | |
new Box<>(...) 中的 <> 称为菱形运算符(diamond operator)。编译器从左侧声明和构造参数推断类型;这里分别推断为 String 与 Integer。除非推断失败,避免写冗余的 new Box<String>(...)。
常见类型参数命名
这些名字没有语义约束,但 JDK 的惯例值得遵从。
| 名称 | 含义 | 常见位置 |
|---|---|---|
T | Type,任意类型 | 泛型类、泛型方法 |
E | Element,元素 | 集合 |
K、V | Key、Value | 映射 |
R | Result,返回类型 | 函数式接口 |
A | Accumulator,累积状态 | Collector、Gatherer |
N | Number,数值类型 | 数值算法 |
类型参数可以不止一个:
1 | |
Pair<K, V> 的两个位置相互独立:键可以是 String,值可以是 Integer。不要为了“更泛型”而把无关类型强行关联为同一个 T。
泛型接口与泛型方法
泛型接口把类型关系交给实现者指定。Comparable<T> 就是最常见的例子:String 实现的是 Comparable<String>,因此只能与另一个 String 比较。
1 | |
泛型方法的类型参数写在修饰符之后、返回类型之前。它不要求所在的类是泛型类;静态工具方法通常应优先做成泛型方法:
1 | |
调用 swap(names, 0, 1) 时,编译器从 List<String> 推断出 T 是 String。大多数场景不必显式传类型实参;当上下文不足或为了消除歧义时才写 ClassName.<String>method():
1 | |
类型参数的作用域
类的类型参数属于该类的实例上下文,静态成员不属于任何实例,因此不能直接使用类的 T:
1 | |
identity 是另一个独立的类型参数,恰好也命名为 T。它与 Cache<T> 完全无关;为避免阅读误解,实际代码可为静态方法使用更具体的名字,例如 <R>。
上界:限制类型参数能做什么
无界的 <T> 只知道 T 至少是 Object,因此只能调用 Object 的成员。若算法需要数值能力或某个接口,应声明上界(upper bound):
1 | |
T extends Number 限制调用者只能传入 Number 的子类,并允许方法调用 doubleValue()。泛型中的 extends 同时表达“继承类”与“实现接口”:
1 | |
一个类型参数可以有多个上界,使用 & 连接。最多一个类上界,且若存在必须写在第一位;余下必须是接口:
1 | |
没有 <T super Number> 这样的“类型参数下界”语法。下界只用于通配符,例如 List<? super Number>;原因是类型参数描述一个要命名、要在多个位置关联的具体类型,而下界通配符描述一个无需命名的未知父类型。
F-bounded type:与自身或父类型比较
JDK 中 Collections.max 使用的约束比 Comparable<T> 宽:
1 | |
Comparable<? super T> 的含义是:T 能与 T 或其任意父类型比较。例如 ScheduledTask 继承了 Task,若 Task implements Comparable<Task>,则 ScheduledTask 也应可参与比较;严格写成 Comparable<T> 会错误地排除这种类型。这个模式称为 F-bounded polymorphism,主要出现在排序、枚举和流式 API 的类型约束中。
泛型默认不变:List<Dog> 不是 List<Animal>
假设 Dog extends Animal。普通引用满足 Dog 是 Animal 的子类型,但 List<Dog> 不是 List<Animal> 的子类型:
1 | |
若允许赋值,就能经由 animals 放入一只 Cat,从而破坏 dogs 的元素约束:
1 | |
这种关系称为不变(invariant)。它保证集合的读写都在编译期安全。
数组采取了另一条路线:Dog[] 是 Animal[] 的子类型,即数组是协变的(covariant)。代价是 JVM 必须在每次写入时检查元素类型,错误会推迟到运行期:
1 | |
优先使用泛型集合而不是数组:集合的类型错误更早暴露,API 也更灵活。
通配符:安全地表达协变和逆变
通配符 ? 代表一个未知类型。它不是 Object,而是“某一个具体类型,但调用方不知道是哪一个”。Java 通过上界和下界通配符,在方法边界表达受控的型变。
| 类型 | 可安全读取为 | 可安全写入 | 语义 |
|---|---|---|---|
List<T> | T | T | 确切的 T 列表 |
List<? extends T> | T | 仅 null | 某个 T 子类型的列表 |
List<? super T> | Object | T 及其子类 | 某个 T 父类型的列表 |
List<?> | Object | 仅 null | 元素类型未知的列表 |
上界通配符:从生产者读取
List<? extends Number> 可以接收 List<Integer>、List<Long> 等任何 Number 子类型列表。元素至少是 Number,所以读取安全;但具体子类型未知,因此不能安全地加入任何非 null 值:
1 | |
“不能添加”指不能添加元素,并非不能修改列表结构。clear()、remove() 等不依赖元素具体类型的操作仍然合法,但在 API 设计上不要依靠这种细节制造意外的副作用。
下界通配符:向消费者写入
List<? super Integer> 可以指向 List<Integer>、List<Number> 或 List<Object>。不论真实类型是哪一个,放入 Integer 都安全;取出时只能确定它是 Object:
1 | |
PECS:声明方法参数的实用规则
PECS 是 Producer Extends, Consumer Super 的缩写:参数向方法提供 T,用 ? extends T;参数接收方法产生的 T,用 ? super T。Collections.copy 的签名正是标准示例:
1 | |
下面的调用中,source 生产 Integer,destination 消费 Integer;类型参数 T 被推断为 Integer:
1 | |
PECS 中的生产者和消费者是相对于当前方法而言的,不是集合本身的永久属性。同一个 List<Integer> 在一个方法里可以是源,在另一个方法里可以是目标。
List<?> 与 List<Object> 不同
List<Object> 是“恰好存放 Object 的列表”,可以加入任意引用类型;List<?> 是“元素类型未知的列表”,只适合不依赖元素类型的操作:
1 | |
因此,printAll 接受 List<String>、List<Integer> 和 List<Object>;而 addAnything 只能接受 List<Object>。把第一个参数改成 List<Object> 会无意义地拒绝大多数调用者。
通配符捕获:为什么辅助泛型方法能解决问题
通配符代表一个固定但未知的类型。若一个操作需要在同一个未知类型的位置读和写,可以让辅助泛型方法“捕获”它:
1 | |
调用 reverse0(values) 时,编译器把 ? 捕获为某个 CAP#1,并在整个调用中令 T = CAP#1。因此读出的值与写回的位置是同一种类型。不要用 List<Object> 强行替代 List<?>;那会改变可接受的实参范围,不能解决捕获问题。
类型参数和通配符如何选择
当一个方法中只有一个参数涉及未知元素类型,且该类型不需要与返回值或其他参数关联,优先通配符:
1 | |
当类型要在多个位置保持一致,必须命名它,使用类型参数:
1 | |
first 的返回值必须与列表元素类型相同,故不能写成 Object first(List<?> values);后者虽然可编译,却丢掉了有价值的类型信息。反过来,若只遍历并输出,写 <T> void logAll(List<T>) 会引入一个没有用途的名字。
字段、局部变量和返回类型通常应使用具体参数化类型,通配符主要服务于输入参数。返回 List<?> 会迫使所有调用者重新处理“未知元素类型”;字段声明为 List<? extends Number> 也会让类内部不能方便地写入。除非 API 真正要隐藏元素类型,否则避免这样设计。
类型擦除:泛型在字节码中的实现
Java 编译器会检查泛型规则,然后擦除类型参数,使旧 JVM 与旧类库仍能运行。大致可以把下面的泛型类理解为右边的非泛型形式:
1 | |
实际生成的类仍叫 Box,上面的 ErasedBox 只是说明。擦除规则如下。
- 无界类型参数
T擦除为Object。 T extends Number擦除为Number。- 多个上界时,
T擦除为第一个上界。 - 参数化类型擦除为原始类型,例如
List<String>擦除为List。 - 编译器在读取处插入必要的强制转换。
例如 Box<String> 的 get() 在字节码层面仍返回 Object,但编译器会在赋值给 String 的位置插入 checkcast String。只要没有绕过编译器的类型检查,这个转换必然成功。
类型实参会以泛型签名(Signature)元数据保留在类、字段、方法的声明中,供编译器和反射工具读取;但不会成为每个对象的运行期状态。因此,new ArrayList<String>() 创建出的对象不能在运行期回答“我的元素类型是 String”。
类型擦除带来的限制与替代方案
不能使用基本类型
类型实参必须是引用类型:
1 | |
原因是擦除后的元素位置必须能表示为 Object 或某个引用上界。Integer 会触发装箱和拆箱;对大规模数值流处理,可评估 IntStream、LongStream、DoubleStream 或专门的原始类型集合库。允许 List<int> 的泛型特化是 Valhalla 项目的目标,截至 Java 25 尚未落地。
不能直接实例化类型参数
编译器不知道 T 的构造器,更不知道擦除后应实例化什么类:
1 | |
优先让调用方提供构造策略。Supplier 不要求无参构造器,也不会把反射异常泄漏到 API 中:
1 | |
若确实需要类元数据,可传入 Class<T> 并调用受控的反射代码;但 Class<T> 只能描述原始类,不能表达完整的 List<String> 之类参数化类型。
不能创建参数化类型数组
下面的代码被禁止:
1 | |
数组在运行期知道组件类型且协变,泛型在运行期擦除且不变。若允许创建,会使 JVM 无法阻止“向数组放入 List<Integer>,再从中取 List<String>”的堆污染。通常应直接使用嵌套集合:
1 | |
内部实现偶尔需要 T[],应优先由调用方提供数组构造器,例如 Stream.toArray(String[]::new)。(T[]) new Object[size] 是未经检查的转换,只有在数组绝不以 T[] 身份暴露、且安全性已被证明时才可局部使用。
不能检查确切的参数化类型
instanceof 不知道 List 的实际类型实参:
1 | |
可以检查 List<?>,因为它的运行期表示就是 List。如果输入来自不可信边界,先检查容器类型,再逐个验证元素类型;一次强制转换为 List<String> 只会得到 unchecked 警告,并不能验证元素。
不能按类型实参重载
不同的类型实参擦除后有相同的方法签名,因此会发生名称冲突:
1 | |
返回类型不能参与 Java 的重载解析,也不能修复这个冲突。应改变方法名、改变非擦除后相同的参数类型,或让一个泛型方法在内部处理两种元素类型。
泛型不能继承 Throwable
异常类型必须在运行期可确定,故泛型类不能直接或间接继承 Throwable:
1 | |
同理,不能 catch (T error),也不能用类型实参构造精确的异常捕获逻辑。用普通异常层级、错误码或承载错误详情的字段表达差异。
原始类型、unchecked 警告与堆污染
原始类型(raw type)是省略类型实参的遗留写法,例如 List 而不是 List<String>。它存在的唯一主要原因是兼容 Java 5 前编译的类库;新代码不应主动使用:
1 | |
这里,编译器曾经能维护的“列表只含 Integer”不变量被 raw type 绕过,这称为堆污染(heap pollution):一个参数化变量引用了不符合其类型实参的对象。
编译器的 unchecked 警告不是可忽略的样式问题,而是类型安全证明中断的边界。处理顺序应是:修复为完整泛型类型;若是第三方或遗留边界,先验证输入;最后才在最小作用域使用 @SuppressWarnings("unchecked"),并在注释中说明为何转换安全。不要在类、包或构建配置层面全局压制警告。
泛型可变参数与 @SafeVarargs
可变参数本质是数组。T... 在 T 不是可再具体化类型(reifiable type)时可能制造堆污染,编译器会给出警告:
1 | |
@SafeVarargs 不是“关闭警告”的装饰,而是作者对调用者的承诺:方法不会向 values 写入错误类型,也不会把该数组泄漏给可能污染它的代码。它只允许标注不能被子类覆盖的方法,例如 static、final、private 方法和构造器。无法证明安全时,应改用 List<T> 参数。
桥方法:擦除后如何保持多态
泛型父类的方法在擦除后可能与子类覆盖方法的参数类型不同:
1 | |
Node<T>.set(T) 擦除为 set(Object),但 StringNode.set(String) 不是字节码层面同签名的方法。为了让下面的多态调用仍然正确,编译器会在 StringNode 中生成桥方法:
1 | |
桥方法的效果近似于:
1 | |
它由编译器生成并标记为 bridge/synthetic,普通业务代码不用直接调用。反射框架若扫描覆盖方法或注解,可能需要通过 Method.isBridge() 跳过桥方法,避免同一逻辑被识别两次。
反射如何取得声明处的泛型信息
虽然对象不保存类型实参,字段、方法和父类声明的泛型签名仍可由反射读取:
1 | |
getType() 返回擦除后的 Class,这里是 List.class;getGenericType() 返回 Type,这里通常是 ParameterizedType,其中包含 String。相关的反射子接口包括:
Class<?>:普通类、接口、数组、基本类型或void。ParameterizedType:如Map<String, Integer>。TypeVariable<?>:如class Box<T>中的T。WildcardType:如? extends Number。GenericArrayType:如T[]。
这解释了 Jackson、Gson 等库为什么需要 TypeReference 或 TypeToken:List<String> 的对象本身没有元素类型信息,框架必须从字段、方法参数或匿名子类的泛型父类声明中获取它。局部变量的泛型声明通常不是稳定的运行期契约,不应把反序列化所需类型信息寄托于它。
设计泛型 API 的准则
- 让类型信息流向调用方。 返回
T、List<T>等具体关系,而不是无理由地返回Object或List<?>。 - 输入参数接受需要的最宽类型。 只读取时用
? extends T,只写入时用? super T;读写都需要时用T。 - 避免暴露 raw type 和 unchecked 转换。 若不可避免,将它们封装在一个经过验证的边界内。
- 不要为“未来可能的类型”预先泛型化。 没有真实的多类型调用场景时,具体类型的 API 更易理解。
- 不要把泛型当作运行期类型分发。 需要根据实际类型分支时,使用普通多态、显式标识或受控的运行期检查。
- 优先集合,不要泛型数组。 这同时避开协变数组与擦除泛型之间的根本冲突。
速查:读写规则和常见误区
| 需求 | 推荐签名 | 原因 |
|---|---|---|
| 只遍历任意元素列表 | List<?> | 不关心元素具体类型 |
读取 Number 或其子类 | List<? extends Number> | 调用者可传 List<Integer> |
写入 Integer | List<? super Integer> | 可传 List<Integer>、List<Number> |
| 读写同一种元素 | List<T> | T 同时保留读写类型 |
| 返回首个元素 | <T> T first(List<T>) | 返回类型与输入元素关联 |
创建 T | Supplier<? extends T> | 不依赖反射或无参构造器 |
最重要的三条规则可以压缩为:泛型默认不变;输入参数按 PECS 选择通配符;遇到运行期限制先想到类型擦除。 以这三条检查 API 签名,绝大多数泛型编译错误都能定位到“类型关系没有被表达出来”,而不是编译器的偶然限制。






