为什么需要泛型

泛型把“类型是否正确”的检查提前到编译期,减少强制类型转换,也让容器和算法可以复用。List<String> 明确表达列表只能保存字符串,读取元素时不再需要手动转换。

List<String> names = new ArrayList<>();
names.add("Java");
String name = names.get(0);

泛型类与泛型方法

泛型类的类型参数作用于整个实例;泛型方法在返回值前单独声明类型参数,它与类是否为泛型无关。

class Box<T> {
    private T value;
    void set(T value) { this.value = value; }
    T get() { return value; }
}

static <T> T first(List<T> list) {
    return list.get(0);
}

类型擦除

Java 泛型主要在编译期工作。编译后,未指定上界的 T 通常被擦除为 Object,指定了上界则擦除为上界类型;编译器会插入必要的类型转换和桥接方法。

这带来几个限制:

  • 不能直接 new T(),因为运行时不知道 T 的具体类型。
  • 不能创建 new T[10]
  • 不能使用 obj instanceof List<String>
  • List<String>List<Integer> 运行时通常是同一个 Class

为什么泛型是不变的

即使 IntegerNumber 的子类,List<Integer> 也不是 List<Number> 的子类。否则便可以通过 List<Number> 向其中放入 Double,破坏原列表的类型安全。

extends 与 super

? extends T 表示某个未知的 T 子类型,适合读取;? super T 表示某个未知的 T 父类型,适合写入。记忆规则是 PECS:Producer Extends,Consumer Super

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

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

对于 List<? extends Number>,可以安全读取为 Number,但不能写入具体数字;对于 List<? super Integer>,可以写入 Integer,读取时只能确定为 Object

面试易错点

  1. 泛型擦除不等于运行时完全没有类型信息,字段和方法签名的泛型信息仍可通过反射读取。
  2. 泛型数组危险是因为数组协变且运行时检查元素类型,而泛型在编译期擦除。
  3. 静态成员属于类,不能直接使用类级别的类型参数。
  4. 原始类型 List 会绕过部分编译期检查,应避免使用。

小结

泛型的核心价值是编译期类型安全。处理通配符时先判断参数是“数据生产者”还是“数据消费者”,再选择 extendssuper

核心考点清单

  • 泛型提供编译期类型安全,运行时主要通过类型擦除实现。
  • List<Integer> 不是 List<Number> 的子类型,泛型默认不变。
  • PECS:生产者使用 extends,消费者使用 super
  • 桥接方法用于在擦除后维持多态,反射仍能读取声明处的部分泛型信息。

高频追问与参考回答

追问:为什么不能 new T() 或 new T[]?

擦除后运行时不知道 T 的确切类型,无法选择构造器或创建带正确运行时元素类型的数组。可传入 Class<T>、工厂函数,或使用集合替代泛型数组。

追问:?T 有什么区别?

T 是可在同一声明中多次关联的命名类型参数;? 表示某个未知类型,适合表达使用边界,但不能把两处独立通配符假定为同一类型。

机制全景图

下面把「Java 泛型、类型擦除与通配符详解」从输入到结果压缩成一条可复述的主链路。面试时先用图建立全局坐标,再进入局部实现,能避免只背零散结论。

flowchart LR
    A["声明类型参数"]
    A --> B["编译期类型检查"]
    B --> C["执行类型擦除"]
    C --> D["生成桥接或强转"]
    D --> E["运行期实际调用"]

完整链路:从输入到结果

沿着「声明类型参数 → 编译期类型检查 → 执行类型擦除 → 生成桥接或强转 → 运行期实际调用」观察输入、状态与输出,下面每个阶段都对应一个可以在源码、日志或系统表中验证的位置。

1. 声明类型参数

类型参数把容器和算法的输入输出关系写入签名,使错误在编译期暴露,并减少调用处显式强制转换。

2. 编译期类型检查

编译器检查赋值、调用和通配符边界;泛型默认不协变,所以 List 不是 List

3. 执行类型擦除

多数类型参数在字节码中擦除为上界,JVM 通常看不到 List 与 List 的元素类型差异。

4. 生成桥接或强转

为保持重写后的多态语义,编译器可能生成 bridge 方法,并在读取泛型值的位置插入 checkcast。

5. 运行期实际调用

运行期分派仍依据擦除后的真实方法和对象类型,反射只能从签名元数据读取保留的泛型声明。

源码与实现定位

入口 阅读重点
javap -v Signature 观察泛型签名与擦除后描述符
java.lang.reflect.Type ParameterizedType、TypeVariable 的运行期元数据

源码或系统表应按上表顺序追踪:先确认入口实际走到哪条路径,再用运行时数据验证,而不是仅凭类名或配置推测。

参数配置与可复现实验

static <T> void copy(List<? extends T> src, List<? super T> dst) {
  dst.addAll(src);
}

