深蓝黑背景封面:右侧大标题「泛型:编译期严格,运行期失忆」,副标题「类型安全、类型擦除与通配符边界」;左侧原创示意图——一块写有 List 尖括号 String 的牌子经过一台碎纸机,出来变成只剩 List 的纸条,尖括号里的类型被擦掉了;左上标注「面试经典 · Java 后端」,右上标注「Day 10 · 10-28」 图 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。

三段式结构图:第一段「动机」——没有泛型时集合里什么都装、取出全靠强转,错误到运行期才炸;有了泛型,类型不匹配在编译期就被拦下,强转也随之消失,类型安全从人肉纪律变成编译器保证。第二段「擦除」——泛型信息只活在编译期,编译完成即被擦掉换成裸类型与自动强转,于是运行期拿不到 List 的 String、不能 new 泛型数组、instanceof 泛型不可用;好处是老字节码不用改,历史兼容的代价是类型信息的运行期缺席。第三段「通配符」——上界 extends 表示「生产者」,只往外拿、读出安全;下界 super 表示「消费者」,只往里放、写入安全,口诀「生产者用 extends、消费者用 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 口诀收尾通配符。你被泛型的哪条限制坑过?欢迎分享。