泛型(Generics)让类型成为类、接口和方法的参数。List<String> 表示“元素类型为 String 的列表”,List<Integer> 表示“元素类型为 Integer 的列表”。它的直接价值是把错误提前到编译期、消除重复的强制转换;它的深层规则则由类型擦除(type erasure)决定。

本文以 Java 25 的常用语法说明。泛型的核心机制自 Java 5 引入后没有变化,Java 25 仍以类型擦除实现泛型,因此绝大多数内容同样适用于较早版本。

代码示例省略了 java.utiljava.lang.reflectjava.util.function 的导入。标有“编译错误”的行用于说明规则,不应直接复制执行。

泛型解决的问题

Java 5 之前,集合只能把元素视为 Object。写入时没有约束,读取时必须由调用方强制转换:

1
2
3
4
5
List values = new ArrayList();
values.add("one");

Integer number = (Integer) values.get(0);
// 直到运行这里才抛出 ClassCastException

真正的错误是把字符串放进了“预期为整数”的列表,却在另一个位置才暴露。改用参数化类型后,编译器知道元素类型:

1
2
3
4
5
List<Integer> values = new ArrayList<>();
values.add(1);

Integer number = values.get(0);
values.add("one"); // 编译错误:需要 Integer

泛型带来两项编译期保证。

  1. 写入检查:只能放入与类型实参兼容的值。
  2. 读取检查:读取结果已是声明的类型,无需调用方转换。

泛型并不在运行期为 List<String>List<Integer> 生成两个类,也不让对象自行记住类型实参。Java 用类型擦除保持了与 Java 5 前字节码的兼容性;这一点会在后文解释。

基础语法:类型参数和类型实参

