图 1:本文封面,编译期严格,运行期失忆。
先说明属性:这是一篇经典题解析,题目出自本地历史面试资料,与任何公司的当前招聘无关。题面:Java 泛型解决了什么问题?什么是类型擦除?通配符的上界下界怎么用?
先把结论立住:**泛型把类型错误从运行期提到编译期,擦除把历史兼容留在字节码里——理解这两个时间点,泛型的所有「怪癖」都能解释。**Oracle 官方教程对动机的表述很直接:泛型为语言带来编译期更强的类型检查、并支持泛型编程;对实现的表述同样直接:编译器把类型参数替换成上界或 Object、插入必要的强转、生成桥方法,最终字节码里只有普通类。
**没有泛型时的疼。**集合里什么都装,取出全靠强转,类型不匹配要到运行期才炸。有了泛型,List<String> 塞进 Integer 在编译期就被拦下,强转也随之消失——类型安全从人肉纪律变成编译器保证。
**擦除的代价与红利。**泛型信息只活在编译期,编译完成即被擦掉换成裸类型加自动强转。红利:不为参数化类型创建新类,老字节码不用改,泛型没有运行期开销——官方教程明确点出这一点。代价:运行期拿不到 List 里的 String,不能 new 泛型数组,instanceof 泛型不可用,重载按擦除后签名判定。
通配符管「赋值兼容」。List<Number> 装不下 List<Integer>——泛型不协变。上界 ? extends Number 表示「某种 Number 子类型的列表」,只往外拿、读出安全,是生产者;下界 ? super Integer 表示「Integer 的某种父类型的列表」,只往里放、写入安全,是消费者。口诀:Producer Extends, Consumer Super。
图 2:动机、擦除与通配符三段论。
擦除不是纸面概念,字节码里看得见。下面这份 Java 程序逐一验证:两种参数化的 List 运行时是不是同一个 Class、instanceof 能测到哪一层、ArrayList 的方法签名擦成了什么样、桥接方法是否真的存在。用 JDK 17 编译运行(javac GenericsDemo.java && java GenericsDemo):
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.List;
public class GenericsDemo {
// 演示桥接方法:子类重写泛型接口方法,编译器会悄悄生成一个擦除版本的桥
static class StringBox implements Comparable<StringBox> {
final String value;
StringBox(String value) { this.value = value; }
@Override public int compareTo(StringBox other) { return value.compareTo(other.value); }
}
public static void main(String[] args) throws Exception {
List<String> strings = new ArrayList<>();
List<Integer> ints = new ArrayList<>();
// 1. 类型擦除:运行期两种参数化的 List 是同一个 Class
System.out.println("List<String> 与 List<Integer> 的运行时 Class 相同:" + (strings.getClass() == ints.getClass()));
System.out.println("运行时 Class 对象上看到的名字:" + strings.getClass().getSimpleName());
// 2. 擦除的后果:运行期拿不到 List 里面的 String,instanceof 只能测裸类型
System.out.println("instanceof List<?> 可以:" + (strings instanceof List<?>));
// strings instanceof List<String> // 编译错误:generic type 不能用于 instanceof
// 3. 擦除留下的痕迹:字节码里只有裸类型 + 编译器插入的强转
Method add = ArrayList.class.getMethod("add", Object.class); // 参数类型被擦成 Object
System.out.println("ArrayList.add 的擦除后签名:add(" + add.getParameterTypes()[0].getSimpleName() + ")");
String s = strings.getClass().getMethod("get", int.class).getReturnType().getSimpleName();
System.out.println("ArrayList.get 的擦除后返回类型:" + s + "(取 String 靠编译器插入的强转)");
// 4. 桥接方法:compareTo(StringBox) 之外还有一个 compareTo(Object)
long bridges = 0;
for (Method m : StringBox.class.getDeclaredMethods()) if (m.isBridge()) bridges++;
System.out.println("StringBox 里编译器生成的桥接方法数:" + bridges);
// 5. 不能 new 泛型数组,但可以 new 裸数组再强转(堆污染风险的来源)
// List<String>[] bad = new List<String>[10]; // 编译错误
@SuppressWarnings("unchecked")
List<String>[] ok = (List<String>[]) new List<?>[10]; // 合法的迂回写法
ok[0] = strings;
System.out.println("裸数组迂回创建成功,长度:" + ok.length);
}
}
实测输出:
List<String> 与 List<Integer> 的运行时 Class 相同:true
运行时 Class 对象上看到的名字:ArrayList
instanceof List<?> 可以:true
ArrayList.add 的擦除后签名:add(Object)
ArrayList.get 的擦除后返回类型:Object(取 String 靠编译器插入的强转)
StringBox 里编译器生成的桥接方法数:1
裸数组迂回创建成功,长度:10
第一行是擦除最直接的证据:运行期根本没有「List of String」这个东西,只有一个 ArrayList。第三、四行说明取元素时的类型安全全靠编译器插入的强转撑着——这也是为什么绕过编译器(反射或裸类型)就能塞进错误类型,运行期才炸 ClassCastException。桥接方法那一行解释了一个诡异现象:反射看 StringBox 会发现两个 compareTo,参数分别是 StringBox 和 Object,后者是编译器为多态生成的桥。
**常见误区。**一是以为 List<Number> 能接收 List<Integer>,数组协变带来的直觉在泛型上失效;二是在 catch、instanceof、new 里用类型参数,全是编译错误,根因都是擦除后运行期没有类型信息;三是滥用裸类型绕过检查,把编译期的保证白白扔掉;四是静态方法误用类的类型参数——静态上下文不属于任何实例,泛型参数只能声明在方法自己头上。
AI 工程里通用管线组件——解析器、处理器、回调——大量依赖泛型表达类型约束,读懂主流框架的方法签名绕不开通配符与擦除。回到答题节奏:先讲动机(编译期拦错误),再讲擦除的三个后果与桥接方法,最后用 PECS 口诀收尾通配符。你被泛型的哪条限制坑过?欢迎分享。