编译泛型实现并用 javap -c -v 查看 checkcast 与 bridge 方法;再故意通过原始类型制造 heap pollution,记录异常真正发生的位置。

验证步骤与预期结果

1. 固定输入和基线

先在没有故障注入的环境执行上述配置,固定数据规模、并发度、运行时版本和预热时间。以「编译告警」为主基线,记录值应满足「-Xlint:unchecked 应为 0」;同时保存 编译告警数量、ClassCastException 分布,使后续变化能够回到同一时间轴比较。

2. 从实现入口确认路径

在「javap -v Signature」确认请求确实进入「观察泛型签名与擦除后描述符」对应的实现,再沿「java.lang.reflect.Type」观察「ParameterizedType、TypeVariable 的运行期元数据」。如果入口路径都未命中,就不应继续调整下游参数,而应先检查调用条件、版本或路由是否与假设一致。

3. 注入本文特有的失败模式

优先复现「使用原始类型导致堆污染」,并把单一变量逐级放大,直到「编译告警」越过「出现 unchecked」。随后再分别验证「滥用无界通配符丢失输入输出关系」和「认为擦除后所有泛型检查都不存在」,三类故障分开执行,避免多个变量同时变化而无法归因。

4. 执行止损和根因修复

第一轮只应用「启用 -Xlint:all 并把告警当构建失败」,确认它能控制影响范围;第二轮应用「用 TypeToken/Class 显式携带运行期类型」,验证核心链路恢复;最后落实「隔离遗留原始类型并在边界校验元素」,消除同类问题再次出现的条件。每一步都保留变更前后数据,不用“感觉变快了”替代测量。

5. 通过退出条件

实验只有同时满足三项才算通过:「编译告警」回到「-Xlint:unchecked 应为 0」、「调用处强转」回到「公共 API 目标为 0」、「bridge 方法」回到「与重写层次匹配」,并且业务结果差异为零。若性能恢复但结果不一致,仍应视为失败;若指标恢复后很快再次越线,则说明只完成了临时止损,没有消除根因。

量化基线

指标 样例基线/口径 风险线 结论
编译告警 -Xlint:unchecked 应为 0 出现 unchecked 定位原始类型边界
调用处强转 公共 API 目标为 0 数量持续增加 重新表达类型关系
bridge 方法 与重写层次匹配 意外增加 检查擦除后签名冲突

这些数值是实验口径或示例告警线,不是可复制到所有系统的固定答案;上线阈值应由本系统稳态、峰值和故障演练共同确定。

事故复盘:通用复制方法无法同时支持多个数字类型

工具方法最初声明为 copy(List, List),导致 List 和 List 都无法自然调用。把生产者参数改为 List<? extends T>、消费者改为 List<? super T> 后,类型关系由编译器验证,同时保持调用方无需强转。

失败模式 首要证据 第一处置动作
使用原始类型导致堆污染 编译告警数量 启用 -Xlint:all 并把告警当构建失败
滥用无界通配符丢失输入输出关系 ClassCastException 分布 用 TypeToken/Class 显式携带运行期类型
认为擦除后所有泛型检查都不存在 桥接方法与字节码签名 隔离遗留原始类型并在边界校验元素

发布与回滚检查点

  • 发布前:确认「javap -v Signature」对应实现和上述配置在目标版本仍然有效,并保存「编译告警」基线。
  • 灰度中:同时观察 编译告警数量、ClassCastException 分布、桥接方法与字节码签名;任一指标越过表中风险线,就停止继续扩量。
  • 回滚时:先执行「启用 -Xlint:all 并把告警当构建失败」控制影响,再回退代码或参数;涉及持久状态时必须额外核对结果差异。
  • 发布后:至少覆盖一个完整峰值周期,确认「使用原始类型导致堆污染」没有再次出现,才关闭变更观察窗口。

方案对比与选型

方案 更适合的场景 主要收益 代价与边界
具体类型参数 T 输入输出需要保持同一类型关系 签名清晰且可推断 无法表达只读或只写的宽泛边界
? extends T 只从参数读取 T 接受 T 的任意子类型容器 不能安全写入非 null 值
? super T 需要向参数写入 T 接受 T 的任意父类型容器 读取结果通常只能视为 Object

选型至少带上 对象创建率、调用频次、输入规模和 API 边界,并用上面的量化基线验证;未知数据应明确为待测假设。

设计边界与工程取舍

泛型解决编译期类型关系,不提供运行期容器元素校验;需要反序列化或反射构造时,应额外传入 Class、TypeToken 或显式解析器。

工程落地遵循:优先保证语言语义、类型契约和向后兼容,再讨论微小性能收益。回答时直接引用「javap -v Signature」、配置实验和事故数据,比复述固定模板更有说服力。