<T> 是类型参数(type parameter),类似方法的普通参数;StringInteger 是调用者传入的类型实参(type argument)。下面的 Box<T> 可以保存任何一种指定类型的值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public final class Box<T> {
private T value;

public Box(T value) {
this.value = value;
}

public T get() {
return value;
}

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

使用时,T 会被替换为具体类型:

1
2
3
4
5
Box<String> name = new Box<>("Ada");
String value = name.get();

Box<Integer> count = new Box<>(42);
Integer number = count.get();

new Box<>(...) 中的 <> 称为菱形运算符(diamond operator)。编译器从左侧声明和构造参数推断类型;这里分别推断为 StringInteger。除非推断失败,避免写冗余的 new Box<String>(...)

常见类型参数命名

这些名字没有语义约束,但 JDK 的惯例值得遵从。

名称含义常见位置
TType,任意类型泛型类、泛型方法
EElement,元素集合
KVKey、Value映射
RResult,返回类型函数式接口
AAccumulator,累积状态CollectorGatherer
NNumber,数值类型数值算法

类型参数可以不止一个:

1
2
3
4
public record Pair<K, V>(K key, V value) {}

Pair<String, Integer> score =
new Pair<>("Ada", 100);

Pair<K, V> 的两个位置相互独立:键可以是 String,值可以是 Integer。不要为了“更泛型”而把无关类型强行关联为同一个 T

泛型接口与泛型方法

泛型接口把类型关系交给实现者指定。Comparable<T> 就是最常见的例子:String 实现的是 Comparable<String>,因此只能与另一个 String 比较。

1
2
3
4
5
6
public interface Mapper<T, R> {
R apply(T value);
}

Mapper<String, Integer> length = String::length;
int n = length.apply("java");

泛型方法的类型参数写在修饰符之后、返回类型之前。它不要求所在的类是泛型类;静态工具方法通常应优先做成泛型方法:

1
2
3
4
5
6
7
8
9
public static <T> void swap(
List<T> list, int left, int right) {
T value = list.get(left);
list.set(left, list.get(right));
list.set(right, value);
}

List<String> names = new ArrayList<>(List.of("A", "B"));
swap(names, 0, 1);

调用 swap(names, 0, 1) 时,编译器从 List<String> 推断出 TString。大多数场景不必显式传类型实参;当上下文不足或为了消除歧义时才写 ClassName.<String>method()

1
List<String> names = Collections.<String>emptyList();

类型参数的作用域

类的类型参数属于该类的实例上下文,静态成员不属于任何实例,因此不能直接使用类的 T

1
2
3
4
5
6
7
8
9
class Cache<T> {
private T latest;

// private static T shared; // 编译错误

static <T> T identity(T value) {
return value;
}
}

identity 是另一个独立的类型参数,恰好也命名为 T。它与 Cache<T> 完全无关;为避免阅读误解,实际代码可为静态方法使用更具体的名字,例如 <R>

上界:限制类型参数能做什么

无界的 <T> 只知道 T 至少是 Object,因此只能调用 Object 的成员。若算法需要数值能力或某个接口,应声明上界(upper bound):

1
2
3
4
5
6
7
8
public static <T extends Number> double sum(
List<T> values) {
double total = 0;
for (T value : values) {
total += value.doubleValue();
}
return total;
}

T extends Number 限制调用者只能传入 Number 的子类,并允许方法调用 doubleValue()。泛型中的 extends 同时表达“继承类”与“实现接口”:

1
2
3
4
public static <T extends Comparable<T>> T larger(
T left, T right) {
return left.compareTo(right) >= 0 ? left : right;
}

一个类型参数可以有多个上界,使用 & 连接。最多一个类上界,且若存在必须写在第一位;余下必须是接口:

1
2
3
4
public static <T extends Number & Comparable<T>> T larger(
T left, T right) {
return left.compareTo(right) >= 0 ? left : right;
}

没有 <T super Number> 这样的“类型参数下界”语法。下界只用于通配符,例如 List<? super Number>;原因是类型参数描述一个要命名、要在多个位置关联的具体类型,而下界通配符描述一个无需命名的未知父类型。

F-bounded type:与自身或父类型比较

JDK 中 Collections.max 使用的约束比 Comparable<T> 宽:

1
2
3
4
5
public static <T extends Comparable<? super T>> T max(
Collection<? extends T> values) {
// 省略遍历实现
throw new UnsupportedOperationException();
}

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。普通引用满足 DogAnimal 的子类型,但 List<Dog> 不是 List<Animal> 的子类型:

1
2
3
List<Dog> dogs = new ArrayList<>();

// List<Animal> animals = dogs; // 编译错误

若允许赋值,就能经由 animals 放入一只 Cat,从而破坏 dogs 的元素约束:

1
2
3
// 假设上一段赋值被允许
animals.add(new Cat());
Dog dog = dogs.get(0); // 读取到 Cat,不再安全

这种关系称为不变(invariant)。它保证集合的读写都在编译期安全。

数组采取了另一条路线:Dog[]Animal[] 的子类型,即数组是协变的(covariant)。代价是 JVM 必须在每次写入时检查元素类型,错误会推迟到运行期:

1
2
Animal[] animals = new Dog[1];
animals[0] = new Cat(); // 运行时 ArrayStoreException

优先使用泛型集合而不是数组:集合的类型错误更早暴露,API 也更灵活。

泛型不变性与数组协变:List Dog 不可赋给 List Animal,而数组协变在运行期抛出异常
泛型用“不变”阻止错误写入;数组则允许协变,并把检查留给运行期。

通配符:安全地表达协变和逆变

通配符 ? 代表一个未知类型。它不是 Object,而是“某一个具体类型,但调用方不知道是哪一个”。Java 通过上界和下界通配符,在方法边界表达受控的型变。

类型可安全读取为可安全写入语义
List<T>TT确切的 T 列表
List<? extends T>Tnull某个 T 子类型的列表
List<? super T>ObjectT 及其子类某个 T 父类型的列表
List<?>Objectnull元素类型未知的列表

上界通配符:从生产者读取

List<? extends Number> 可以接收 List<Integer>List<Long> 等任何 Number 子类型列表。元素至少是 Number,所以读取安全;但具体子类型未知,因此不能安全地加入任何非 null 值:

1
2
3
4
5
List<Integer> ints = List.of(1, 2, 3);
List<? extends Number> numbers = ints;

Number first = numbers.get(0);
// numbers.add(4); // 编译错误:可能原本是 List<Long>

“不能添加”指不能添加元素,并非不能修改列表结构。clear()remove() 等不依赖元素具体类型的操作仍然合法,但在 API 设计上不要依靠这种细节制造意外的副作用。

下界通配符:向消费者写入

List<? super Integer> 可以指向 List<Integer>List<Number>List<Object>。不论真实类型是哪一个,放入 Integer 都安全;取出时只能确定它是 Object

1
2
3
4
5
6
List<Number> numbers = new ArrayList<>();
List<? super Integer> sink = numbers;

sink.add(1);
sink.add(2);
Object first = sink.get(0);

PECS:声明方法参数的实用规则

PECS 是 Producer Extends, Consumer Super 的缩写:参数向方法提供 T,用 ? extends T;参数接收方法产生的 T,用 ? super TCollections.copy 的签名正是标准示例:

1
2
3
4
5
6
7
public static <T> void copy(
List<? super T> destination,
List<? extends T> source) {
for (int i = 0; i < source.size(); i++) {
destination.set(i, source.get(i));
}
}

下面的调用中,source 生产 Integerdestination 消费 Integer;类型参数 T 被推断为 Integer

1
2
3
4
5
List<Integer> source = List.of(1, 2, 3);
List<Number> destination =
new ArrayList<>(List.of(0, 0, 0));

copy(destination, source);

PECS 中的生产者和消费者是相对于当前方法而言的,不是集合本身的永久属性。同一个 List<Integer> 在一个方法里可以是源,在另一个方法里可以是目标。

List<?>List<Object> 不同

List<Object> 是“恰好存放 Object 的列表”,可以加入任意引用类型;List<?> 是“元素类型未知的列表”,只适合不依赖元素类型的操作:

1
2
3
4
5
6
7
8
9
10
static void printAll(List<?> values) {
for (Object value : values) {
System.out.println(value);
}
}

static void addAnything(List<Object> values) {
values.add("text");
values.add(1);
}

因此,printAll 接受 List<String>List<Integer>List<Object>;而 addAnything 只能接受 List<Object>。把第一个参数改成 List<Object> 会无意义地拒绝大多数调用者。

通配符捕获:为什么辅助泛型方法能解决问题

通配符代表一个固定但未知的类型。若一个操作需要在同一个未知类型的位置读和写,可以让辅助泛型方法“捕获”它:

1
2
3
4
5
6
7
8
9
10
11
12
public static void reverse(List<?> values) {
reverse0(values);
}

private static <T> void reverse0(List<T> values) {
for (int left = 0, right = values.size() - 1;
left < right; left++, right--) {
T value = values.get(left);
values.set(left, values.get(right));
values.set(right, value);
}
}

调用 reverse0(values) 时,编译器把 ? 捕获为某个 CAP#1,并在整个调用中令 T = CAP#1。因此读出的值与写回的位置是同一种类型。不要用 List<Object> 强行替代 List<?>;那会改变可接受的实参范围,不能解决捕获问题。

PECS、copy 的类型推断与通配符捕获:T 为 Integer 时从 source 读取并向 destination 写入
`extends` 提供可读作 `T` 的最小保证,`super` 提供可写入 `T` 的最大范围;捕获让未知类型在一次调用中保持一致。

类型参数和通配符如何选择

当一个方法中只有一个参数涉及未知元素类型,且该类型不需要与返回值或其他参数关联,优先通配符:

1
2
3
4
5
public static void logAll(List<?> values) {
for (Object value : values) {
System.out.println(value);
}
}

当类型要在多个位置保持一致,必须命名它,使用类型参数:

1
2
3
4
5
6
7
8
public static <T> T first(List<T> values) {
return values.get(0);
}

public static <T> void move(
List<? super T> target, List<? extends T> source) {
target.addAll(source);
}

first 的返回值必须与列表元素类型相同,故不能写成 Object first(List<?> values);后者虽然可编译,却丢掉了有价值的类型信息。反过来,若只遍历并输出,写 <T> void logAll(List<T>) 会引入一个没有用途的名字。

字段、局部变量和返回类型通常应使用具体参数化类型,通配符主要服务于输入参数。返回 List<?> 会迫使所有调用者重新处理“未知元素类型”;字段声明为 List<? extends Number> 也会让类内部不能方便地写入。除非 API 真正要隐藏元素类型,否则避免这样设计。

类型擦除:泛型在字节码中的实现

Java 编译器会检查泛型规则,然后擦除类型参数,使旧 JVM 与旧类库仍能运行。大致可以把下面的泛型类理解为右边的非泛型形式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Box<T> {
private T value;

T get() {
return value;
}
}

// 擦除后的近似形式
class ErasedBox {
private Object value;

Object get() {
return value;
}
}

实际生成的类仍叫 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
2
// List<int> values = new ArrayList<>(); // 编译错误
List<Integer> values = new ArrayList<>();

原因是擦除后的元素位置必须能表示为 Object 或某个引用上界。Integer 会触发装箱和拆箱;对大规模数值流处理,可评估 IntStreamLongStreamDoubleStream 或专门的原始类型集合库。允许 List<int> 的泛型特化是 Valhalla 项目的目标,截至 Java 25 尚未落地。

不能直接实例化类型参数

编译器不知道 T 的构造器,更不知道擦除后应实例化什么类:

1
2
3
class Factory<T> {
// T create() { return new T(); } // 编译错误
}

优先让调用方提供构造策略。Supplier 不要求无参构造器,也不会把反射异常泄漏到 API 中:

1
2
3
4
5
6
7
8
9
10
11
public static <T> List<T> createMany(
int count, Supplier<? extends T> supplier) {
List<T> result = new ArrayList<>(count);
for (int i = 0; i < count; i++) {
result.add(supplier.get());
}
return result;
}

List<StringBuilder> values = createMany(
3, StringBuilder::new);

若确实需要类元数据,可传入 Class<T> 并调用受控的反射代码;但 Class<T> 只能描述原始类,不能表达完整的 List<String> 之类参数化类型。

不能创建参数化类型数组

下面的代码被禁止:

1
// List<String>[] lists = new List<String>[10];

数组在运行期知道组件类型且协变,泛型在运行期擦除且不变。若允许创建,会使 JVM 无法阻止“向数组放入 List<Integer>,再从中取 List<String>”的堆污染。通常应直接使用嵌套集合:

1
2
List<List<String>> lists = new ArrayList<>();
lists.add(new ArrayList<>());

内部实现偶尔需要 T[],应优先由调用方提供数组构造器,例如 Stream.toArray(String[]::new)(T[]) new Object[size] 是未经检查的转换,只有在数组绝不以 T[] 身份暴露、且安全性已被证明时才可局部使用。

不能检查确切的参数化类型

instanceof 不知道 List 的实际类型实参:

1
2
3
4
5
6
7
8
Object value = List.of("a", "b");

if (value instanceof List<?>) {
List<?> list = (List<?>) value;
Object first = list.get(0);
}

// if (value instanceof List<String>) {} // 编译错误

可以检查 List<?>,因为它的运行期表示就是 List。如果输入来自不可信边界,先检查容器类型,再逐个验证元素类型;一次强制转换为 List<String> 只会得到 unchecked 警告,并不能验证元素。

不能按类型实参重载

不同的类型实参擦除后有相同的方法签名,因此会发生名称冲突:

1
2
3
void save(List<String> values) {}

// void save(List<Integer> values) {} // 编译错误:名称冲突

返回类型不能参与 Java 的重载解析,也不能修复这个冲突。应改变方法名、改变非擦除后相同的参数类型,或让一个泛型方法在内部处理两种元素类型。

泛型不能继承 Throwable

异常类型必须在运行期可确定,故泛型类不能直接或间接继承 Throwable

1
// class Failure<T> extends Exception {} // 编译错误

同理,不能 catch (T error),也不能用类型实参构造精确的异常捕获逻辑。用普通异常层级、错误码或承载错误详情的字段表达差异。

原始类型、unchecked 警告与堆污染

原始类型(raw type)是省略类型实参的遗留写法,例如 List 而不是 List<String>。它存在的唯一主要原因是兼容 Java 5 前编译的类库;新代码不应主动使用:

1
2
3
4
5
6
List raw = new ArrayList();
raw.add("not a number");

List<Integer> numbers = raw; // unchecked 警告
Integer number = numbers.get(0);
// 运行时 ClassCastException

这里,编译器曾经能维护的“列表只含 Integer”不变量被 raw type 绕过,这称为堆污染(heap pollution):一个参数化变量引用了不符合其类型实参的对象。

编译器的 unchecked 警告不是可忽略的样式问题,而是类型安全证明中断的边界。处理顺序应是:修复为完整泛型类型;若是第三方或遗留边界,先验证输入;最后才在最小作用域使用 @SuppressWarnings("unchecked"),并在注释中说明为何转换安全。不要在类、包或构建配置层面全局压制警告。

泛型可变参数与 @SafeVarargs

可变参数本质是数组。T...T 不是可再具体化类型(reifiable type)时可能制造堆污染,编译器会给出警告:

1
2
3
4
@SafeVarargs
public static <T> List<T> immutableList(T... values) {
return List.of(values);
}

@SafeVarargs 不是“关闭警告”的装饰,而是作者对调用者的承诺:方法不会向 values 写入错误类型,也不会把该数组泄漏给可能污染它的代码。它只允许标注不能被子类覆盖的方法,例如 staticfinalprivate 方法和构造器。无法证明安全时,应改用 List<T> 参数。

桥方法:擦除后如何保持多态

泛型父类的方法在擦除后可能与子类覆盖方法的参数类型不同:

1
2
3
4
5
6
7
8
class Node<T> {
void set(T value) {}
}

class StringNode extends Node<String> {
@Override
void set(String value) {}
}

Node<T>.set(T) 擦除为 set(Object),但 StringNode.set(String) 不是字节码层面同签名的方法。为了让下面的多态调用仍然正确,编译器会在 StringNode 中生成桥方法:

1
2
Node<String> node = new StringNode();
node.set("ok");

桥方法的效果近似于:

1
2
3
void set(Object value) {
set((String) value);
}

它由编译器生成并标记为 bridge/synthetic,普通业务代码不用直接调用。反射框架若扫描覆盖方法或注解,可能需要通过 Method.isBridge() 跳过桥方法,避免同一逻辑被识别两次。

类型擦除、声明处 Signature 元数据与桥方法如何共同保持 Java 泛型的类型安全和多态
泛型规则主要在编译期兑现:类型参数会擦除,读取点补上转换;声明的泛型签名和桥方法分别服务反射与多态。

反射如何取得声明处的泛型信息

虽然对象不保存类型实参,字段、方法和父类声明的泛型签名仍可由反射读取:

1
2
3
4
5
6
class Holder {
List<String> names;
}

Field field = Holder.class.getDeclaredField("names");
Type type = field.getGenericType();

getType() 返回擦除后的 Class,这里是 List.classgetGenericType() 返回 Type,这里通常是 ParameterizedType,其中包含 String。相关的反射子接口包括:

  • Class<?>:普通类、接口、数组、基本类型或 void
  • ParameterizedType:如 Map<String, Integer>
  • TypeVariable<?>:如 class Box<T> 中的 T
  • WildcardType:如 ? extends Number
  • GenericArrayType:如 T[]

这解释了 Jackson、Gson 等库为什么需要 TypeReferenceTypeTokenList<String> 的对象本身没有元素类型信息,框架必须从字段、方法参数或匿名子类的泛型父类声明中获取它。局部变量的泛型声明通常不是稳定的运行期契约,不应把反序列化所需类型信息寄托于它。

设计泛型 API 的准则

  1. 让类型信息流向调用方。 返回 TList<T> 等具体关系,而不是无理由地返回 ObjectList<?>
  2. 输入参数接受需要的最宽类型。 只读取时用 ? extends T,只写入时用 ? super T;读写都需要时用 T
  3. 避免暴露 raw type 和 unchecked 转换。 若不可避免,将它们封装在一个经过验证的边界内。
  4. 不要为“未来可能的类型”预先泛型化。 没有真实的多类型调用场景时,具体类型的 API 更易理解。
  5. 不要把泛型当作运行期类型分发。 需要根据实际类型分支时,使用普通多态、显式标识或受控的运行期检查。
  6. 优先集合,不要泛型数组。 这同时避开协变数组与擦除泛型之间的根本冲突。

速查:读写规则和常见误区

需求推荐签名原因
只遍历任意元素列表List<?>不关心元素具体类型
读取 Number 或其子类List<? extends Number>调用者可传 List<Integer>
写入 IntegerList<? super Integer>可传 List<Integer>List<Number>
读写同一种元素List<T>T 同时保留读写类型
返回首个元素<T> T first(List<T>)返回类型与输入元素关联
创建 TSupplier<? extends T>不依赖反射或无参构造器

最重要的三条规则可以压缩为:泛型默认不变;输入参数按 PECS 选择通配符;遇到运行期限制先想到类型擦除。 以这三条检查 API 签名,绝大多数泛型编译错误都能定位到“类型关系没有被表达出来”,而不是编译器的偶然限制。