泛型让编译器检查放进列表、盒子或方法里的是什么,然后在程序运行前把这些信息擦除。通过运行小程序,学会上下界、通配符、PECS,以及怎样读懂 JDK 的方法签名。
有了泛型,你可以写 List<String>,而不是一个什么都能装的列表。这样编译器会检查放进去的东西,取出来时你也不用再强制类型转换。泛型也是一些看似完全合理的代码编译不过的原因:你不能写 new T(),而 List<Integer> 也不是 List<Number>。只要看清编译器在检查完类型之后做了什么,这两条规则就说得通了。
本文讲泛型修复的那个 bug,泛型类、泛型 record 和泛型方法,有界类型,类型擦除,通配符和 PECS 规则,泛型接口,基本类型,以及怎样读懂 Collections.max 这样的签名。下面每个程序都在 Java 25 上跑过,输出直接从运行结果粘贴而来。想自己运行,就把代码存成 Main.java,再执行 java Main.java。
为什么需要泛型:Object 列表出错太晚
Java 5 之前,List 里装的是 Object,所以你什么都能放进去,取出来的每样东西都得强制类型转换。下面就是这种写法,它今天依然合法:
@SuppressWarnings({"rawtypes", "unchecked"})
void main() {
List names = new ArrayList();
names.add("Ada");
names.add(42);
for (Object o : names) {
String name = (String) o;
IO.println(name.toUpperCase());
}
}
输出后停止:
ADA
Exception in thread "main" java.lang.ClassCastException: class java.lang.Integer cannot be cast to class java.lang.String (java.lang.Integer and java.lang.String are in module java.base of loader 'bootstrap')
bug 出在 names.add(42),程序却在两行之后的强制转换处崩溃。在真实的代码库里,add 和强制转换可能在不同的文件里,由不同的人写,前后相隔几个月。异常告诉你错误的值是在哪里被发现的,而不是在哪里被放进去的。
没有类型实参的 List 叫作原始类型(raw type)。本系列的每个程序都用 javac -Xlint:all -Werror 构建,这就是那行 @SuppressWarnings 存在的原因。去掉它,构建就会停下:
void main() {
List names = new ArrayList();
names.add("Ada");
names.add(42);
IO.println(names.size());
}
构建失败,报错:
Main.java:2: warning: [rawtypes] found raw type: List
Main.java:2: warning: [rawtypes] found raw type: ArrayList
Main.java:3: warning: [unchecked] unchecked call to add(E) as a member of the raw type List
Main.java:4: warning: [unchecked] unchecked call to add(E) as a member of the raw type List
error: warnings found and -Werror specified
没有 -Werror 时,这些只是警告,java Main.java 照样会运行代码。现在给列表加上类型实参:
void main() {
List<String> names = new ArrayList<>();
names.add("Ada");
names.add(42);
for (String name : names) {
IO.println(name.toUpperCase());
}
}
构建失败,报错:
Main.java:4: error: incompatible types: int cannot be converted to String
names.add(42);
^
现在错误指向真正出错的那一行,而且什么都不会运行。循环里不需要强制转换,因为编译器已经知道每个元素都是 String。new ArrayList<>() 里那对空的 <> 叫菱形语法:编译器会从左边复制类型实参。
泛型类和泛型 record
泛型类在尖括号里声明一个类型参数,每次使用这个类时再把它填上。T 是类型的占位符,就像方法参数是值的占位符:
class Box<T> {
private T value;
Box(T value) {
this.value = value;
}
T get() {
return value;
}
void set(T value) {
this.value = value;
}
}
record Pair<A, B>(A first, B second) {}
void main() {
Box<String> label = new Box<>("fragile");
label.set("this side up");
IO.println(label.get().length());
var entry = new Pair<>("Ada", 36);
String name = entry.first();
int age = entry.second();
IO.println(name + " is " + age);
IO.println(entry);
}
输出:
12
Ada is 36
Pair[first=Ada, second=36]
label.get() 返回 String,所以不用强制转换就能调用 .length()。Pair<A, B> 有两个参数,record 根据构造器实参把两个都填上:Pair<String, Integer>。按照惯例,类型参数用单个大写字母:T 表示类型,E 表示元素,K 和 V 表示键和值。
泛型方法与类型推断
方法可以有自己的类型参数,声明在返回类型前面。每次调用时,编译器根据实参推算出它们是什么。这叫作类型推断:
static <T> T firstOrDefault(List<T> items, T fallback) {
return items.isEmpty() ? fallback : items.getFirst();
}
void main() {
String city = firstOrDefault(List.of("Lisbon", "Pune"), "nowhere");
Integer score = firstOrDefault(List.of(), 0);
IO.println(city);
IO.println(score);
var mixed = List.<Number>of(1, 2.5);
mixed.forEach(n -> IO.println(n.doubleValue()));
}
输出:
Lisbon
0
1.0
2.5
第一次调用里,T 是 String。第二次调用里,List.of() 是空的,所以编译器从默认值 0 得出 T,把它定为 Integer。
List.<Number>of(1, 2.5) 是显式类型见证(explicit type witness):由你自己指定 T,而不是让编译器推断。你很少需要它。我们在自己的方法上也试了同样的写法,Main.<String>firstOrDefault(...),javac 却对 Main 报了 cannot find symbol。紧凑源文件里的类没有能在代码中写出来的名字,所以你没法给它的静态方法加类型见证。
有界类型:对 T 提更多要求
普通的 T 可以是任何类型,所以你能在它上面调用的只有 Object 的方法。想调用 compareTo,就得告诉编译器 T 是可比较的。不告诉它会怎样:
static <T> T max(List<T> items) {
T best = items.getFirst();
for (T item : items) {
if (item.compareTo(best) > 0) {
best = item;
}
}
return best;
}
void main() {
IO.println(max(List.of(3, 9, 4)));
}
构建失败,报错:
Main.java:4: error: cannot find symbol
if (item.compareTo(best) > 0) {
^
加一个界限(bound)就能解决。<T extends Comparable<T>> 的意思是“任何 T,只要一个 T 能和另一个 T 比较”。这里即使是接口,也用 extends:
static <T extends Comparable<T>> T max(List<T> items) {
T best = items.getFirst();
for (T item : items) {
if (item.compareTo(best) > 0) {
best = item;
}
}
return best;
}
void main() {
IO.println(max(List.of(3, 9, 4)));
IO.println(max(List.of("pear", "apple", "plum")));
}
输出:
9
plum
一个类型参数可以有多个界限,用 & 连接。如果其中有类,类要放在最前面。<T extends Number & Comparable<T>> 让同一个方法既能用 Number 的 doubleValue(),又能用 Comparable 的 compareTo:
static <T extends Number & Comparable<T>> String summary(List<T> items) {
T best = items.getFirst();
double total = 0;
for (T item : items) {
total += item.doubleValue();
if (item.compareTo(best) > 0) {
best = item;
}
}
return "max " + best + ", total " + total;
}
void main() {
IO.println(summary(List.of(3, 9, 4)));
IO.println(summary(List.of(1.5, 0.25)));
}
输出:
max 9, total 16.0
max 1.5, total 1.75
记住这一节。我们的 max 有个缺口,本文结尾会碰到它;Collections.max 的签名之所以更古怪,正是因为它没有这个缺口。
类型擦除:先检查类型,再把它去掉
Java 在编译时检查泛型类型,然后把它们去掉。到了运行时,List<String> 和 List<Integer> 都只是 ArrayList:
record Box<T>(T value) {}
record Ranked<T extends Comparable<T>>(T value) {}
void main() {
List<String> words = new ArrayList<>();
List<Integer> numbers = new ArrayList<>();
IO.println(words.getClass() == numbers.getClass());
IO.println(words.getClass().getName());
IO.println(Box.class.getRecordComponents()[0].getType());
IO.println(Ranked.class.getRecordComponents()[0].getType());
}
输出:
true
java.util.ArrayList
class java.lang.Object
interface java.lang.Comparable
所有列表共用一个类对象。运行时没有任何地方记录一个列表原本是用来装字符串还是数字的。最后两行显示了编译后的 record 里 T 还剩下什么:普通的 T 变成 Object,有界的 T 变成它的第一个界限 Comparable。这种替换叫作类型擦除。
用十岁孩子能懂的话说
泛型盒子就像一个饭盒,盖子上贴着标签:“只装三明治”。装饭盒的时候,大人会看一眼标签。你想塞一只袜子进去,他们当场就会拦住你。
饭盒装好以后,放进书包之前,标签就被撕掉了。到了午饭时间,没人检查标签,因为标签已经没了。其实也不需要检查。之前已经查过,所以里面装的就是三明治。
所以书包里的每个饭盒看起来都一样。你没法问书包里的饭盒“你以前是装三明治的吗?”,因为答案已经跟着标签进了垃圾桶。
准确的说法
javac 会根据类型实参检查泛型类型的每一次使用。然后它编译代码,把每个类型参数替换成它的擦除类型,也就是它的第一个界限;没有界限时就是 Object。如果你的代码读取一个 T 并把它当作 String 使用,javac 会在那里插入一个隐藏的强制转换。ArrayList 的字节码只有一个类,由每一个 ArrayList<Whatever> 共用。
Java 5 选择擦除,是为了让泛型代码和旧的非泛型代码能用同样的 class 文件一起运行。代价是类型实参在运行时不存在,所以任何需要在运行时知道它的操作都写不出来。
这个比喻的局限:标签并不总是完全消失。字段、方法参数和父类的声明类型会在 class 文件里保留泛型签名,反射可以读到。丢掉的是每个对象的类型实参:某个具体的 ArrayList 不知道自己是作为 ArrayList<String> 创建的。而且大人也可能被骗:原始类型或非受检的强制转换会跳过检查,这时袜子真的可能进到饭盒里。
擦除导致的限制
你不能创建 T,因为运行时没有 T 可供调用构造器:
class Factory<T> {
T make() {
return new T();
}
}
void main() {
IO.println(new Factory<String>());
}
构建失败,报错:
Main.java:3: error: unexpected type
return new T();
^
你也不能创建 T[]。数组会在运行时保存它的元素类型,而擦除意味着没有类型可存:
<T> T[] makeArray(int size) {
return new T[size];
}
void main() {
IO.println(makeArray(3).length);
}
构建失败,报错:
Main.java:2: error: generic array creation
return new T[size];
^
绕开这两个限制的常见做法,是传进一个能负责创建的东西,比如 Class<T> 或 Supplier<T>。
你也不能问一个对象它是不是 List<String>,因为对象自己并不知道:
void main() {
Object thing = new ArrayList<String>();
IO.println(thing instanceof List<String>);
}
构建失败,报错:
Main.java:3: error: Object cannot be safely cast to List<String>
IO.println(thing instanceof List<String>);
^
这一点出乎我们的意料。这个错误并不是说 instanceof 里禁止用泛型,而是关于安全。如果变量是 Collection<String>,那么 thing instanceof List<String> 能编译也能运行,因为凡是通过检查的,确实就是字符串列表。这是 Java 16 加入的:javac --release 15 会拒绝它。如果从 Object 出发,就改用 instanceof List<?>。
最后,只有类型实参不同的两个重载方法不能放在同一个类里:
void print(List<String> items) {
IO.println("strings");
}
void print(List<Integer> items) {
IO.println("integers");
}
void main() {
print(List.of("a"));
}
构建失败,报错:
Main.java:5: error: name clash: print(List<Integer>) and print(List<String>) have the same erasure
void print(List<Integer> items) {
^
擦除之后,两个方法都是 print(List),而一个 class 文件里不能有名字和参数都相同的两个方法。给它们起不同的名字。
如果原始类型或非受检的强制转换让一个 List<String> 变量指向了装着 Integer 的列表,这就叫作堆污染(heap pollution)。之后 ClassCastException 会在离肇事代码很远的地方冒出来,就像第一个程序那样。
通配符:List<Integer> 不是 List<Number>
Integer 是 Number,所以看起来 List<Integer> 也应该是 List<Number>。编译器说不行:
void main() {
List<Integer> ints = new ArrayList<>(List.of(1, 2));
List<Number> nums = ints;
nums.add(2.5);
IO.println(ints);
}
构建失败,报错:
Main.java:3: error: incompatible types: List<Integer> cannot be converted to List<Number>
List<Number> nums = ints;
^
原因在第 4 行。如果允许第 3 行,nums.add(2.5) 就会把一个 Double 放进列表,而 ints 仍然承诺这个列表里只有整数。泛型类型是不变的(invariant):除非 A 和 B 是同一个类型,否则 List<A> 和 List<B> 毫无关系。
数组做了相反的选择,代价在运行时付出:
void main() {
Integer[] ints = {1, 2};
Number[] nums = ints;
IO.println(nums[0]);
nums[1] = 2.5;
}
输出后停止:
1
Exception in thread "main" java.lang.ArrayStoreException: java.lang.Double
数组是协变的(covariant):Integer[] 是 Number[],所以赋值能编译通过。同样错误的写入到运行时才失败。这之所以行得通,只是因为数组记得自己的元素类型,而这恰恰是被擦除的泛型做不到的。所以泛型改为在编译时抓住这个错误。
? extends T:只从中读取的列表
不变性会让一个简单的方法变得没用。sum(List<Number>) 接收不了 List<Integer>。通配符能解决这个问题。List<? extends Number> 的意思是“某种类型的列表,这个类型是 Number 或它的子类型”:
double sum(List<? extends Number> items) {
double total = 0;
for (Number n : items) {
total += n.doubleValue();
}
return total;
}
void main() {
IO.println(sum(List.of(1, 2, 3)));
IO.println(sum(List.of(1.5, 2.5)));
}
输出:
6.0
4.0
读取是安全的。不管列表实际装的是什么,每个元素都是某种 Number。写入就不安全了:
void main() {
List<Integer> ints = new ArrayList<>();
List<? extends Number> nums = ints;
nums.add(2.5);
}
构建失败,报错:
Main.java:4: error: incompatible types: double cannot be converted to CAP#1
nums.add(2.5);
^
CAP#1 是 javac 给“这个 ? 背后的未知类型”起的名字。它可能是 Integer,也可能是 Double,编译器无法证明 2.5 放得进去,所以拒绝。除了 null,你什么都不能往 List<? extends Number> 里加。
? super T:只往里写入的列表
List<? super Integer> 的意思是“Integer 或它某个父类型的列表”。它可能是 List<Integer>、List<Number> 或 List<Object>。这些列表都能装 Integer,所以往里加一个是安全的。读取时只能拿到 Object,因为你不知道手上是哪一种列表。
两种通配符在复制方法里碰面,这个方法从一个列表读取,再写进另一个列表:
static <T> void copy(List<? super T> dst, List<? extends T> src) {
for (T item : src) {
dst.add(item);
}
}
void main() {
List<Integer> ints = List.of(1, 2);
List<Double> doubles = List.of(2.5);
List<Number> numbers = new ArrayList<>();
List<Object> anything = new ArrayList<>(List.of("start"));
copy(numbers, ints);
copy(numbers, doubles);
copy(anything, ints);
IO.println(numbers);
IO.println(anything);
}
输出:
[1, 2, 2.5]
[start, 1, 2]
同一个方法把整数复制进 List<Number> 和 List<Object>,又把浮点数复制进同一个 List<Number>。如果写成 copy(List<T> dst, List<T> src),只有第一次调用能编译,而且前提是两个列表的 T 都是 Integer,可 numbers 并不是。JDK 自己的 Collections.copy 就是这个签名。
经验法则是 PECS:producer extends, consumer super(生产者用 extends,消费者用 super)。如果一个参数产出值给你的方法读取,用 extends。如果它消费你的方法写入的值,用 super。两者都有,就用普通的 T。
源列表产出 T 值,所以它的参数用 extends,方法只从中读取。目标列表消费 T 值,所以它的参数用 super,方法只往里写入。
单独的 ?:任何列表都行
无界通配符 List<?> 的意思是“某种类型的列表,我不关心是哪种”。如果方法只需要对所有列表都成立的操作,比如 size() 或打印,就用它:
String describe(List<?> items) {
return items.size() + " items, first is " + items.getFirst();
}
void main() {
IO.println(describe(List.of("a", "b")));
IO.println(describe(List.of(3.5, 7.0, 1.0)));
}
输出:
2 items, first is a
3 items, first is 3.5
List<?> 和原始的 List 不一样。原始类型关掉了检查,所以你什么都能加。List<?> 保留检查,所以除了 null 你什么都不能加,javac 也不会对它发出警告。
泛型接口:Comparable<T> 和仓储
接口也能带类型参数,JDK 里用得最多的就是 Comparable<T>。实现 Comparable<Version> 意味着一个 Version 能和另一个 Version 比较,这样我们的有界 max 就能接受它:
record Version(int major, int minor) implements Comparable<Version> {
@Override
public int compareTo(Version other) {
if (major != other.major) {
return Integer.compare(major, other.major);
}
return Integer.compare(minor, other.minor);
}
}
static <T extends Comparable<T>> T max(List<T> items) {
T best = items.getFirst();
for (T item : items) {
if (item.compareTo(best) > 0) {
best = item;
}
}
return best;
}
void main() {
var versions = List.of(new Version(21, 0), new Version(25, 1), new Version(25, 0));
IO.println(max(versions));
}
输出:
Version[major=25, minor=1]
compareTo 接收的是 Version 而不是 Object,所以里面既不需要强制转换,也不需要 instanceof。
你自己的接口也可以用同样的方式泛型化。业务代码里常见的一个例子是仓储(repository),它按 ID 存储和查找实体。Repository<T, ID> 适用于任何实体类型和任何 ID 类型:
interface Repository<T, ID> {
void save(ID id, T item);
Optional<T> findById(ID id);
List<T> findAll();
}
record User(String name) {}
class InMemoryRepository<T, ID> implements Repository<T, ID> {
private final Map<ID, T> items = new LinkedHashMap<>();
@Override
public void save(ID id, T item) {
items.put(id, item);
}
@Override
public Optional<T> findById(ID id) {
return Optional.ofNullable(items.get(id));
}
@Override
public List<T> findAll() {
return List.copyOf(items.values());
}
}
void main() {
Repository<User, Integer> users = new InMemoryRepository<>();
users.save(1, new User("Ada"));
users.save(2, new User("Linus"));
IO.println(users.findById(2));
IO.println(users.findById(7));
IO.println(users.findAll());
}
输出:
Optional[User[name=Linus]]
Optional.empty
[User[name=Ada], User[name=Linus]]
users.save("one", ...) 编译不过,因为这个仓储的 ID 是 Integer。Spring Data 这类框架的仓储也是基于同样的思路构建的。讲 Optional 的那一篇会介绍 findById 返回的是什么。
基本类型与泛型:没有 List<int>
类型实参必须是引用类型,所以不允许用 int 这样的基本类型:
void main() {
List<int> scores = new ArrayList<>();
IO.println(scores);
}
构建失败,报错:
Main.java:2: error: unexpected type
List<int> scores = new ArrayList<>();
^
原因还是擦除。类型参数会变成 Object 或某个界限,而 int 不是对象。你要写 List<Integer>,Java 在放进去时把每个 int 装箱成 Integer,取出来时再拆箱。这很方便,但有一个陷阱,每个人都会栽一次:
void main() {
List<Integer> scores = new ArrayList<>(List.of(1, 2, 3));
scores.remove(1);
IO.println(scores);
scores.remove(Integer.valueOf(1));
IO.println(scores);
}
输出:
[1, 3]
[3]
List 有两个 remove 方法:remove(int index) 和 remove(Object o)。scores.remove(1) 选中了 int 那个,因为它不需要装箱,所以删掉的是下标 1 处的元素,也就是 2。如果你想删的是值 1,就传一个 Integer,像第二次调用那样。
装箱还要占内存:List<Integer> 为每个数字保存一个指向独立对象的引用。Valhalla 项目正在开发值类(value class),能缩小这个差距,但它们不在 Java 25 里。
读懂 JDK 里的泛型签名
JDK 里的泛型签名看上去很密,但每一部分只回答一个问题,而这些部分你现在都见过了。下面是 Collections.max 的声明:
public static <T extends Object & Comparable<? super T>> T max(Collection<? extends T> coll)
一次读一部分:
<T ...>:max是一个泛型方法,有一个类型参数T。T extends ... Comparable<? super T>:T必须能和T或T的某个父类型比较。这又是 PECS。比较操作消费一个T,所以用super。Collection<? extends T> coll:实参产出T值,所以用extends。只要元素是T或其子类型,任何集合都行,不只是List。max前面的T:方法返回一个T。Object &:第一个界限决定擦除结果。有了它,擦除后的方法返回Object,和 Java 5 之前的max完全一样,所以调用它的旧编译代码仍然能链接。
第 2 步正是我们自己的 max 的缺口。LocalDate 并没有实现 Comparable<LocalDate>,它实现的是 Comparable<ChronoLocalDate>,一个位于它上层的接口。我们的 <T extends Comparable<T>> 接受不了它:
static <T extends Comparable<T>> T max(List<T> items) {
T best = items.getFirst();
for (T item : items) {
if (item.compareTo(best) > 0) {
best = item;
}
}
return best;
}
void main() {
var dates = List.of(LocalDate.of(2026, 3, 1), LocalDate.of(2026, 9, 14));
IO.println(max(dates));
}
构建失败,报错:
Main.java:13: error: method max in class Main cannot be applied to given types;
reason: inference variable T has incompatible equality constraints ChronoLocalDate,LocalDate
Collections.max 能接受同一个列表,反射也证实了第 5 步:
void main() throws NoSuchMethodException {
var dates = List.of(LocalDate.of(2026, 3, 1), LocalDate.of(2026, 9, 14));
IO.println(Collections.max(dates));
var erased = Collections.class.getMethod("max", Collection.class);
IO.println(erased.getReturnType());
}
输出:
2026-09-14
class java.lang.Object
Comparable<? super T> 让 T 可以是 LocalDate,而比较操作定义在 ChronoLocalDate 上。如果你写的库方法要比较东西,照搬这个形式。getMethod("max", Collection.class) 按擦除后的参数类型找到方法,因为运行时只剩下这个类型。
要点
- 泛型把类型错误从运行时提前到编译时。原始的
List会把迟到的ClassCastException带回来,-Xlint:all会对它发出警告。 - 类型参数写在类名后面(
Box<T>),或方法返回类型前面(<T> T first(...))。编译器通常能推断出它们。 - 界限
<T extends Comparable<T>>让你能在T上调用界限类型的方法。多个界限用&连接。 - 类型实参在检查之后就被擦除。所以
new T()、new T[]、从Object出发的instanceof List<String>,以及只有类型实参不同的重载,都编译不过。 List<Integer>不是List<Number>。数组是协变的,出错时改在运行时抛出ArrayStoreException。- PECS:只读的参数用
? extends T,只写的参数用? super T,类型无关紧要时用?。 - 类型实参必须是引用类型,所以
int会被装箱成Integer。小心remove(int)和remove(Object)的区别。
编译器检查完类型实参就把它扔掉,所以泛型的每条规则,说的都是它在扔掉之前能证明什么。