跳到主要内容

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 是类型参数。创建对象时,StringInteger 是实际传入的类型:

Box<String> title = new Box<>("Coding 101");
Box<Integer> count = new Box<>(101);

String value = title.get();

同一个 Box<T> 可以保存不同类型的数据,编译器仍然能够检查 setget 的类型是否一致。

Java 泛型只能接收引用类型。需要表示 intlong 等基本类型时,要使用 IntegerLong 等包装类型。

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 Ttarget 负责接收数据,所以使用 ? super T

下面的调用可以把整数复制到数字列表:

List<Integer> source = List.of(1, 2, 3);
List<Number> target = new ArrayList<>();

copy(source, target);

设计泛型 API 时,可以按照三个问题选择写法:

  1. 多个位置是否需要保持同一种类型?需要时声明 <T>
  2. 参数是否只负责提供数据?使用 ? extends T
  3. 参数是否只负责接收数据?使用 ? super T

类型只出现一次,也没有放宽参数范围的需要时,直接写具体类型通常更清楚。

4. 泛型的常见问题

4.1 为什么 List<Integer> 不能赋值给 List<Number>

IntegerNumber 的子类,但 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

numbersintegers 指向同一个列表。如果允许赋值,就能通过 numbersDouble 放进原来的 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,无法确定问号代表 IntegerDouble,还是其他 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) 时,编译器仍然知道 numbersList<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,两个方法会得到相同的签名,因此编译器会报告名称冲突。

如果两个操作都需要保留,可以使用不同的方法名,或者让参数在擦除后仍然是不同类型,例如分别使用 ListSet。仅修改返回类型不能形成重载。

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>