Java 泛型
Java 泛型允许将类型作为类、接口和方法的参数。本文会依次介绍泛型解决的问题、基本概念、使用场景、常见问题,以及公开面经中出现过的泛型面试题。
1. 泛型解决的问题
泛型让编译器检查代码中的类型关系,将一部分运行时错误提前到编译阶段。同时,同一个类或方法可以安全地处理多种类型。
没有泛型时,集合只能把元素当成 Object 保存。取出元素时需要手动转换类型:
List values = new ArrayList();
values.add("Coding 101");
Integer count = (Integer) values.get(0);
这段代码可以通过编译,却会在运行时抛出 ClassCastException。编译器只知道集合保存了 Object,无法判断这次转换是否正确。
使用泛型后,元素类型成为集合声明的一部分:
List<String> values = new ArrayList<>();
values.add("Coding 101");
String title = values.get(0);
List<String> 只能按照 String 使用。写入其他类型会直接产生编译错误,读取时也不再需要强制类型转换。
泛型还可以复用结构相同的代码。例如,保存字符串和保存整数不需要分别实现两个容器,只需要为容器声明一个类型参数。
泛型检查的是类型关系。数据是否为
null、字符串内容是否合法,仍然需要其他校验。
2. 泛型的基本概念
2.1 泛型类
泛型类把类型参数声明在类名后面:
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 是类型参数。创建对象时,String 和 Integer 是实际传入的类型:
Box<String> title = new Box<>("Coding 101");
Box<Integer> count = new Box<>(101);
String value = title.get();
同一个 Box<T> 可以保存不同类型的数据,编译器仍然能够检查 set 和 get 的类型是否一致。
Java 泛型只能接收引用类型。需要表示 int、long 等基本类型时,要使用 Integer、Long 等包装类型。
2.2 泛型接口
接口也可以声明类型参数:
public interface Converter<S, T> {
T convert(S source);
}
S 表示输入类型,T 表示输出类型。实现接口时可以确定这两个类型:
Converter<String, Integer> length = String::length;
Integer count = length.convert("Coding 101");
泛型接口适合表达转换器、比较器、仓储接口等结构稳定,但输入或输出类型不同的抽象。
2.3 泛型方法
方法可以单独声明类型参数,不要求所在的类是泛型类:
public static <T> T first(List<T> values) {
if (values.isEmpty()) {
throw new IllegalArgumentException("values must not be empty");
}
return values.get(0);
}
返回类型前面的 <T> 表示这是一个泛型方法。调用时,编译器通常可以从参数推断实际类型:
String firstName = first(List.of("Lin", "Asuka"));
Integer firstNumber = first(List.of(1, 2, 3));
类型参数的作用,是建立多个位置之间的类型关系。在这个方法中,参数里的元素类型与返回类型必须相同。
2.4 类型参数的边界
有些代码不能接受任意类型。例如,求和方法需要调用 Number.doubleValue(),因此可以为类型参数设置上界:
public static <T extends Number> double sumNumbers(List<T> values) {
double result = 0;
for (T value : values) {
result += value.doubleValue();
}
return result;
}
T extends Number 表示 T 必须是 Number 或它的子类。这里可以传入 List<Integer>、List<Long> 或 List<Double>,但不能传入 List<String>。
在类型参数的边界中,extends 同时用于类和接口。例如,T extends Comparable<T> 表示 T 必须实现 Comparable<T>。
2.5 通配符
通配符不属于“类型参数的边界”。<T extends Number> 约束声明出来的类型参数,? extends Number 则在使用泛型时描述一个未知类型的范围。两者都可以使用边界,但出现的位置和解决的问题不同。
<T> 声明了一个有名字的类型,用来连接参数、返回值等多个位置。? 表示某个未知类型,用来描述一个位置可以接受的类型范围。
2.5.1 无界通配符:?
List<?> 表示元素类型未知的列表。方法只需要使用与具体元素类型无关的能力时,可以使用无界通配符:
public static int sizeOf(List<?> values) {
return values.size();
}
这个方法可以接收 List<String>、List<Integer> 或其他列表,因为它并不读取元素的具体能力。
2.5.2 上界通配符:? extends T
下面的方法可以读取任意 Number 子类的列表:
public static double sum(List<? extends Number> values) {
double result = 0;
for (Number value : values) {
result += value.doubleValue();
}
return result;
}
? extends Number 表示元素属于某个未知的 Number 子类。读取时可以安全地按照 Number 使用,但不能向列表写入某个具体的数字类型。
2.5.3 下界通配符:? super T
如果方法需要写入 Integer,可以使用下界通配符:
public static void addDefaults(List<? super Integer> values) {
values.add(0);
values.add(1);
}
? super Integer 可以接收 List<Integer>、List<Number> 或 List<Object>。方法可以安全地写入 Integer,但读取时只能确定结果是 Object。
这两条规则通常写成 PECS:
- Producer Extends:参数负责提供数据时,使用
? extends T。 - Consumer Super:参数负责接收数据时,使用
? super T。
参数需要同时读取和写入,并且两处必须保持同一种具体类型时,应该继续使用 <T>,而不是通配符。
2.6 类型擦除
Java 编译器会先使用泛型信息完成类型检查,再执行类型擦除。没有显式边界的类型参数通常擦除为 Object;存在边界时,擦除为它的第一个边界。
因此,Box<String> 和 Box<Integer> 在运行时使用的是同一个 Box 类。泛型类型不会为每一组类型参数生成一份新的类。
类型擦除不等于 Class 文件中完全没有泛型信息。类、字段和方法声明中的泛型签名可以保存在 Signature 属性中,反射也可以读取这些声明信息。但是,一个普通的 new ArrayList<String>() 对象不会因此记住自己的元素类型是 String。
2.7 桥接方法
类型擦除可能改变父类方法的参数类型。例如,下面的子类在源码中重写了 set(Integer):
public class Box<T> {
public void set(T value) {}
}
public class IntegerBox extends Box<Integer> {
@Override
public void set(Integer value) {}
}
类型擦除后,Box.set(T) 的参数会变成 Object,而 IntegerBox.set(Integer) 仍然接收 Integer。为了让重写和动态绑定继续成立,编译器会在 IntegerBox 中生成一个桥接方法。它的逻辑相当于:
// 由编译器生成,不能在源码中重复声明
public void set(Object value) {
set((Integer) value);
}
桥接方法把擦除后的父类签名转发到子类方法。它通常不需要手动处理,但可能出现在反射结果和异常堆栈中;通过反射检查方法时,可以使用 Method.isBridge() 判断它是否由编译器生成。
3. 泛型的使用场景
泛型适合描述稳定的代码结构与可变的类型关系。
| 使用场景 | 常见写法 | 泛型解决的问题 |
|---|---|---|
| 集合保存确定类型的数据 | List<User>、Map<Long, Order> | 在编译阶段检查读写类型 |
| 复用数据容器 | Result<T>、Page<T> | 同一套状态和分页结构承载不同数据 |
| 抽象接口 | Repository<T, ID>、Converter<S, T> | 让不同实现确定自己的输入与输出类型 |
| 编写通用算法 | <T> T first(List<T>) | 保持参数与返回值的类型关系 |
| 放宽 API 的参数范围 | List<? extends T>、List<? super T> | 接收一组相关类型,同时保留类型安全 |
一个复制方法会同时用到类型参数、上界通配符和下界通配符:
public static <T> void copy(
List<? extends T> source,
List<? super T> target) {
target.addAll(source);
}
T 连接源列表与目标列表的元素类型。source 负责提供数据,所以使用 ? extends T;target 负责接收数据,所以使用 ? super T。
下面的调用可以把整数复制到数字列表:
List<Integer> source = List.of(1, 2, 3);
List<Number> target = new ArrayList<>();
copy(source, target);
设计泛型 API 时,可以按照三个问题选择写法:
- 多个位置是否需要保持同一种类型?需要时声明
<T>。 - 参数是否只负责提供数据?使用
? extends T。 - 参数是否只负责接收数据?使用
? super T。
类型只出现一次,也没有放宽参数范围的需要时,直接写具体类型通常更清楚。
4. 泛型的常见问题
4.1 为什么 List<Integer> 不能赋值给 List<Number>
Integer 是 Number 的子类,但 List<Integer> 不是 List<Number> 的子类:
List<Integer> integers = new ArrayList<>();
// List<Number> numbers = integers; // 编译错误
编译器拒绝这次赋值,是为了阻止下面的情况发生:
// 假设 List<Integer> 可以赋值给 List<Number>
List<Number> numbers = integers;
numbers.add(3.14); // List<Number> 允许写入 Double
Integer value = integers.get(0); // 原来的列表却要求读出 Integer
numbers 和 integers 指向同一个列表。如果允许赋值,就能通过 numbers 把 Double 放进原来的 List<Integer>,下一次按照 Integer 读取时,类型安全就会被破坏。上面的代码用于说明原因,实际会在赋值处直接编译失败。
Java 泛型默认保持不变性。需要接收一组相关类型时,使用通配符描述允许的范围。
4.2 为什么不能向 List<? extends Number> 写入 Integer
List<? extends Number> 表示具体元素类型未知,只能确定它是 Number 的子类。实际传入的对象可能是 List<Double>,向其中写入 Integer 同样会破坏类型安全。
下面的例子中,numbers 实际引用的是一个 List<Double>:
List<Double> doubles = new ArrayList<>();
doubles.add(3.14);
List<? extends Number> numbers = doubles;
Number first = numbers.get(0); // 可以读取,结果至少是 Number
// numbers.add(1); // 编译错误:不能确定列表能否接收 Integer
编译器只看到 ? extends Number,无法确定问号代表 Integer、Double,还是其他 Number 子类。如果允许执行 numbers.add(1),这里就会把 Integer 写入 List<Double>。
因此,这种列表可以安全地读取为 Number,但不能写入任何非 null 的具体数字。
4.3 List<?> 与 List<Object> 有什么区别
List<?> 可以引用 List<String>、List<Integer> 等任意元素类型的列表。由于具体类型未知,读取结果只能按照 Object 使用,也不能安全地写入非 null 元素。
List<Object> 只能引用元素类型明确为 Object 的列表。它不能接收 List<String>,但可以写入任意 Object。
List<String> strings = new ArrayList<>();
strings.add("Coding 101");
List<?> unknown = strings; // 可以引用 List<String>
Object value = unknown.get(0); // 只能按照 Object 读取
// unknown.add("Java"); // 编译错误:具体元素类型未知
List<Object> objects = new ArrayList<>();
objects.add("Java"); // 可以写入 String
objects.add(101); // 也可以写入 Integer
// List<Object> other = strings; // 编译错误
判断两者的关键是:? 表示“存在一个具体类型,但当前不知道是什么”;Object 表示“元素类型明确就是 Object”。
4.4 为什么不能直接执行 new T()、new T[] 或 instanceof List<String>
类型擦除后,运行时通常不知道 T 的具体类型,也无法确定应该调用哪个构造方法,因此不能直接执行 new T()。下面三种写法都会编译失败:
public static <T> void inspect(Object value) {
// T item = new T(); // 编译错误
// T[] items = new T[10]; // 编译错误
// boolean matched = value instanceof List<String>; // 编译错误
boolean isList = value instanceof List<?>; // 可以检查原始类型 List
}
需要创建 T 时,可以让调用方提供创建对象的方式:
public static <T> T create(Supplier<T> factory) {
return factory.get();
}
StringBuilder builder = create(StringBuilder::new);
数组会在运行时检查元素类型,而 T 的具体类型已经被擦除,所以不能直接创建 new T[]。同样,List<String> 不是运行时可完整检查的类型,instanceof 只能写成 value instanceof List<?>。
5. 泛型的面试题
这一节用于检查前文内容是否已经掌握。先根据考察重点用自己的话回答,再展开参考回答核对理解。参考回答只提供一种解释思路,不要求使用相同的表述。
5.1 泛型是什么,一般用在什么场景
出现公司:小米、百度、高德
考察重点
- 泛型解决了哪些类型安全问题,又复用了哪些代码结构。
- 泛型类、泛型接口和泛型方法分别在什么位置声明类型参数。
- 哪些场景需要用类型参数建立关系,哪些场景直接使用具体类型更清楚。
相关内容:第 1 节“泛型解决的问题”、第 2 节“泛型的基本概念”、第 3 节“泛型的使用场景”。
参考回答
泛型允许在类、接口和方法中声明类型参数。编译器会根据类型参数检查不同位置之间的类型关系,因此能够在编译阶段发现一部分类型错误,也能减少读取数据时的强制类型转换。
泛型还让一份代码可以处理多种类型。集合、通用数据容器、转换接口和工具方法都经常使用泛型。设计 API 时,如果参数与返回值之间需要保持同一种类型,可以声明类型参数;如果类型关系固定,直接使用具体类型会更清楚。
5.2 泛型擦除以后,为什么 List<Integer> 仍然不能写入 String
出现公司:高德
考察重点
- 泛型检查与类型擦除分别发生在哪个阶段。
- 编译器和 JVM 在这段代码中各自负责什么。
- 原始类型、未检查的强制转换和反射为什么可能造成堆污染。
相关内容:第 2.6 节“类型擦除”、第 4.4 节关于运行时类型检查的说明。
参考回答
类型擦除发生在编译器完成泛型检查之后。编译 numbers.add(value) 时,编译器仍然知道 numbers 是 List<Integer>,所以只允许传入 Integer,传入 String 会直接编译失败。
擦除后的 JVM 不再按照泛型参数检查元素类型。如果使用原始类型、未检查的强制转换或反射绕过编译器,就可能把 String 写入这个列表,形成堆污染。以后按照 Integer 读取该元素时,编译器插入的类型转换可能抛出 ClassCastException。
5.3 参数分别是 List<String> 和 List<Integer> 的方法能否重载
出现公司:广州嘉为科技
考察重点
- 判断方法能否重载时,应该比较什么签名。
List<String>与List<Integer>擦除后会变成什么类型。- 如果两个操作都需要保留,API 可以怎样重新设计。
相关内容:第 2.6 节“类型擦除”。
参考回答
不能。Java 判断方法重载时会比较方法名和参数类型。List<String> 与 List<Integer> 完成类型擦除后都会变成 List,两个方法会得到相同的签名,因此编译器会报告名称冲突。
如果两个操作都需要保留,可以使用不同的方法名,或者让参数在擦除后仍然是不同类型,例如分别使用 List 和 Set。仅修改返回类型不能形成重载。
5.4 ? extends T 与 ? super T 有什么区别
出现公司:东方财富
考察重点
- 两种通配符分别允许哪些实际类型。
- 为什么上界通配符适合读取,下界通配符适合写入。
- 能否从“具体类型未知”推导出读写限制,而不是只背诵 PECS。
相关内容:第 2.5 节“通配符”、第 4.2 节关于上界通配符写入限制的说明。
参考回答
? extends T 表示某个未知的 T 子类型。读取元素时,至少可以确定它是 T;写入时却无法确定列表实际需要哪一种子类型,因此不能写入具体的非 null 值。
? super T 表示某个未知的 T 父类型。无论实际类型是 T 还是它的父类型,写入 T 都是安全的;读取时无法确定具体父类型,只能按照 Object 使用。PECS 是这组读写规则的记忆方式,选择通配符时仍然需要先判断参数负责提供数据还是接收数据。
5.5 编写一个返回 List<T> 的泛型方法
出现公司:三一重工
考察重点
- 能否正确声明泛型方法,并确定
<T>的位置。 - 能否使用同一个
T建立参数与返回值之间的类型关系。 - 实现完成后,能否使用不同类型的参数检查返回列表的元素类型。
相关内容:第 2.3 节“泛型方法”。
参考回答
public static <T> List<T> wrap(T value) {
List<T> result = new ArrayList<>();
result.add(value);
return result;
}
<T> 声明在返回类型之前,同一个 T 同时用于参数和返回列表,从而保持两者的类型关系。传入 String 时返回 List<String>,传入 Integer 时返回 List<Integer>。
Java 泛型的规则以官方文档为准: