跳到主要内容
约 11 分钟5 张卡

Java 泛型

Java 泛型让类、接口和方法使用类型参数,在复用代码的同时保留类型检查。例如,List<String> 能在编译期拒绝写入整数,读取时也无需手写强制转换。掌握泛型,需要理解类型参数怎样关联输入与输出、通配符允许哪些读写,以及擦除后还会发生哪些运行时检查。

1. 面试核心​

先记住这几条,再读正文
  • 类型参数的作用。 泛型用同一个类型参数关联字段、入参和返回值,让编译器检查这些类型关系。集合、容器和通用接口由此可以复用代码,并减少调用方的显式强制转换。卡 · 类型参数
  • 泛型的不变性。 Integer 是 Number 的子类型,List<Integer> 却不能赋给 List<Number>,否则整数列表就可能被写入 Double。
  • 通配符的读写边界。 ? extends T 适合按 T 读取,? super T 适合写入 T 及其子类型的值。可用 PECS 记忆:提供数据用 extends,接收数据用 super。卡 · 通配符
  • 检查与擦除的先后顺序。 编译器先检查泛型,再将类型变量擦除为上界,并插入必要的强制转换。原始类型或反射绕过检查后,错误可能推迟到读取时才暴露。卡 · 类型擦除
  • 桥接方法的作用。 泛型擦除后,接口方法与具体实现的方法签名可能不同。编译器生成桥接方法来转换参数、转发调用,维持重写关系。卡 · 桥接方法与运行时类型检查
  • 泛型数组与堆污染。 泛型变长参数的数组检查无法识别具体类型参数,错误类型可能混入其中。@SafeVarargs 是安全声明,不会修复不安全的实现。卡 · 泛型变长参数与堆污染

2. 类型参数​

类型参数是暂时留给使用方确定的类型,例如 T。泛型类用同一个参数关联字段、构造参数和返回值,就能复用同一结构:

final class Box<T> {
private final T value;

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

T get() {
return value;
}
}

Box<String> box = new Box<>("java");
String value = box.get();

创建 Box<String> 后,构造参数和 get() 返回值都按 String 检查。所以调用方取值时可以直接赋给 String,省去手写强制转换。

泛型方法则把自己的类型参数声明在返回类型之前:

static <T> List<T> wrap(T value) {
return List.of(value);
}

调用 wrap("java") 并用 List<String> 接收时,编译器把 T 推断为 String;传入整数并用 List<Integer> 接收时,T 则为 Integer。输入参数与目标类型共同参与推断,同一个 T 让输入和输出保持关联。如果两边的类型已经固定,直接使用具体类型或接口即可。

2.1 类型上界​

方法需要调用某类对象的特定能力时,可以给类型参数设置上界。上界限制使用方可传入的类型,也告诉编译器哪些方法可用:

static <T extends Number> double twice(T value) {
return value.doubleValue() * 2;
}

T extends Number 表示 T 是 Number 或其子类型,因此方法可以调用 doubleValue()。多个上界表示类型必须同时满足这些要求。如果包含类上界,它必须写在第一个位置,后面再列接口上界。

3. 泛型的不变性​

Integer 是 Number 的子类型,但这种关系不会自动扩展到 List<Integer> 和 List<Number>。这称为泛型的不变性。下面的赋值会编译失败:

List<Integer> integers = new ArrayList<>(List.of(1, 2));
// List<Number> numbers = integers; // 编译错误

假设允许这种赋值,numbers.add(3.14) 就可以向同一个列表写入 Double,破坏原先只存放 Integer 的约定。因此,Java 在编译期拒绝这次赋值。

上面是一段用于解释类型漏洞的假设,注释中的赋值实际无法编译。这里使用可变的 ArrayList,便于单独讨论类型约束。如果直接使用 List.of 返回的列表,还会受到禁止修改的限制;这是集合自身的规则,与泛型的类型检查分别生效。

数组则支持协变,即 Integer[] 可以赋给 Number[]。数组保留实际元素类型,因此写入 Double 时会在运行时抛出 ArrayStoreException。泛型把这类错误提前到编译期发现。

4. 通配符​

方法有时需要接受不同元素类型的列表,又只使用其中一部分能力。通配符 ? 表示某个未知类型,通过限制它的上界或下界,可以确定哪些读写操作安全。

4.1 上界通配符​

只需要读取数字时,可以接受元素为 Number 或其子类型的列表。上界通配符用 ? extends Number 表达这个范围:

static double sum(List<? extends Number> values) {
double total = 0;
for (Number value : values) {
total += value.doubleValue();
}
return total;
}

实参可以是 List<Integer> 或 List<Double>。无论具体是哪一种,读取出的非空元素都能当作 Number 使用,因此可以调用 doubleValue()。

写入时需要知道具体元素类型。实际传来的列表可能是 List<Double>,这时写入 Integer 就会破坏约定。因此,通过这个通配符引用直接添加一个值时,只有 null 能通过类型检查;列表是否接受空值,还取决于它自身的实现。

