第 03 章 类型系统深水区:泛型擦除与通配符(为什么 List<String> 不是 List<Object>)

你在翻 Spring 源码的时候撞见这两行:

<T> T getBean(Class<T> requiredType)
void addListeners(Collection<? super Order> listeners)

语法你都认识。第一个是泛型方法,第二个是下界通配符。但你不敢改它 —— 你不知道把它改成 <T> T getBean(Class<?> type) 或者 Collection<Order> 会在哪儿炸掉。

这一章就是把”我不敢”变成”我知道边界在哪”。


03.1 先证明给你看:擦除是真实存在的

第 01 章 1.6 节提过一句擦除,打个招呼而已。现在把它按在地上证明给你看。

证据一:字节码里根本没有 String

import java.util.ArrayList;
import java.util.List;
 
public class EraseDemo {
    public static void main(String[] args) {
        List<String> names = new ArrayList<>();
        names.add("order-1");
        String first = names.get(0);   // 这行看起来没有强转
        System.out.println(first);
    }
}

编译后 javap -c EraseDemo.class:

$ javap -c EraseDemo.class
Compiled from "EraseDemo.java"
public class EraseDemo {
  public EraseDemo();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return
 
  public static void main(java.lang.String[]);
    Code:
       0: new           #7                  // class java/util/ArrayList
       3: dup
       4: invokespecial #9                  // Method java/util/ArrayList."<init>":()V
       7: astore_1
       8: ldc           #10                 // String order-1
      10: invokeinterface #12,  2           // InterfaceMethod java/util/List.add:(Ljava/lang/Object;)Z
      15: pop
      16: aload_1
      17: iconst_0
      18: invokeinterface #13,  2           // InterfaceMethod java/util/List.get:(I)Ljava/lang/Object;
      23: checkcast     #17                 // class java/lang/String     <-- 编译器偷偷塞的强转
      26: astore_2
      27: getstatic     #19                 // Field java/lang/System.out:Ljava/io/PrintStream;
      30: aload_2
      31: invokevirtual #25                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      34: return
}

怎么读这段输出(javap 里的 #N 是常量池索引,不同 JDK 版本编号不同,看注释就行):List.add 的入参是 Ljava/lang/Object;(不是 String);List.get 的返回是 Ljava/lang/Object;;紧跟其后的 checkcast #17 // class java/lang/String 就是编译器替你补的那一刀 —— 你源码里没写 (String),字节码里有。

所以”擦除”的准确含义是:泛型信息在编译期被拿去做类型检查,检查通过后就扔掉,同时在必要处插一条 checkcast 把类型”补回来”。编译期有类型,运行期没有。

证据二:两个不同参数化的 List,运行时是同一个类

List<String>  a = new ArrayList<>();
List<Integer> b = new ArrayList<>();
System.out.println(a.getClass() == b.getClass());   // true
System.out.println(a.getClass());                   // class java.util.ArrayList

ArrayList<String> 和 ArrayList<Integer> 是同一个 Class 对象,JVM 里只加载了一份 ArrayList。

问题 1: 既然是同一个类,那 ArrayList 内部那个 Object[] elementData 数组,凭什么能同时装 String 和 Integer?答案很平淡:因为它装的从来就是 Object。所谓 ArrayList<String> 只是编译器在调用点给你加了一层检查,容器本身对元素类型一无所知。

证据三:反射可以绕过编译器的检查

编译期检查做完就下班了:

import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.List;
 
public class BypassDemo {
    public static void main(String[] args) throws Exception {
        List<Integer> nums = new ArrayList<>();
        nums.add(1);
 
        // 拿的是运行时的方法,它的签名是 add(Object)
        Method add = nums.getClass().getMethod("add", Object.class);
        add.invoke(nums, "我其实是字符串");        // 没人拦你,调用成功
 
        System.out.println(nums);                  // [1, 我其实是字符串]  <-- 打印没问题
 
        // 但只要你用 "Integer 的眼光" 去看它,checkcast 就炸
        Integer n = nums.get(1);                   // ClassCastException
    }
}

注意爆炸的位置:System.out.println(nums) 完全正常(打印走 Object.toString(),不需要 cast),而 nums.get(1) 赋给 Integer 时炸了。

这个位置错位的特性非常值钱。 被污染的集合可以安静地流过十几层调用,最后在某行看起来完全无辜的代码上炸掉。

问题 2: 生产上真会遇到吗?我又不会主动用反射往 List 里塞错类型。会。三条常见路径:

  1. 原始类型(raw type)滥用:某个老接口签名是 List(不是 List<Order>),你传进去一个 List<Order>,对面往里塞了个 String。编译只有 warning,没人看 warning。
  2. Map<String, Object> 这类”万能容器”:JSON 反序列化出来一堆 LinkedHashMap,被强转成 DTO 时炸(03.7 有完整案例)。
  3. 泛型数组 + 数组协变(03.4),这是唯一”不用反射、不用 raw type”也能污染堆的路径。

【思考】为什么 Java 选了擦除,而 Go / C++ / Rust 都选了代码生成?是设计失误吗?

提示:想一下 Java 5 是 2004 年发布的,Go 1.18 是 2022 年。这十八年的差距意味着什么。

参考答案

不是设计失误,是 2004 年那个时间点上唯一可行的选择。

第一层:二进制兼容是硬约束。 Java 5 发布时(2004 年)已有海量在跑的 Java 代码和既定的 JVM 规范,Sun 面临两个选择:方案 A(擦除) —— 泛型只存在于源码和编译期,字节码格式一行不改,List 和 List<String> 编译后都是 java/util/List;方案 B(特化) —— 改字节码格式,为 List<String>、List<Integer> 各生成一份类型特定实现。方案 B 意味着所有已发布的 .class 文件和所有已部署的 JVM 全部作废,得让全世界重编译一遍代码,这在 2004 年是商业上不可接受的。

对比 Go:Go 1.18(2022 年)引入泛型时,“Go 的代码”这个概念才十来年,而且 Go 是静态链接、全量重编译的 —— 每次 go build 都从源码编译整个程序,不存在”我这个 jar 是三年前编译的”这种场景。没有历史包袱,所以能选 GC shape stencil + 字典这种更适合性能、但不兼容”部分重编译”的方案。

第二层:擦除换来的好处。 因为 List 和 List<String> 是同一个类:java.util.Collections(Java 1.2,无泛型)能原封不动地服务泛型代码;List<String> 能传给签名是 List 的老方法,只给个警告;第三方 jar 不需要为泛型重新发版。这在 2004-2010 年是决定性的。

第三层:代价清单(这笔账后来很贵) —— 运行时无泛型信息,JSON 反序列化只能靠 TypeToken(03.7);无法 new T()(03.2);List<int> 非法(03.6);擦除破坏重写,于是有桥接方法(03.3);数组协变与泛型不变冲突(03.4)。

更深一层:这是一个关于**“兼容性税”**的普遍规律 —— 任何语言在生命周期中后段引入重大特性,都要为全部历史用户买单。Java 2004 年付的是擦除这笔税;Go 不用付,是因为它还年轻且编译模型允许全量重编译。

这笔交易不是单边亏:Go 用表达力的小限制换来了类型信息 + 无装箱;Java 用类型信息 + 性能换来了生态的平滑迁移。


03.2 擦除的四条铁律及其后果

擦除不是一句口号,它由四条具体的语言规则构成。每条都给你代码证据和绕过方案。

铁律一:不能 new T(),也不能 new T[]

public class Box<T> {
    private T value;
 
    public Box() {
        this.value = new T();          // 编译错误:type parameter T cannot be instantiated directly
    }
 
    public T[] toArray() {
        return new T[10];              // 编译错误:generic array creation
    }
}

为什么不行? 因为 new 是要 JVM 执行的字节码指令(new + invokespecial <init>),JVM 运行时压根不知道 T 是什么,也不知道它有没有无参构造。注意区分:new T() 是运行时动作,擦除后没信息了;但 T 作为类型声明是可以的(编译器知道它)。

绕过方案 A:类型令牌 Class<T>(最常用)

import java.lang.reflect.Array;
 
public class Box<T> {
    private final Class<T> type;
 
    public Box(Class<T> type) {      // 调用方必须告诉你 T 是什么
        this.type = type;
    }
 
    public T create() throws ReflectiveOperationException {
        return type.getDeclaredConstructor().newInstance();   // 等价于 new T()
    }
 
    @SuppressWarnings("unchecked")
    public T[] createArray(int len) {
        return (T[]) Array.newInstance(type, len);            // 等价于 new T[len]
    }
}
 
// 用法
Box<Order> box = new Box<>(Order.class);
Order[] orders = box.createArray(10);       // 真·Order[],不是 Object[]

Array.newInstance(Class<?>, int) 返回的数组运行时组件类型就是 Order —— 比 (T[]) new Object[len] 强得多,后者会造成 03.4 的堆污染。

绕过方案 B:工厂方法 / Supplier<T>(更干净)

public class Box<T> {
    private final Supplier<? extends T> factory;
    public Box(Supplier<? extends T> factory) { this.factory = factory; }
    public T create() { return factory.get(); }
}
 
Box<Order> box = new Box<>(Order::new);   // 编译期就能检查 Order 有无参构造

问题 3: 为什么用 Supplier<? extends T> 而不是 Supplier<T>?(提示:你要造一个 Box<List<String>>,能写出 List<String>::new 吗?)能,但构造器引用的类型会被推断成 Supplier<ArrayList<String>> 之类的具体实现,? extends T 能放宽这个约束。

铁律二:不能对泛型类型做 instanceof

Object o = new ArrayList<String>();
 
if (o instanceof List<String>) { }     // 编译错误:illegal generic type for instanceof
if (o instanceof List<?>) { }          // 合法
if (o instanceof List) { }             // 合法(原始类型,不推荐)

为什么 List<?> 反而合法? 因为 instanceof 是运行时检查,只能问”你是不是 ArrayList 这个类”,问不了”你的元素是不是 String”(擦除后没人知道)。而 List<?> 的语义是”某个未知但确定类型的 List” —— 这个命题在运行时恰好只需检查”是不是 List”,它不对元素类型做任何断言,所以检查是完备的。

JLS 4.7 管这种”运行时信息完整、可检查”的类型叫 reifiable(可具体化)。完整定义:

类型是否 reifiable
非泛型类/接口(String)是
参数化类型且所有类型实参都是无界通配符(List<?>、Map<?,?>)是
原始类型(List)是
原始类型(int)是
元素类型 reifiable 的数组(String[]、List<?>[])是
List<String>、List<? extends Number>、T否

绕过方案:instanceof List<?> + 逐元素 instanceof:

static boolean isStringList(Object o) {
    if (!(o instanceof List<?> list)) return false;    // Java 16+ 的模式匹配
    return list.stream().allMatch(String.class::isInstance);
}

注意这是 O(n) 的,且空 List 会返回 true —— 语义上跟”这是个 List<String>”并不等价。这就是擦除的代价。

铁律三:不能创建泛型数组

List<String>[] arr = new List<String>[10];   // 编译错误:generic array creation
List<?>[] arr2    = new List<?>[10];         // 合法!
List[] arr3       = new List[10];            // 合法(原始类型,有警告)

为什么? 这是本章最核心的矛盾,03.4 会完整展开。先给结论:数组是协变的,写入时做运行时类型检查(ArrayStoreException);但擦除后 List<String> 的运行时类型就是 List,存储检查只能验到”这是个 List”,验不了”元素是 String”。若允许 new List<String>[10],你就能合法塞进 ArrayList<Integer> 然后让读取方炸掉 —— 所以从源头禁掉。

绕过:无界通配符数组 + 强制转换((List<String>[]) new List<?>[10],配 @SuppressWarnings("unchecked")),或者干脆别用数组,用 List<List<String>>(推荐)。

顺带一个高频坑:可变参数 + 泛型。static <T> List<T> of(T... elements) 会报 Possible heap pollution —— 因为 T... 在运行时就是 T[],而 T 不 reifiable,实际创建的是 Object[](或边界的擦除类型数组)。JDK 自己的 List.of / Arrays.asList 都面临这个问题,解法是加 @SafeVarargs(Java 7+,Java 9 起也能加在 private 方法上)—— 警告消失,但你要自己保证不做坏事。

铁律四:static 上下文不能引用类的类型参数

public class Repository<T> {
    private static T cache;                    // 编译错误:non-static type variable T
 
    public static T get() { return null; }     // 编译错误
 
    public static void set(T t) { }            // 编译错误
 
    public static <E> List<E> empty() {        // 合法:这是方法自己的类型参数
        return new ArrayList<>();
    }
}

为什么? 因为 static 成员属于类,而类型参数 T 属于实例。Repository<String> 和 Repository<Integer> 两个实例共享同一个 static cache 字段 —— 那它该是 String 还是 Integer?没有答案,所以语言直接禁掉。而 static <E> ... 合法,因为 E 是方法级类型参数,每次调用由调用点确定,跟类无关,也不存进任何字段。

绕过方案:static 方法要用类型,就把 Class<T> 当参数传进来。这也是为什么 JDK 和 Spring 里充斥着 <T> T getBean(Class<T> requiredType)(Spring)、<T> T readValue(String content, Class<T> valueType)(Jackson)这种签名。

Go 对照:Go 里没有 static,也没有”类级类型参数”(类型参数只能定义在类型或函数上)。所以你第一次遇到这条会觉得莫名其妙:为什么一个类型参数会有”属于类”和”属于方法”两种归属? 因为 Java 里 static 是类的一部分。


03.3 桥接方法(Bridge Method):擦除留下的补丁

擦除之后有个直接后果:父类被擦除后的方法签名,跟子类想实现的方法签名可能对不上。

现象

public final class IntBox implements Comparable<IntBox> {
    private final int value;
    public IntBox(int value) { this.value = value; }
 
    @Override
    public int compareTo(IntBox other) {
        return Integer.compare(this.value, other.value);
    }
}

你只写了一个 compareTo。反编译一下:

$ javap -c IntBox.class
public final class IntBox implements java.lang.Comparable<IntBox> {
  public int compareTo(IntBox);
    Code:
       0: aload_0
       1: getfield      #7                  // Field value:I
       4: aload_1
       5: getfield      #7                  // Field value:I
       8: invokestatic  #13                 // Method java/lang/Integer.compare:(II)I
      11: ireturn
 
  public int compareTo(java.lang.Object);    // <-- 哪来的?
    Code:
       0: aload_0
       1: aload_1
       2: checkcast     #2                  // class IntBox
       5: invokevirtual #18                 // Method compareTo:(LIntBox;)I
       8: ireturn
}

两个 compareTo。第二个就是桥接方法,它身上带 ACC_BRIDGE 和 ACC_SYNTHETIC 两个标志位。

为什么需要它

推导一遍:Comparable<T> 擦除后接口里的方法是 int compareTo(Object);你在 IntBox 里实现的是 int compareTo(IntBox);这两个是不同的签名,不构成重写。于是有人写 Comparable c = intBox; c.compareTo(other) 时,JVM 找不到 compareTo(Object)。编译器就补一个,内部 checkcast 成 IntBox 再转发给真身。

桥接方法纯粹是给 JVM 看的转发壳,源码层面你永远看不见它。

真实坑一:反射会拿到桥接方法

框架代码的高频事故源:

import java.lang.reflect.Method;
import java.util.Arrays;
 
public class BridgeScan {
    public static void main(String[] args) {
        for (Method m : IntBox.class.getDeclaredMethods()) {
            System.out.printf("%s(%s) bridge=%s synthetic=%s%n",
                m.getName(), Arrays.toString(m.getParameterTypes()),
                m.isBridge(), m.isSynthetic());
        }
    }
}
// compareTo([class IntBox])            bridge=false synthetic=false
// compareTo([class java.lang.Object])  bridge=true  synthetic=true

事故场景(真实排查案例):某内部 RPC 框架启动时报 IllegalStateException: Duplicate method mapping: compareTo。排查发现序列化层的 Map<String, Method> 以方法名为 key,遍历 getDeclaredMethods() 时没过滤桥接方法,同名方法注册了两次。根因就是 getDeclaredMethods() 会返回编译器生成的桥接方法。修复:

for (Method m : clazz.getDeclaredMethods()) {
    if (m.isBridge() || m.isSynthetic()) continue;   // 跳过编译器生成的方法
    if (Modifier.isStatic(m.getModifiers())) continue;
    registry.put(m.getName(), m);
}

教训:任何遍历 getDeclaredMethods() / getMethods() 的框架代码,都必须先过滤 isBridge() 和 isSynthetic() —— 序列化器、ORM 字段映射、Spring handler 扫描、MyBatis Mapper 解析,无一例外。

真实坑二:桥接方法还会因为”返回值协变”生成

不只是泛型,Java 5 允许重写时收窄返回类型,这也靠桥接实现:

class Animal { Object get() { return null; } }
class Dog extends Animal {
    @Override String get() { return "wang"; }   // 返回类型从 Object 收窄到 String
}

Dog.class.getDeclaredMethods() 里会有一个 Object get() 桥接方法。如果 getDeclaredMethod("get") 拿到的是桥接那个,m.getReturnType() 返回 Object 而不是 String —— 基于返回类型做分派的框架就会选错。

【思考】桥接方法会造成什么诡异的 bug?除了上面说的重复注册和返回类型错误,还有什么更隐蔽的?

提示:想想”只重新编译了子类,没重新编译父类”这种部分编译的场景。

参考答案

最隐蔽的一个:二进制兼容导致的 NoSuchMethodError。 这是”部分重编译”的经典陷阱,也是 00 章讲过的 Jar Hell 的一个变种。

// v1 的库代码
public class BaseEntity<T> { public void setId(T id) { ... } }
 
// 你的代码,针对 v1 编译
public class Order extends BaseEntity<Long> {
    @Override public void setId(Long id) { ... }   // 编译器生成桥接 setId(Object)
}

编译 Order 时,class 文件里有 setId(Long)(真身)和 setId(Object)(桥接)。然后库升级到 v2,作者把 setId 改名成了 assignId(或改了 T 的边界)。你把 v2 的 jar 放进 classpath,但没有重新编译 Order —— 这在大型项目里极其常见,你只升级了依赖,没动自己的代码。

运行时:JVM 加载 Order,看到标记着 ACC_BRIDGE 的 setId(Object),尝试解析它要转发的目标 setId(Long) —— 目标不存在了。结果:java.lang.NoSuchMethodError: Order.setId(Ljava/lang/Long;)V。

诡异在哪? 报错堆栈指向的调用点,源码上根本看不到那个方法。你 grep 整个项目都找不到谁调用了 setId(Long) —— 它是编译器帮你生成的。

排查与修复:javap -v Order.class | grep -B2 "ACC_BRIDGE" 能看到 class 文件里真实存在的方法及其 flags;修复办法是把依赖 v2 的模块重新全量编译。这也是为什么 CI 里”依赖升级后必须 clean build”是一条铁律。

另一个隐蔽 bug:注解继承 + 桥接方法。

interface Repo<T> {
    @Transactional
    void save(T entity);
}
 
class OrderRepo implements Repo<Order> {
    @Override public void save(Order o) { ... }   // 这个方法上有 @Transactional 吗?
}

OrderRepo 里会有两个 save:save(Order) 是真身,没有 @Transactional(注解在接口方法上不继承);save(Object) 是桥接,某些 JDK/编译器版本会把接口的注解合并到桥接方法上。于是框架扫描找 @Transactional 时可能只找到桥接方法 —— Spring 的 AnnotationUtils.findAnnotation 就是专门处理这件事的。你写框架代码时,查注解前先把桥接方法跳过去。

更深一层:桥接方法本质上是擦除欠下的技术债的一张收据 —— 为了让”擦除后的类型世界”和”源码里的类型世界”在 JVM 层面能对上,编译器必须在 class 文件里塞入源码中不存在的东西。这类”编译器偷偷加东西”的地方还有几个:内部类的 this$0 字段和 access$000 合成方法;lambda 的 lambda$main$0 方法和 LambdaMetafactory 动态生成的类;record 的 equals/hashCode/toString;enum 的 values()/valueOf()。共同点是:源码里没有,字节码里有,反射能看见。 所以每当你写基于反射的框架代码,第一件事永远是问自己:“这个 Method / Field 是编译器生成的吗?“


03.4 数组的协变性:Java 的一个”设计妥协”

现象

String[] strs  = new String[10];
Object[] objs  = strs;             // 合法!数组是协变的(covariant)
 
List<String> ls = new ArrayList<>();
List<Object> lo = ls;              // 编译错误!泛型是不变的(invariant)

问题 4: 凭什么数组可以、泛型就不行?

第一段:数组协变是 Java 1.0 的历史遗留

Java 1.0(1996 年)没有泛型,但总得有办法写通用代码,比如 Arrays.sort(Object[])。如果 String[] 不是 Object[] 的子类型,Arrays.sort 就得为每一种类型写一个重载 —— 在没有泛型的年代,唯一的出路就是让数组协变。

代价是运行时才炸:

String[] strs = new String[10];
Object[] objs = strs;
objs[0] = new Integer(1);   // 编译通过,运行时 ArrayStoreException

为什么这里能拦住?因为数组在创建时就记住了自己的组件类型(newarray/anewarray 指令带的运行时类型信息),每次 aastore 都做一次类型检查。这是 JVM 里少数”运行时还保留着类型信息”的地方。

Go 对照:[]string 和 []any 是完全不同的类型,不能赋值。Go 选了不变性,代价是 1.18 之前写通用代码只能靠 interface{} 断言。

第二段:泛型坚持不变,是为了把错误提前到编译期

假设 List<String> 是 List<Object> 的子类型,会发生什么:

// 假设(注意是假设)泛型是协变的
List<String> strs = new ArrayList<>();
List<Object> objs = strs;          // 假设合法
objs.add(new Integer(1));          // 往"装着 String 的 list"里塞 Integer
String s = strs.get(0);            // ClassCastException,在千里之外炸

这就是 堆污染(heap pollution):一个变量声明时说自己是 List<String>,实际上里面装了别的东西。JLS 4.12.2 专门定义了这个术语。

对比一下两种错误的爆炸时机:数组协变时,错误写入能在运行时被拦住(ArrayStoreException,数组记得组件类型);泛型如果协变,拦不住(擦除后 List 不记得元素类型),最早要到”读取并强转的那一刻”才发现。

这就是关键差别:数组有运行时类型信息兜底,泛型没有。 数组可以承担协变的风险,泛型承担不起,所以选择了不变。

所以本章标题的答案是:

List<String> 不是 List<Object> 的子类型,因为没有运行时检查能为这个协变兜底。语言宁可在编译期拒绝这个赋值,也不愿让你在运行时炸。

唯一的漏洞:泛型数组 + 强制转换

new List<String>[10] 被禁了,但你还是能通过强制转换制造污染:

@SuppressWarnings("unchecked")
List<String>[] arr = (List<String>[]) new List<?>[10];   // 只有警告,没有错误
 
Object[] objs = arr;                  // 数组协变,合法
objs[0] = new ArrayList<Integer>();   // 运行时组件类型擦除后是 List → 检查通过
 
String s = arr[0].get(0);             // 在完全无关的一行,ClassCastException

objs[0] = ... 为什么不炸?因为数组的运行时组件类型是擦除后的 List。数组的运行时检查在这里被擦除给骗过去了。

问题 5: 那 new List<?>[10] 为什么合法?它不也会被塞进 ArrayList<Integer> 吗?会,但没关系 —— List<?> 不对元素类型做任何承诺,你从里面读出来只能做”不含元素类型假设”的操作(size()、clear()),取元素只拿到 Object,没有任何承诺被违背,所以不会污染。

这是本章最值得记住的一条推理链:

reifiable(JLS 4.7)的本质是”运行时能完整验证这个类型”。List<?> 能验证(只需验证是不是 List);List<String> 不能(验证不了元素)。而数组的创建表达式要求组件类型 reifiable —— 于是 new List<?>[10] 合法,new List<String>[10] 非法。

【思考】为什么泛型坚持不变(invariant)?如果 List<String> 真的是 List<Object> 的子类型,最坏的情况是什么?

提示:不要只想”会 ClassCastException”,想一想一个被污染的 list 在大型系统里能传播多远。

参考答案

最坏情况:错误在时间和空间上被彻底解耦,排查成本从”分钟级”变成”天级”。

直接答案:会导致堆污染(heap pollution),你可以往一个声明为 List<Object> 但实际装着 String 的 list 里塞 Integer,然后在别处 String s = list.get(0) 时 ClassCastException。

但真正的代价不是这个异常本身,是这个异常的”位置”。 看一段真实世界的传播链:

// 第 1 层:DAO,签名用了协变的假设
List<Object> loadAll() {
    List<String> names = queryNames();
    return names;                    // 假设协变合法
}
 
// 第 2 层:Service,往里塞了个看起来无害的东西
void audit(List<Object> all) {
    all.add(AuditMarker.INSTANCE);   // 完全合法,编译无警告
}
 
// 第 3 层:另一个 Service,被缓存了
List<String> cached = cache.computeIfAbsent("k", k -> dao.loadAll());
 
// 第 4 层:三天后,一个批处理任务
for (String name : cached) {         // ClassCastException,在这里炸
    sendEmail(name);
}

这四层代码可能分布在四个模块、由四个人写、相隔三个月,报错的批处理作者根本没碰过第 1、2 层。

为什么这种 bug 特别贵:不可复现(污染在某次请求路径上,爆炸在几小时后的另一个任务里);堆栈完全误导(指向第 4 层,祸首在第 2 层);类型签名说谎(List<String> 不再是能相信的契约,整个代码库的类型标注失去意义)。

Go 对照:Go 里为什么没这个问题?

names := []string{"a", "b"}
var anySlice []any = names   // 编译错误:cannot use names as []any value

Go 的 slice 是不变的,想写通用代码只有两条路:1.18 前靠 interface{} 或显式转换(O(n) 拷贝),1.18 后靠泛型。Go 选择”语言不做妥协,代价转嫁给写通用代码的人”;Java 在数组上做了妥协,在泛型上拒绝了妥协。

那 Java 泛型怎么解决”我需要协变”? 用使用点型变(use-site variance),即通配符:

List<String> strs = new ArrayList<>();
List<? extends Object> view = strs;   // 合法!只读视图
view.add(new Object());               // 编译错误:不能写

精妙之处在于:它把”协变”从类型定义挪到了使用点,并用”禁止写入”换来安全性。这就是下一节 PECS 的内容。

更深一层:这体现了类型系统的一条普遍规律 —— 型变(variance)与可变性(mutability)的冲突:只读容器可以安全协变;只写容器可以安全逆变;又读又写的容器只能不变。Java 数组两者都要(既协变又可写),于是必须付运行时检查的代价;Go slice 选了不变;Kotlin 把这个做进了类型系统(List<out E> 只读协变 / MutableList<E> 不变)—— Java 没有只读集合接口,只能用通配符在调用点模拟。

记住这条:一个容器”既能读又能写”,它就不可能是协变的。


03.5 通配符:? extends 和 ? super(PECS)

先看一段反直觉的代码

class Animal {}
class Dog extends Animal {}
class Cat extends Animal {}
 
List<Dog> dogs = new ArrayList<>();
 
List<Animal> animals = dogs;              // 编译错误:incompatible types
List<? extends Animal> animals2 = dogs;   // 合法
 
animals2.add(new Dog());                  // 编译错误!为什么?
animals2.add(null);                       // 这个倒是可以
Animal a = animals2.get(0);               // 合法,返回 Animal

【思考】为什么 List<? extends Animal> 不能 add?我明明知道里面装的是 Animal 的子类型啊。

参考答案

因为编译器不知道”到底是哪个子类型”。

List<? extends Animal> 的完整语义是”某个确定的、Animal 的未知子类型的 List”。它可能是 List<Dog>,也可能是 List<Cat>。编译器不知道具体是哪个,所以无法保证 add 的类型安全。

用一个反例推翻它:

void addDog(List<? extends Animal> list) {
    list.add(new Dog());     // 假设这行合法
}
 
// 调用方
List<Cat> cats = new ArrayList<>();
addDog(cats);                // 合法的调用:List<Cat> 匹配 List<? extends Animal>
Cat c = cats.get(0);         // ClassCastException:Dog 不是 Cat

如果允许 add,上面这段代码就完全合法且会炸。这就是禁止 add 的全部理由。 而 add(null) 可以,因为 null 是所有引用类型的成员,塞进去不违背任何”元素类型”的承诺。

关键点:这个”不知道”正是安全性的来源。 编译器对每个 ? extends 变量都会在内部分配一个捕获变量(capture variable),比如 CAP#1 extends Animal —— 一个全新的、只在这一处有效的、不兼容于任何其他类型的类型。所以 list.add(list.get(0)) 也是编译错误:读到的是 CAP#1,要写进去的也是 CAP#1,但编译器不敢假设两次是同一个。这种”过度保守”就是安全性的代价。

代码锚点 —— ? extends 的容器是只读视图:

static double totalWeight(List<? extends Animal> animals) {
    double sum = 0;
    for (Animal a : animals) sum += a.weight();   // 读,返回 Animal,安全
    return sum;
}
totalWeight(new ArrayList<Dog>());   // 调用方完全自由

Go 对照:Go 没有通配符,只能显式声明类型参数(func TotalWeight[T Animal](xs []T))或退回接口。Go 没有声明点型变(out/in),靠类型参数 + 约束;Java 的通配符是使用点型变 —— 型变声明在变量/参数上。JDK 的 List<E> 定义里没有 out,是调用方决定 ? extends 还是 ? super。

更深一层:? extends 的核心价值不是”少写代码”,而是把型变的选择权推迟到使用点。代价是:每个泛型 API 作者都必须在签名里正确标上通配符,否则调用方就受限 —— 那些通配符不是装饰,是 API 可用性的一部分。你写 void process(List<Animal>) 而不是 List<? extends Animal>,调用方就永远传不进 List<Dog>。

PECS 原则

Joshua Bloch 在《Effective Java》里给了一条记忆口诀:PECS:Producer Extends, Consumer Super. 翻译成人话:

你拿这个容器干什么该用的通配符直觉
只从里面读(它是生产者)? extends T”我接受 T 的任意子类型集合,反正我只读”
只往里面写(它是消费者)? super T”我接受 T 的任意父类型集合,反正我只写”
又读又写不用通配符,就用 List<T>没得选,不变

源码证据一:Collections.copy

// JDK 真实签名
public static <T> void copy(List<? super T> dest, List<? extends T> src) {
    int srcSize = src.size();
    if (srcSize > dest.size())
        throw new IndexOutOfBoundsException("Source does not fit in dest");
 
    if (srcSize < COPY_THRESHOLD || (src instanceof RandomAccess && dest instanceof RandomAccess)) {
        for (int i=0; i<srcSize; i++)
            dest.set(i, src.get(i));      // 从 src 读,往 dest 写 —— 完美对应
    } else {
        ListIterator<? super T> di = dest.listIterator();
        ListIterator<? extends T> si = src.listIterator();
        for (int i=0; i<srcSize; i++) {
            di.next();
            di.set(si.next());
        }
    }
}

dest 是消费者(往里写)→ ? super T;src 是生产者(从里读)→ ? extends T。

源码证据二:Stream.forEach

void forEach(Consumer<? super T> action);

action 消费 T → ? super T。好处是 Consumer<Object> printer = System.out::println; list.forEach(printer); 能编译(Object 是 String 的父类型)。如果签名是 Consumer<T>,一个打印任意对象的 Consumer<Object> 反而用不了 —— 这就是不变性的代价。

源码证据三:Collections.max —— static <T extends Comparable<? super T>> T max(Collection<? extends T> coll)。集合是生产者 → ? extends;Comparable<? super T> 是消费者 → ? super,意思是”T 可以和自己的某个父类型比较”(Dog extends Animal,只要 Animal 实现了 Comparable<Animal>,Dog 就自动可用)。

问题 6: void addAll(List<Order> target, List<Order> source) 改造成通用方法,签名怎么写?static <T> void addAll(List<? super T> target, List<? extends T> source)。如果 target 还要读(比如做去重判断),那就只能是 List<T>。

无界通配符 ?:什么时候用

List<?>  list1;     // 某个未知类型的 List
List<Object> list2; // 明确装 Object 的 List
List     list3;     // 原始类型,关掉泛型检查

三者的语义是三个不同的东西:

List<?>List<Object>原始 List
含义某个确定但未知类型 X 的 List装 Object 的 List关掉泛型的 List(历史遗留)
List<String> 能赋值给它吗能不能能(有 unchecked 警告)
能 add(任意对象) 吗不能(只能 add null)能能
reifiable(可 instanceof)是否是
什么时候用方法只读且不关心元素类型真要往里塞任意类型几乎永远别用

Class<?> 是最常见的用法:void printInfo(Class<?> c) 接受任何 Class 且不关掉泛型检查;void printInfoRaw(Class c) 用的是原始类型 —— 原始类型是传染性的,一旦某个表达式里出现它,编译器会把整个表达式的泛型检查降级。

通配符捕获与 helper 方法

这是通配符最实用也最少人知道的技巧。问题:List<?> 不能写,那怎么写一个交换两个元素的通用方法?

static void swap(List<?> list, int i, int j) {
    list.set(i, list.set(j, list.get(i)));   // 编译错误!
}

报错原因:list.get(i) 返回 CAP#1,list.set(j, ...) 需要 CAP#1,编译器认为这两个 CAP#1 可能不是同一个。

解决:通配符捕获(wildcard capture)+ helper 方法。

// 公开 API:用通配符,调用方舒服
public static void swap(List<?> list, int i, int j) {
    swapHelper(list, i, j);          // 这一行能编译,是关键
}
 
// 私有 helper:用类型参数 T,"捕获"那个未知的 ?
private static <T> void swapHelper(List<T> list, int i, int j) {
    T tmp = list.get(i);             // T 是一个确定的类型变量,读写都行
    list.set(i, list.get(j));
    list.set(j, tmp);
}

为什么 swapHelper(list, i, j) 能编译? 因为编译器在调用点把 ? 捕获成一个全新的类型变量 CAP#1,然后推断 T = CAP#1。在 helper 内部 T 是一个确定的类型变量,所以读写都合法。

这个技巧的本质:把”通配符的未知性”隔离在一个方法调用的边界上 —— 外面是通配符(灵活),里面是类型变量(可用)。JDK 自己也这么干(可以打开 java.util.Collections 的源码看 swap)。

问题 7: 为什么不直接声明成 <T> void swap(List<T>, int, int)?其实也行(推断 T = CAP#1)。但 List<?> 放在公开签名上有个额外好处:它向调用方明确传达”我不关心元素类型”。


03.6 Java 泛型 vs Go 泛型:实现机制的正面比较

Go 1.18+ 的 GC shape stencil + 字典

Go 没走 C++ 的完全单态化,也没走 Java 的擦除,走了中间路线。

GC shape(GC 形状):两个类型若”内存布局相同、指针位图相同”就算同一个 shape。所有指针类型共享一个 shape;int 和 float64 虽然都是 8 字节但不是同一个 shape(运算指令不同);int16 和 int32 也不是(移位操作不同);接口类型永远自成一组。

stencil(模板印版):编译器为每个 shape 生成一份函数体,而不是为每个类型生成一份。

// 你写的
func Max[T constraints.Ordered](a, b T) T {
    if a > b { return a }
    return b
}
// 编译器生成(概念示意)
// Max·shape·int64(a, b int64) int64     <- int, int64 共用
// Max·shape·*uint8(a, b *uint8) *uint8  <- 所有指针类型共用

dictionary(字典):shape 相同但类型不同的地方(调 T 的方法、拿 T 的 runtime._type、接口转换需要的 itab),通过一个隐藏的额外参数传入 —— 所以 x.String() 在生成代码里是”从字典取方法指针再间接调用”。

关键收益:原始类型不装箱(Max[int] 直接操作 int);运行时能拿到类型实参;代码体积比完全单态化小得多。代价:方法调用变成字典间接调用(多一次 load,且影响内联);Go 无法支持”实例化集合无限膨胀”的程序(类型参数递归展开的泛型编译不过)。

Java 的擦除 与 正面性能对比

只有一份 ArrayList.class,内部是 Object[],取元素时 checkcast:

维度Java(擦除)Go(GC shape + 字典)
内存占用元素必须装箱:Integer 16 字节(对象头 12 + int 4)+ 引用 48 字节 ≈ **2024 字节/元素**[]int 4 字节/元素,零装箱
缓存局部性引用数组 → 每次访问一次指针跳转 → cache miss值数组连续布局,预取友好
调用开销checkcast(通常很便宜)+ 虚方法分派字典取方法指针的间接调用,影响内联
代码体积一份,无膨胀按 shape 分组,少量重复(go.shape.* 类型)
编译速度快(只编译一次)慢(要为用到的每个 shape 生成代码)
运行时类型信息无有(字典里有 runtime._type)
原始类型进泛型不支持,必须装箱支持
表达力限制无(擦除不限制实例化)无限递归实例化编译不过

注意”代码体积:Java 无膨胀”这一栏 —— 这是擦除唯一的性能优势,在超大规模代码库、移动端这些场景是真实的。

【思考】Go 的泛型为什么不直接单态化(像 C++ 那样每个类型生成一份)?

提示:想想 C++ 模板最被人诟病的是什么。

参考答案

两个核心原因:编译速度和二进制体积。

原因一:编译速度是 Go 的核心竞争力,不能丢。 完全单态化意味着:一个泛型函数被 N 个类型实例化就生成 N 份代码,每份都要分别走一遍优化、寄存器分配、机器码生成;泛型函数调用泛型函数时组合数量还可能指数级膨胀。C++ 项目编译动辄几十分钟,很大一部分就是模板实例化。

原因二:二进制体积(code bloat)。 C++ 模板的”代码膨胀”出了名 —— 一个 std::vector<T> 被 20 种类型实例化就是 20 份成员函数代码。Go 的部署优势(单文件、体积小、镜像小)会被破坏。

GC shape 正好砍在痛点上:所有指针类型共享一个 shape,而 Go 程序里泛型被指针类型实例化的情况占绝大多数,所以实际生成的代码份数远小于类型个数。

代码锚点(Go 侧):go tool nm app | grep -i shape 能看到 main.Max[go.shape.int_0] 这类符号 —— shape 类型被放在一个叫 go.shape 的伪包里,名字带类型参数下标(_0、_1),用来区分”两个类型参数恰好同 shape”的情况。

如果 Go 用了擦除会怎样? Sum([]int{...}) 得把 []int 转成 []any,每个 int 装箱成堆对象。Go 社区绝不会接受这个代价。

更深一层:这三种方案是一个三角权衡,只能选两个角:

方案运行时性能代码体积/编译速度运行时类型信息
C++ 完全单态化最优最差有
Java 擦除最差(装箱 + 间接)最优无
Go GC shape接近单态化中等有

Go 选了”牺牲一点运行时性能 + 中等的代码体积,换取类型信息 + 无装箱”;Java 选了”牺牲运行时性能和类型信息,换取零代码膨胀 + 完全向后兼容”。

这里有个反常识的点值得你记住:Java 的擦除在 2004 年是”最省代码体积”的方案,但在今天的硬件条件下代码体积早就不是瓶颈了,内存带宽和缓存才是。当年省下的那点代码量,现在换来的 GC 压力和 cache miss 代价要高得多。技术决策会过期,这是所有架构决策的宿命。

这不是 Java 程序员不懂泛型,是擦除逼出来的。

算一笔账。 存一千万个订单金额:

long[] amounts = new long[10_000_000];          // 80 MB,连续内存
List<Long> list = new ArrayList<>(10_000_000);  // 每个 Long: 16 字节对象 + 4~8 字节引用
                                                 // ≈ 200~240 MB,且对象散落在堆上

4~5 倍的内存膨胀,外加三笔账:GC 压力(一千万个 Long 是一千万个要被扫描、标记、移动的活对象);缓存不友好(每次 get(i) 跳到另一处地址取对象,一千万次 cache miss);分配成本(装箱要分配、初始化、最终被回收)。

真实代码里长什么样:Kafka broker 全程操作 ByteBuffer(MemoryRecords),落盘和发送走零拷贝;Spark Tungsten 的 MemoryBlock 用 long[] 作堆内页(JVM 保证 8 字节对齐),配 sun.misc.Unsafe(Java 9+ 是 VarHandle)直接读写地址;Flink 的 MemorySegment 直接包装 byte[] 或堆外内存;fastutil、HPPC、Eclipse Collections、Trove 为每种原始类型手工(或模板生成)一套特化集合(Int2IntOpenHashMap、LongArrayList)—— 它们的存在本身就是擦除的账单。

问题 8: 你的服务里有个 Map<Long, List<Order>> 存了百万级数据,第一步该做什么?先量:jmap -histo:live <pid> 看 java.lang.Long 占多少。如果占大头,再考虑换成 Long2ObjectOpenHashMap(fastutil)或 long[] + 排序 + 二分。

Valhalla 项目:补救进行中

Project Valhalla(2014 年启动)就是 Oracle 对这笔账单的偿还计划。截至 2026 年中,它的状态是:

特性JEP状态
Value Classes and Objects(值类,无对象标识)JEP 401预览(有基于 JDK 26 的 early-access 构建,位于 jdk.java.net/valhalla)
Null-Restricted Value Class Types—Draft
Enhanced Primitive Boxing(让 List<int> 成为可能)JEP 402Draft
Array Enhancements—开发中
Parametric JVM(泛型特化 / reified generics)—开发中

官方明确说了:这五块能力不会一次性交付,会跨多个 JDK 版本逐步释放,泛型特化(Parametric JVM)排在很后面。

一个可以自己玩的例子(JEP 401 early-access 构建,需 --enable-preview):

value class Point {
    private final int x;
    private final int y;
    Point(int x, int y) { this.x = x; this.y = y; }
    int x() { return x; }
    int y() { return y; }
}

值类放弃了对象标识(identity),于是 JVM 可以把字段**扁平化(flatten)**直接内联进数组和字段,而不是存一个指针 —— Point[] 变成”一串 x,y,x,y”,跟 C 的结构体数组一样。

这意味着什么? 二十年后 Java 终于要补上 List<int> 和”无装箱的泛型”。但你现在写的代码还得按擦除的世界观来设计。


03.7 绕过擦除:Type Token 与 Super Type Token

擦除最烦人的后果:

// 我想把一段 JSON 反序列化成 List<Order>,怎么告诉 Gson 元素类型是 Order?
List<Order> orders = gson.fromJson(json, ???);

List.class 不行 —— 运行时只有一个 List.class,它不记得元素类型。

经典事故:反序列化出来一堆 LinkedHashMap

现象:线上偶发 ClassCastException: LinkedHashMap cannot be cast to Order,这段代码跑了半年没事,某天开始炸:

// 出问题的代码
public <T> List<T> parseList(String json) {
    return gson.fromJson(json, List.class);   // 编译能过,只有警告
}
 
List<Order> orders = parseList(json);
Order o = orders.get(0);       // ClassCastException:实际是 LinkedHashMap

根因:fromJson(json, List.class) 告诉 Gson”目标是 List”,Gson 不知道元素类型,就按默认 Object 处理 —— JSON 对象被解析成 LinkedHashMap。由于擦除,这个错误被带到了调用点才爆。修复:gson.fromJson(json, new TypeToken<List<Order>>() {}.getType())。

教训:擦除后,“类型”这个信息必须由调用方显式传递,否则运行时就是一张白纸。

TypeToken 的原理

Gson 的核心实现就三步:getClass().getGenericSuperclass() 拿到带泛型的父类声明 → 强转 ParameterizedType → getActualTypeArguments()[0] 取出 List<Order>。

public class TypeToken<T> {
    private final Type type;
    protected TypeToken() { this.type = getTypeTokenTypeArgument(); }
 
    private Type getTypeTokenTypeArgument() {
        Type superclass = getClass().getGenericSuperclass();   // 匿名子类的父类声明
        if (superclass instanceof ParameterizedType p) {
            return p.getActualTypeArguments()[0];   // 拿到 List<Order>
        }
        throw new IllegalStateException("必须写成 new TypeToken<X>() {} 的形式");
    }
    public Type getType() { return type; }
}

使用(注意那个 {},它是整个技巧的核心):

Type t = new TypeToken<List<Order>>() {}.getType();
System.out.println(t);   // java.util.List<com.example.Order>
 
List<Order> orders = gson.fromJson(json, t);

{} 不是语法糖,它创建了一个匿名内部类。而匿名内部类是一个真实的类,它的 class 文件里有一行 extends TypeToken<java.util.List<com.example.Order>> —— 这行声明被写进了 class 文件的 Signature 属性里。

涉及的反射 API(可以自己验证):

import java.lang.reflect.*;
 
class Order {}
 
public class TypeTokenLab {
    public static void main(String[] args) {
        // 匿名子类能拿到带泛型的父类声明
        Type t = new java.util.ArrayList<Order>(){}.getClass().getGenericSuperclass();
        System.out.println(t);                                  // java.util.ArrayList<Order>
        ParameterizedType p = (ParameterizedType) t;
        System.out.println(p.getRawType());                     // class java.util.ArrayList
        System.out.println(p.getActualTypeArguments()[0]);      // class Order
    }
}

同款招数的其他变种

库类名
Gsoncom.google.gson.reflect.TypeToken
Jacksoncom.fasterxml.jackson.core.type.TypeReference
MyBatisorg.apache.ibatis.type.TypeReference
Springorg.springframework.core.ParameterizedTypeReference

用法清一色是 new Xxx<List<Order>>() {}(Jackson 是 mapper.readValue(json, new TypeReference<List<Order>>() {}))。

Spring 的典型场景是 RestTemplate:

ResponseEntity<List<Order>> resp = restTemplate.exchange(
    url, HttpMethod.GET, null,
    new ParameterizedTypeReference<List<Order>>() {}
);

Spring 的 ResolvableType:TypeToken 的加强版

TypeToken 有个硬伤:只能解析”直接写死的具体类型”。OrderRepo extends BaseRepo<Order> 能搞定;但 UserRepo extends MiddleRepo<User> 而 MiddleRepo<T> extends BaseRepo<T> 时,你只能拿到 TypeVariable。

Spring 4.0 的 org.springframework.core.ResolvableType 就是来解决这个的 —— 它沿着继承链一路解析类型变量的替换关系:

ResolvableType t = ResolvableType.forClass(UserRepo.class)
                                 .as(BaseRepo.class)     // 定位到继承链上的 BaseRepo
                                 .getGeneric(0);         // 第 0 个类型参数
System.out.println(t.resolve());     // class User   <- 正确解析出来了

其他常用入口:forField(Field)、forMethodReturnType(Method)、forMethodParameter(Method, int)。

这也是 Spring 能写出 JpaRepository<Order, Long> 这种”接口上声明泛型、实现类什么都不写”的底层支撑 —— Spring Data 启动时用 ResolvableType 从你的接口声明里把 Order 和 Long 挖出来(第 17 章讲)。

【思考】为什么匿名内部类能保留泛型信息,而普通代码不能?明明都是被擦除的 Java 代码。

提示:注意”擦除”到底擦的是什么 —— 是 class 文件里的一切,还是只是其中一部分?

参考答案

因为擦除不是”把 class 文件里的泛型信息全删掉”,而是”只在必要的地方保留”。保留的那一小部分,恰好只有声明位置才有。

Java 5 为了在不改 JVM 的前提下塞进泛型,给 class 文件格式加了一个 Signature 属性(JVMS 4.7.9),它只写在三个地方:类的声明(class OrderRepo extends BaseRepo<Order>)、字段的声明(private List<Order> orders;)、方法的声明(参数和返回值)。方法体内部的局部变量和表达式类型一律不写,擦得干干净净。

代码锚点,自己验证:

import java.lang.reflect.*;
import java.util.*;
 
class Order { List<String> tags; }
 
public class SignatureLab {
    // 字段声明有 Signature
    List<Order> field = new ArrayList<>();
 
    public static void main(String[] args) throws Exception {
        // 字段:能拿到泛型
        Field f = SignatureLab.class.getDeclaredField("field");
        System.out.println(f.getGenericType());   // java.util.List<Order>   ✅
 
        // 字段:擦除后的原始类型
        System.out.println(f.getType());          // interface java.util.List
 
        // 成员字段 Order.tags 也一样
        System.out.println(Order.class.getDeclaredField("tags").getGenericType());
        // java.util.List<java.lang.String>   ✅
 
        // 局部变量:无解,反射 API 根本没有入口
        List<Order> local = new ArrayList<>();
        // 没有任何标准反射 API 能问出 local 的泛型类型
    }
}

用 javap -v 能直接看到 Signature 属性:

$ javap -v -p SignatureLab.class
  java.util.List<Order> field;
    descriptor: Ljava/util/List;
    flags: (0x0000)
    Signature: #12   // Ljava/util/List<LOrder;>;     <-- 在这里!

为什么匿名内部类能利用这一点? 因为它是一个真实的类 —— 编译器会为它生成一个 class 文件(Main$1.class),里面必须记录它继承了谁,而”继承了谁”是类的声明,属于 Signature 覆盖范围:

new TypeToken<List<Order>>() {}
// 编译器生成:class Main$1 extends TypeToken<java.util.List<Order>>
//                                             ^^^^^^^^^^^^^^^^^^^^^^ 写进了 Signature

对照:普通对象为什么不行? ArrayList 这个类自己的 Signature 里只有类型变量 E,而你 new 出来的实例没有自己的 class 文件(共用 ArrayList.class),所以从 list.getClass() 出发永远只能看到 E。匿名内部类有”自己的 class 文件”,这就是它能记住东西的根本原因。

一个补充细节:局部变量的泛型信息其实没完全消失,编译器会把它写进 LocalVariableTypeTable(JVMS 4.7.14)。但那是给调试器用的,标准反射 API 不提供入口,要用只能靠 ASM/ByteBuddy 解析字节码。

更深一层:这暴露了擦除的本质 —— 擦除擦的是”实例的类型”,不是”声明的类型”。 一个类编译成 class 文件后,它”是什么”(声明、字段、方法签名)被完整保留;“它被用成了什么”被完全丢弃。

于是:框架能知道你的字段是 List<Order>,永远不可能知道你的局部变量是。 Spring 能注入 @Autowired List<Order> orders,但没法注入你在方法里写的 List<Order> local = ...。同理,MyBatis-Plus 选择 Order::getAmount 方法引用而不是字符串列名 —— 方法引用的目标方法签名写在 SerializedLambda 里,运行时可读。下一节展开。


03.8 实战:设计一个泛型 API 会踩的坑

场景:给团队写一个通用的 Repository<T, ID> + Page<T>,支持链式条件查询。看着是个小需求,但它会把这一章所有的坑都踩一遍。

版本一:天真写法(跑不起来)

public interface Repository<T, ID> {
    Optional<T> findById(ID id);
    List<T> findAll();
    Page<T> page(int pageNo, int pageSize);
}
 
public class JdbcRepository<T, ID> implements Repository<T, ID> {
    @Override
    public List<T> findAll() {
        // 问题 1:我不知道 T 是什么类,查哪张表?
        String sql = "SELECT * FROM ???";
        // 问题 2:我不知道 T 有哪些字段,怎么把 ResultSet 映射成对象?
        // 问题 3:T 没有 new() 可用
        T entity = new T();          // 编译错误
        return null;
    }
}

三个问题全部来自擦除。

版本二:能用的骨架

第一步:用 getGenericSuperclass() 把 T 挖出来(03.7 的招数):

import java.lang.reflect.*;
import java.util.*;
 
public abstract class AbstractRepository<T, ID> implements Repository<T, ID> {
 
    protected final Class<T> entityType;
    protected final String tableName;
    protected final Map<String, Field> columnFields = new LinkedHashMap<>();
 
    @SuppressWarnings("unchecked")
    protected AbstractRepository() {
        // 只在"直接子类是非泛型具体类"时有效,见下方说明
        Type sup = getClass().getGenericSuperclass();
        if (!(sup instanceof ParameterizedType p)) {
            throw new IllegalStateException(getClass() + " 必须直接继承并指定具体泛型实参");
        }
        Type[] args = p.getActualTypeArguments();
        // 注意:如果子类还是泛型的,args[0] 会是 TypeVariable 而不是 Class
        this.entityType = (Class<T>) args[0];
        this.tableName  = camelToSnake(entityType.getSimpleName());
 
        for (Field f : entityType.getDeclaredFields()) {
            int mod = f.getModifiers();
            if (Modifier.isStatic(mod) || Modifier.isTransient(mod)) continue;
            f.setAccessible(true);
            columnFields.put(camelToSnake(f.getName()), f);
        }
    }
 
    protected T newEntity() {
        try {
            return entityType.getDeclaredConstructor().newInstance();   // 绕过 new T()
        } catch (ReflectiveOperationException e) {
            throw new IllegalStateException("实体需要无参构造: " + entityType, e);
        }
    }
 
    private static String camelToSnake(String s) {
        return s.replaceAll("([a-z])([A-Z])", "$1_$2").toLowerCase();
    }
}

这一步的坑:getGenericSuperclass() 只认直接父类。继承链上还有类型变量时(MiddleRepo<T> extends AbstractRepository<T, ID>),你从 OrderRepo 出发只能拿到 TypeVariable,得沿着链往上替换 —— 这正是 Spring ResolvableType 存在的意义(03.7)。写框架时直接用它。

第二步:Page<T> 的定义与序列化

import java.io.Serializable;
import java.util.List;
 
// Java 16+ record;Java 8 项目里得写成 final class + 构造器 + getter
public record Page<T>(List<T> records, long total, int pageNo, int pageSize)
        implements Serializable {
 
    public int totalPages() {
        return (int) ((total + pageSize - 1) / pageSize);
    }
}

这里有个真实的坑:反序列化。 objectMapper.writeValueAsString(page) 没问题(运行时类就是 Page),但 readValue(json, Page.class) 是错的 —— 元素会变成 LinkedHashMap,在 p.records().get(0) 处 ClassCastException。正确写法是 readValue(json, new TypeReference<Page<Order>>() {})。

第三步:遍历方法时要过滤桥接方法(03.3)

实体如果有泛型基类(很常见),反射扫描就会撞上桥接方法:

abstract class BaseEntity<ID> { public abstract ID getId(); }
class Order extends BaseEntity<Long> {
    private Long id;
    @Override public Long getId() { return id; }   // 编译器额外生成桥接 Object getId()
}
 
for (Method m : entityType.getDeclaredMethods()) {
    if (m.isBridge() || m.isSynthetic()) continue;   // 否则你会认为 Order 有两个 getId
    if (Modifier.isStatic(m.getModifiers())) continue;
    // ...
}

第四步:条件查询的链式 API 和它的类型难题

最直接的写法是传字符串列名 —— repo.query().eq("amount", 100)。问题是改字段名时编译器不报错,重构必炸。MyBatis-Plus 的答案是lambda 方法引用:

LambdaQueryWrapper<Order> w = new LambdaQueryWrapper<>();
w.eq(Order::getAmount, 100);

【思考】为什么 MyBatis-Plus 这种框架要用那么多泛型 + 反射 + lambda(Order::getAmount 这种写法)?它到底靠什么在擦除的世界里把”amount”这个信息找回来的?

参考答案

因为 SerializedLambda 是 Java 8 留下的一扇后门,让框架能从 lambda 里反查出”它引用了哪个方法”。

字符串列名在重构时会静默失效,而 Order::getAmount 是编译期可检查的(改了方法名就编译不过)。为了在运行时拿到”getAmount”这个字符串,MyBatis-Plus 把方法引用序列化了 —— 不是真序列化成字节,而是触发 Java 8 给 lambda 留的一个机制:

Java 8 的 lambda 运行时由 LambdaMetafactory 动态生成类。而为了让 lambda 支持序列化,JLS 规定:若 lambda 的目标类型是可序列化的函数式接口,编译器会为宿主类生成一个 writeReplace() 方法,返回 java.lang.invoke.SerializedLambda,里面记录了 getImplClass()(宿主类)、getImplMethodName()(被引用的方法名)、getImplMethodSignature()、getCapturedArg(int)。

代码锚点:这就是那个黑魔法的最小实现

import java.io.Serializable;
import java.lang.invoke.SerializedLambda;
import java.lang.reflect.Method;
import java.util.function.Function;
 
// 关键点 1:函数式接口必须继承 Serializable,否则编译器不生成 writeReplace
@FunctionalInterface
public interface SFunction<T, R> extends Function<T, R>, Serializable {}
 
class Order {
    private Integer amount;
    public Integer getAmount() { return amount; }
}
 
public class LambdaHack {
    // 关键点 2:反射调用 lambda 生成类里的 writeReplace
    static SerializedLambda extract(Serializable lambda) throws Exception {
        Method m = lambda.getClass().getDeclaredMethod("writeReplace");
        m.setAccessible(true);                       // 它是 private final
        return (SerializedLambda) m.invoke(lambda);
    }
 
    public static void main(String[] args) throws Exception {
        SFunction<Order, Integer> getter = Order::getAmount;
        SerializedLambda sl = extract(getter);
        System.out.println(sl.getImplClass());        // com/example/Order
        System.out.println(sl.getImplMethodName());   // getAmount   <-- 拿到了!
 
        // 按 JavaBean 规范从方法名推出属性名
        String prop = methodToProperty(sl.getImplMethodName());   // amount
        System.out.println(prop);
    }
 
    static String methodToProperty(String name) {
        if (name.startsWith("get") && name.length() > 3)
            return Character.toLowerCase(name.charAt(3)) + name.substring(4);
        if (name.startsWith("is") && name.length() > 2)
            return Character.toLowerCase(name.charAt(2)) + name.substring(3);
        return name;
    }
}

如果目标类型不可序列化(普通的 Function<Order,Integer>),getDeclaredMethod("writeReplace") 会拿到 NoSuchMethodException。MyBatis-Plus 的 SFunction<T,R> 就是这么设计的。

完整链路:LambdaQueryWrapper.eq(Order::getAmount, 100) → LambdaUtils.extract() 拿到 SerializedLambda → getImplMethodName() = getAmount → PropertyNamer.methodToProperty() = amount → 查 TableInfo 得到列名 → 拼 SQL。

代价和坑:JDK 9+ 强模块化环境下对 lambda 生成类调 setAccessible 会受模块访问控制约束;每次都要反射,所以正经框架都会缓存解析结果;方法引用指向合成方法或桥接方法时,拿到的名字可能不是你预期的。

更深一层:TypeToken、ResolvableType、SerializedLambda 的共同点是都在用”声明”偷渡运行时类型信息 —— TypeToken 的载体是类的声明(Signature 属性),SerializedLambda 的载体是方法引用的目标方法,Spring 注解的载体是字段和参数的声明。

擦除擦掉的是”实例化时填的类型实参”,没擦掉”声明里写的类型”。 所以整个生态的类型恢复技巧,本质都在问同一个问题:“有没有哪个地方,把我要的类型写成了一个声明?” 能写在字段上就用注解 + 反射;能写成匿名类的父类就用 TypeToken;能写成方法引用就用 SerializedLambda;只能写成局部变量就没救,只能自己把 Class<T> 传进来。

这就是为什么 Java 框架里到处是”你得多写一个参数”/“你得写个匿名类”/“你得用方法引用” —— 那不是 API 啰嗦,是擦除的过路费。

Go 对照:Go 里做同样的事简单得多 —— 类型实参在运行时还在,或者干脆用语言原生的 struct tag(字段后面写 db:"amount",编译期写进类型定义,运行时 reflect 可读)。本质上还是”用声明偷渡信息”,但 Go 的 struct tag 是原生支持的,不需要 SerializedLambda 这种后门。这就是有无历史包袱的差距。


03.9 Go ↔ Java 泛型综合对照表

维度Go 1.18+Java 5+
实现机制GC shape stencil + 字典类型擦除(Object + checkcast)
运行时类型实参保留(字典里有 runtime._type)丢失
原始类型支持([]int、无装箱)不支持(必须 Integer)
类型参数归属只能定义在类型或函数上可定义在类或方法上;类的类型参数不能用于 static 成员
约束机制接口类型集(type sets)、comparable、any上界 <T extends X>,无下界(下界只能在通配符上)
型变无(slice/泛型都是不变的)使用点型变:? extends / ? super(PECS)
通配符不存在?、? extends T、? super T
方法自己的类型参数不支持(泛型方法非法)支持(<T> T foo(T t))
数组/切片型变slice 不变,[]string 不能当 []any数组协变(有 ArrayStoreException 兜底),泛型不变
泛型数组/切片[]List[T] 随便写new List<String>[10] 非法(new List<?>[10] 合法)
反射拿泛型信息可以(类型实参在运行时存在)只能拿声明上的(Signature 属性),拿不到局部变量的
类型令牌技巧不需要(Go 1.18 前用 reflect.TypeOf 传类型)必需(Class<T>、TypeToken<T>{}、ResolvableType)
桥接方法问题不存在存在(isBridge() 过滤)
表达力限制无限递归实例化编译不过无此限制
典型性能陷阱字典间接调用影响内联装箱 + GC 压力 + cache miss

03.10 本章核心结论

  1. 擦除的准确含义是”编译期检查完就扔,在必要处补一条 checkcast” —— List<String> 和 List<Integer> 在运行时是同一个 ArrayList.class,容器内部只认 Object。

  2. 擦除是 2004 年为二进制兼容做的取舍,不是设计失误。 它让整个 Java 生态不用重编译就平滑迁移;Go 2022 年引入泛型时没这个包袱,所以能选 GC shape + 字典。

  3. 擦除的四条铁律:不能 new T()、不能 instanceof List<String>、不能创建泛型数组、static 不能用类的类型参数。 每条都有绕过方案(类型令牌、Array.newInstance、无界通配符、方法级类型参数)。

  4. List<String> 不是 List<Object> 的子类型,因为没有运行时检查能为协变兜底。 数组敢协变是因为它记得组件类型(能抛 ArrayStoreException),泛型擦除后什么都不记得。

  5. PECS:生产者用 ? extends,消费者用 ? super。 这不是风格建议,是”能不能编译”的问题。漏了通配符,调用方就永远传不进子类型/父类型集合。

  6. 桥接方法是擦除留下的转发壳。 任何遍历 getDeclaredMethods() 的框架代码都必须先过滤 isBridge() 和 isSynthetic(),否则会重复注册、拿错返回类型、甚至 NoSuchMethodError。

  7. 擦除擦掉的是”实例化时填的类型实参”,没擦掉”声明里写的类型”。 所有类型恢复技巧(TypeToken、ResolvableType、SerializedLambda、注解)都是在用”声明”偷渡运行时信息;局部变量是唯一没救的地方。

  8. List<Long> 比 long[] 贵 4~5 倍内存、多一千万个 GC 对象、遍历全是 cache miss。 这是 Kafka/Flink/Spark 在热路径上不用 List<Long> 的全部理由,也是 Valhalla 存在的原因。


03.11 深度思考题

题 1:擦除这个决定,事后看是赚了还是亏了?

参考答案

我的判断:短期大赚,长期亏,但即使重来一次,2004 年的 Sun 还是会这么选。

赚的一面:生态零成本迁移(已有的 jar 不用重编译就能和泛型代码互操作;若当年选了特化,Java 生态会分裂成两个不兼容的世界);JVM 规范一行没改;学习成本低。

亏的一面:性能税交了二十年(装箱的内存膨胀、GC 压力、cache miss 每天被支付几十亿次,而内存延迟恰是这二十年进步最慢的硬件指标 —— 擦除的代价随时间越来越贵);表达力损失(List<int> 到今天写不出来);整个生态被迫发明补丁(TypeToken、SerializedLambda、ResolvableType、fastutil/HPPC/Trove);Valhalla 的补救成本(十二年过去值类才刚进预览 —— 这就是”技术债”最真实的含义:欠债时省下的力气,还债时连本带利)。

判断一个”为兼容做的妥协”是否划算,看两件事:省下的迁移成本是否大于它此后每年要付的税乘以存在年数?有没有渐进迁移路径能两者兼得?对 Java 来说,第一问 2006 年”划算”、2016 年”不划算”;第二问是”有,但 Valhalla 走了十二年还没走完”。

对照你的 Go 经验:这个案例教你的不是”别做兼容妥协”,而是 —— 妥协的代价是有利息的,且利率会随硬件和环境变化。做决定时要把它算进去。


题 2:为什么 List<String>[] 非法,但 List<?>[] 合法?

参考答案

直接答案:因为 List<?> 是 reifiable,List<String> 不是。数组创建表达式要求组件类型 reifiable(JLS 15.10.1 + JLS 4.7)。

要真正理解,得把三件事串起来。 第一,JLS 4.7 的 reifiable 定义(03.2 有完整表格):List<?> 满足”所有类型实参都是无界通配符”,List<String> 一条都不满足。第二,“reifiable”为什么存在? 直觉含义是:这个类型的运行时表示包含的信息跟编译时一样多 —— List 擦除后还是 List(没丢);List<?> 只需验证”是不是 List”(能验完);List<String> 的”元素必须全是 String”运行时验证不了。第三,数组创建偏偏要求 reifiable,因为数组协变且每次写入都做运行时类型检查。 看这条污染链:

// 假设允许 new List<String>[10]
List<String>[] arr = new List<String>[10];
Object[] objs = arr;                  // 数组协变,合法
objs[0] = new ArrayList<Integer>();   // 运行时组件类型擦除后只是 List → 检查通过,拦不住
String s = arr[0].get(0);             // 千里之外 ClassCastException

数组的存储检查(字节码 aastore)只能验到擦除后的类型 —— 对 List<String> 来说擦除后的检查目标就是 List,这个检查形同虚设。既然验不了,就别让你建。

那 List<?>[] 为什么放过? 因为塞进去不违背任何承诺:

List<?>[] arr = new List<?>[10];
Object[] objs = arr;
objs[0] = new ArrayList<Integer>();   // 同样通过运行时检查
 
List<?> item = arr[0];
item.size();                 // 可以
Object o = item.get(0);      // 可以,返回 Object
String s = item.get(0);      // 编译错误 —— 编译器从头到尾没给过你这个承诺

List<?> 从一开始就没承诺过元素类型,所以没有任何承诺被打破 —— 它是唯一一个”擦除后不损失信息”的参数化类型。

更深一层:数组协变、运行时存储检查、擦除 —— 三条规则单独看都合理,组合起来就产生矛盾,Java 的解法是在矛盾的交叉点上立一道禁令。这种”两个好设计撞车,于是加补丁规则”的现象在 Java 里非常多:数组协变 + 擦除 → 禁止泛型数组;擦除 + 重写 → 桥接方法;泛型 + 可变参数 → @SafeVarargs。

记住这个模式,你就不会再觉得”这规则怎么这么怪” —— 怪规则背后通常都是”两个好设计撞车”的历史。


题 3(动手,无标准答案):把你写过的一个 Go 泛型工具用 Java 重写一遍

挑 Map[T,U]、Set[T comparable]、Max[T Ordered]、Result[T] 或 Cache[K,V] 中的一个 —— 它们分别命中不同的坑。重写时按下面五问自查:

A:要不要创建 T 的数组? Go 里 make([]T, n) 随手就写,Java 里 new T[n] 非法,只能靠 Array.newInstance(type, n) 加类型令牌。你会开始觉得别扭:几乎每个泛型方法都要多传一个 Class<T>。某种程度上,Java 的泛型就是一套”编译期宏”。

B:comparable 约束怎么表达? Java 里没有语言级对应物 —— 任何对象都有 equals(),所以 HashSet<T> 直接能用,但编译期保证更弱:Go 的 comparable 保证 == 可用,Java 只保证 equals 存在(还可能是错的实现)。

C:你用到零值了吗? func Zero[T any]() T 在 Java 里写不出来 —— 没有统一的”零值”概念。

D:需要序列化 T 吗? 那调用方必须写 new TypeToken<Result<Order>>() {},不能只写 Result.class。

E:你的 []T 到底该用 Java 的什么? 这是最值得想的一问。Go 的 []T 是一块连续内存,Java 的 List<T> 是一个装引用的数组。 数值密集场景下,Java 的正确答案根本不是 List<Long>,而是 long[] + 手写循环,或者上 fastutil。

做完你会得到一条直觉:Java 泛型适合”组织引用类型的容器和 API 结构”,不适合”写数值密集的通用算法”。前者擦除无所谓,后者擦除致命。

反向思考(更有价值):你的 Go 代码里有没有哪些地方,是因为”Go 泛型运行时有类型信息”才写得出来的?(提示:靠 reflect.TypeOf(T) 做分发的那些)


题 4(开放):如果让你给 Java 设计一套”不擦除”的泛型,同时保持向后兼容,你会怎么设计?

参考答案(含 Valhalla 的真实思路)

难点全在”保持向后兼容”这五个字上。 你必须同时满足:老的 .class 不用重编译就能跑;老的源码不用改就能编译;新代码能拿到运行时类型信息且能写 List<int>。

先排除两个天真方案:给 List<String> 生成新类 —— 老代码拿到的还是老 List,两个世界不通;在对象里存一个 Class 字段 —— 老 class 文件没这个字段。

Valhalla 的真实思路:类型参数的”二元性”(T / T.ref)。

  1. 引入值类(value class):无对象标识,字段全 final,== 比字段值。因为无标识,JVM 可以把字段扁平化内联进数组和字段。
  2. 让原始类型也能当泛型实参(JEP 402):把 int 变成一种值类型,于是 List<int> 的元素直接内联,无 Integer 对象。
  3. 让类型参数声明”要不要擦除”:T = 值/特化语义(可以是值类型,运行时保留类型信息,由 Parametric JVM 按需生成特化实现);T.ref = 引用语义(走今天的擦除路径)。老的 List<E> 被重新解释为 List<E.ref>,老代码零改造。
// 概念示意(语法未定稿)
class Box<T> { private T value; T get() { return value; } }
 
Box<String> b1;   // String 是引用类型 → 擦除路径,行为跟今天一样
Box<int>    b2;   // 值类型 → 真的生成 int 特化版本,无装箱

为什么能兼容? 老代码用的类型实参全是引用类型(String、Order、Long),走 T.ref,.class 文件不用动;只有显式用值类型实例化时才走特化路径。

代价(Valhalla 走了十二年的原因):T 与 T.ref 的转换规则极其微妙;数组协变的老问题要重新面对(List<int> 和 List<long> 成了两个运行时类);null 怎么办(为此专门开了 “Null-Restricted Value Class Types”);反射 API 要扩展;JVM 要改类加载、方法分派、JIT。

给你的判断框架:任何”给成熟语言加运行时类型信息”的方案,都必须回答 —— 老代码里的 List 到底是 List<T> 还是 List<T.ref>? Valhalla 的答案是”看实参”,它把复杂度从语言规范转移到了编译器和 JVM 实现上。

对照 Go:Go 不需要这套东西,因为它从头没有”擦除过的老代码”。兼容性成本不是一次付清的,它会以”未来每个新特性都要绕着它走”的形式持续收租。


题 5:一个真实排查题 —— 为什么这段代码在测试环境好好的,生产上偶发 ClassCastException?

public class OrderCache {
    private static final Map<String, List> CACHE = new ConcurrentHashMap<>();
 
    @SuppressWarnings("unchecked")
    public static <T> List<T> getOrLoad(String key, Supplier<List<T>> loader) {
        return (List<T>) CACHE.computeIfAbsent(key, k -> loader.get());
    }
}
 
// 调用点
List<Order> orders = OrderCache.getOrLoad("orders", () -> dao.loadOrders());

提示:把 Map<String, List> 的声明、擦除、computeIfAbsent 的时机一起想。

参考答案

原始类型 Map<String, List> 关掉了泛型检查,缓存里存的是”某个具体类型的 List”,取出来时被强行解释成 List<T> —— 编译器不加 checkcast,因为 (List<T>) 这个转换是 unchecked 的。

第一层:List 是原始类型,整个 CACHE 的泛型检查被关掉了。 往里 put 任何 List<X> 都编译通过,取出来转成 List<T> 只需一个 unchecked 警告。

第二层(最关键):(List<T>) 这个强转在运行时什么都没做。 擦除后 T 变成 Object,(List<T>) 变成 (List),而 computeIfAbsent 返回的本来就是 List —— 这是恒等变换,编译器连一条 checkcast 都不生成。 所以这个方法返回的 list 从没被检查过。

第三层:真正的 checkcast 在调用点,不在缓存里。 Order o = orders.get(0); —— 编译器推断 T = Order 时不产生任何检查,checkcast 只在这一行插入,所以在这里才炸。

第四层:为什么”生产上偶发”? 要触发它,需要同一个 key 下被存入不同元素类型的 List:要么是 key 碰撞(硬编码的 "list" 被两处复用),要么是环境分支差异(生产上多了个降级分支,返回兜底的 List<Map>)。而 computeIfAbsent 只在首次加载时执行 loader —— 第一个抢到的线程决定了这个 key 里装的是什么。所以 bug 只在服务刚启动或缓存刚过期的那一瞬间发生,之后长期静默。

修复(治标):把类型令牌传进来,在写入时检查。

private static final Map<String, List<?>> CACHE = new ConcurrentHashMap<>();
 
public static <T> List<T> getOrLoad(String key, Class<T> elementType,
                                    Supplier<List<T>> loader) {
    List<?> cached = CACHE.computeIfAbsent(key, k -> {
        List<T> loaded = loader.get();
        // 加载完立刻检查:让错误在"存进去"的那一刻就炸,而不是几小时后
        for (T t : loaded)
            if (t != null && !elementType.isInstance(t))
                throw new IllegalStateException("缓存类型污染: key=" + key);
        return loaded;
    });
    @SuppressWarnings("unchecked")
    List<T> result = (List<T>) cached;
    return result;
}

治本方案:让 key 带上类型。 record CacheKey(String bizKey, Class<?> elementType) {},用 Map<CacheKey, List<?>> —— 从根上杜绝”同一个 key 装不同类型”。

三条教训:

  1. 原始类型永远不要出现在容器声明里。 Map<String, List> 里的 List 写成 List<?>,一个字符的差别换来编译器的全程保护。
  2. (List<T>) 这种 unchecked 强转是”我向编译器保证这是对的”,编译器信你然后什么都不查。写 @SuppressWarnings("unchecked") 前先问:我拿什么保证?答案是”我觉得”就别写。
  3. 让错误在写入时炸,不要在读取时炸。 污染点就是最好的报错点,缓存、队列、配置中心这类”跨时间传递数据”的组件尤其要注意。

Go 对照:这段代码在 Go 里根本编译不过 —— map[string][]T 的 T 必须编译期确定,Go 不允许把 []User 塞进声明为 []Order 的位置。Go 用”编译期拒绝”代替了 Java 的”运行时炸”。

这也印证 03.4 那句话:擦除把类型错误从编译时推到运行时,而原始类型是唯一能让你连编译时检查一起丢掉的按钮 —— 所以永远别按它。


下一章预告

第 04 章进 JVM 全景:类加载、字节码、内存区域。

这一章你已经摸到了 JVM 的边界 —— checkcast 指令、Signature 属性、ACC_BRIDGE 标志位、NoSuchMethodError。但这些都只是从门缝里瞥了一眼。

下一章我们把门打开:一个 .class 文件里到底有什么(常量池、字段表、方法表、属性表)、类加载的双亲委派是怎么回事、为什么同一个类被两个 ClassLoader 加载就是两个不同的类、以及方法区/堆/栈/元空间各自装什么。

承接关系很直接: 这一章讲的是”编译器往 class 文件里塞了什么”,下一章讲”JVM 怎么读它、怎么执行它”。理解了 class 文件的结构,你才算真正理解为什么擦除必须靠 Signature 属性来补救。