4.2 下界通配符​

方法需要把整数写入列表时,可以用 ? super Integer 接受元素类型为 Integer 或其父类型的列表:

static void addDefaults(List<? super Integer> target) {
target.add(0);
target.add(1);
}

实参可以是 List<Integer>、List<Number> 或 List<Object>,三者都能接收整数 0 和 1。读取时,实际列表还可能包含其他对象,因此只能把结果当作 Object 使用。

这两种限制可以用 PECS 记忆,即 Producer Extends, Consumer Super:

  • 参数主要提供 T,也就是供方法读取时,使用 extends。
  • 参数主要接收 T,也就是供方法写入时,使用 super。
  • 如果要按同一个具体类型读写,则直接使用 List<T>。

4.3 未知类型与 Object 元素类型​

List<?> 表示元素类型未知,可以接收 List<String> 等任意元素类型的列表。读取结果只能按 Object 使用。直接添加值时,类型检查只接受 null,而具体列表仍可以拒绝空值。

List<Object> 则明确规定元素类型为 Object,因此可以写入各种对象。但根据泛型不变性,List<String> 无法赋给 List<Object>。两者的差别在于,一个隐藏了实际元素类型,一个明确把元素类型指定为 Object。

5. 类型擦除​

类型擦除让不同类型参数的泛型代码共用同一份运行时实现。编译器先完成类型检查,再把类型变量替换为最左上界;没有显式上界时,替换为 Object。例如,T extends Number 擦除为 Number,而无上界的 T 擦除为 Object。需要恢复具体类型的位置,还会插入强制转换。

因为检查先于擦除,下面的错误在编译期就会被发现:

List<Integer> numbers = new ArrayList<>();
// numbers.add("wrong"); // 编译错误

擦除后,List<String> 和 List<Integer> 共用 List 这个运行时接口,集合也不会根据变量声明逐次检查元素的泛型参数。这里还需要区分两种容易绕过检查的写法:

  • 原始类型省略了类型参数,例如直接声明 List。
  • 未检查转换是编译器无法确认泛型参数是否匹配的转换,会产生 unchecked 警告。

原始类型、未检查转换和反射都可以绕过泛型检查,让错误类型的值进入集合。这时,变量声明的泛型类型与实际保存的数据不一致,称为堆污染。之后按 Integer 读取错误值时,编译器插入的转换会抛出 ClassCastException。

擦除还带来以下四项限制:

  • new T() 无法编译,因为运行时缺少具体类型和构造器信息。创建实例需要显式提供工厂或构造器。
  • new T[] 会编译失败,数组需保留运行时元素类型。
  • Object 无法用 instanceof List<String> 检查元素类型。
  • 参数分别为 List<String> 和 List<Integer> 的两个方法擦除后签名相同,不能形成重载。

泛型重写还需要兼容擦除后的方法签名。编译器会生成转发调用的桥接方法,具体过程见下一节。

6. 桥接方法与运行时类型检查​

泛型方法在源码中满足重写关系,擦除后的参数类型却会出现差异。编译器通过生成桥接方法补上这个差异,让按接口发起的调用仍能到达具体实现。

interface Sink<T> { void accept(T value); }
final class TextSink implements Sink<String> {
public void accept(String value) {}
}

接口的 accept(T) 擦除后接收 Object,实现类的 accept(String) 仍接收 String。因此,编译器在 TextSink 中生成 accept(Object) 作为桥接方法。它先将参数转为 String,再转发到 accept(String)。

如果通过原始类型传入其他对象,调用仍会到达桥接方法,但参数转换会抛出 ClassCastException。桥接方法由此既保留了重写关系,也在转发给具体实现前完成所需的类型转换。

运行时类型检查还要结合表达式已有的静态类型。Java 16 起,部分参数化类型可用于 instanceof,Java 17 和 21 也支持;前提是检查所需的转换不涉及未检查转换:

List<String> values = new ArrayList<>();
System.out.println(values instanceof ArrayList<String>); // true,Java 16+
Object unknown = values;
// boolean valid = unknown instanceof List<String>; // 编译错误
boolean list = unknown instanceof List<?>; // 合法

这三个检查的区别在于已知类型信息:

  • values 已声明为 List<String>,第一个检查只需判断实现类是否为 ArrayList。
  • unknown 只声明为 Object,无法证明其中所有元素都是 String,所以第二个检查编译失败。
  • List<?> 把元素类型保留为未知,第三个检查只确认对象是否实现 List,因此合法。

7. 泛型变长参数与堆污染​

泛型变长参数会同时遇到数组协变和类型擦除。List<String>... 通过数组传递,擦除后数组的运行时类型是 List[]。它能检查写入对象是否为列表,却无法检查列表中装的是字符串还是整数。下面的反例会因此在读取时失败:

static String broken(List<String>... values) { // 未检查警告
Object[] aliases = values;
aliases[0] = List.of(42); // 运行时数组类型允许 List
return values[0].get(0); // ClassCastException
}

aliases 和 values 指向同一个数组。写入 List<Integer> 时,数组检查只看到 List,因此允许写入。随后 values[0].get(0) 按声明类型读取 String,实际拿到的却是 Integer,于是转换失败。

@SafeVarargs 是作者对类型安全的声明,用来抑制对应的未检查警告,不会增加运行时检查。使用前要确认方法既不向数组写入错误类型,也不把数组暴露给不安全代码。

Java 17 和 21 中,这个注解可用于以下方法或构造器:

  • 变长参数构造器。
  • 静态变长参数方法。
  • final 或私有的变长参数实例方法。

其中,私有实例方法从 Java 9 起可以使用 @SafeVarargs。

8. 常见问题​

8.1 为什么不能向 List<? extends Number> 写入 Integer?​

因为实际传入的列表可以是 List<Double>。编译器只知道元素是某个未知的 Number 子类型,所以无法确认 Integer 满足该列表的具体类型要求。需要写入整数时,可以改用 List<? super Integer> 表达接收能力。

8.2 泛型擦除后,为什么还能通过反射读取部分泛型信息?​

8.3 什么时候直接使用具体类型更合适?​

9 · 面试题卡片​

9.1 泛型是什么,一般用在什么场景​

出现公司:小米、百度、高德

考察重点

  • 解释类型参数怎样支持代码复用,并把类型错误提前到编译期。
  • 判断类型关系应放在类、接口还是方法上表达。

相关内容:类型参数。

参考回答

泛型用类型参数复用类、接口和方法,并在编译期检查类型关系。

例如,Box<String> 的构造参数和返回值都按 String 检查,调用方取值时无需手写强制转换。

集合、通用容器、转换接口和工具方法都适合用泛型。跨多个成员共享类型关系时,用泛型类或接口;关系只属于一次方法调用时,用泛型方法。类型已经固定时,直接写具体类型更清楚。

9.2 类型擦除以后,为什么 List<Integer> 仍然不能写入 String​

出现公司:高德

考察重点

  • 说明泛型检查与类型擦除的先后顺序。
  • 解释原始类型和反射怎样绕过检查,并导致后续读取失败。

相关内容:类型擦除。

参考回答

因为泛型检查先于类型擦除。

编译 add 时,编译器仍知道变量是 List<Integer>,所以传入 String 会直接报错。

原始类型、未检查转换或反射可以绕过这层检查。错误对象混入后,再按 Integer 读取时,编译器插入的强制转换就会抛出 ClassCastException。

9.3 ? extends T 与 ? super T 有什么区别​

出现公司:东方财富

考察重点

  • 根据未知类型的上界与下界,推导哪些读写操作安全。
  • 说明 PECS 为什么把提供数据与接收数据分别对应到 extends 和 super。

相关内容:通配符。

参考回答
  • ? extends T 适合按 T 读取。 元素来自某个未知的 T 子类型,读取时可以向上转型;直接添加值时,除 null 外无法确认哪个值符合该具体类型。
  • ? super T 适合写入 T 及其子类型的值。 列表的元素类型是 T 或其父类型,都能接收这些值;读取时则只能保证得到 Object。

这就是 PECS:提供数据用 extends,接收数据用 super。需要按同一具体类型读写时,使用 List<T>。能通过类型检查,也仍需满足列表自身是否可修改、是否接受空值等约束。

9.4 泛型桥接方法怎样保持重写关系?​

出现公司:暂无已核验的公开来源(本站自测)

考察重点:说明擦除怎样改变方法签名,以及桥接方法怎样完成转换和转发。

相关内容:桥接方法与运行时类型检查。

参考回答

桥接方法通过“接收擦除后的调用,再转发给具体实现”保持重写关系。

例如,Sink<T>.accept(T) 擦除后接收 Object,TextSink 的实现却接收 String。编译器便在 TextSink 中生成 accept(Object),先转为 String,再调用 accept(String)。

如果通过原始类型传入其他类型的对象,桥接方法中的转换会抛出 ClassCastException。

9.5 泛型变长参数为什么会堆污染,SafeVarargs 能修复吗?​

出现公司:暂无已核验的公开来源(本站自测)

考察重点:解释数组的运行时检查为何看不到泛型参数,并说明 @SafeVarargs 的作用范围。

相关内容:泛型变长参数与堆污染。

参考回答
  • 堆污染来自数组检查看不到泛型参数。 List<String>... 的运行时数组类型是 List[]。通过 Object[] 别名写入 List<Integer> 时,数组仍认为这是合法的 List;随后按 String 读取元素,才会抛出 ClassCastException。
  • @SafeVarargs 无法修复这种错误。 它是作者对实现安全的声明,用来抑制相关警告,不会增加运行时检查。方法仍需避免写入错误类型,也要避免把数组暴露给不安全代码。

10. 参考资料​