第 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.ArrayListArrayList<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 里塞错类型。会。三条常见路径:
- 原始类型(raw type)滥用:某个老接口签名是
List(不是List<Order>),你传进去一个List<Order>,对面往里塞了个String。编译只有 warning,没人看 warning。 Map<String, Object>这类”万能容器”:JSON 反序列化出来一堆LinkedHashMap,被强转成 DTO 时炸(03.7 有完整案例)。- 泛型数组 + 数组协变(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); // 在完全无关的一行,ClassCastExceptionobjs[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 valueGo 的 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)+ 引用 4 | []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 代价要高得多。技术决策会过期,这是所有架构决策的宿命。
实际影响:为什么 Kafka / Flink / Spark 里满是 long[] 和 int[]
这不是 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 402 | Draft |
| 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
}
}同款招数的其他变种
| 库 | 类名 |
|---|---|
| Gson | com.google.gson.reflect.TypeToken |
| Jackson | com.fasterxml.jackson.core.type.TypeReference |
| MyBatis | org.apache.ibatis.type.TypeReference |
| Spring | org.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 本章核心结论
-
擦除的准确含义是”编译期检查完就扔,在必要处补一条
checkcast” ——List<String>和List<Integer>在运行时是同一个ArrayList.class,容器内部只认Object。 -
擦除是 2004 年为二进制兼容做的取舍,不是设计失误。 它让整个 Java 生态不用重编译就平滑迁移;Go 2022 年引入泛型时没这个包袱,所以能选 GC shape + 字典。
-
擦除的四条铁律:不能
new T()、不能instanceof List<String>、不能创建泛型数组、static不能用类的类型参数。 每条都有绕过方案(类型令牌、Array.newInstance、无界通配符、方法级类型参数)。 -
List<String>不是List<Object>的子类型,因为没有运行时检查能为协变兜底。 数组敢协变是因为它记得组件类型(能抛ArrayStoreException),泛型擦除后什么都不记得。 -
PECS:生产者用
? extends,消费者用? super。 这不是风格建议,是”能不能编译”的问题。漏了通配符,调用方就永远传不进子类型/父类型集合。 -
桥接方法是擦除留下的转发壳。 任何遍历
getDeclaredMethods()的框架代码都必须先过滤isBridge()和isSynthetic(),否则会重复注册、拿错返回类型、甚至NoSuchMethodError。 -
擦除擦掉的是”实例化时填的类型实参”,没擦掉”声明里写的类型”。 所有类型恢复技巧(TypeToken、ResolvableType、SerializedLambda、注解)都是在用”声明”偷渡运行时信息;局部变量是唯一没救的地方。
-
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)。
- 引入值类(value class):无对象标识,字段全 final,
==比字段值。因为无标识,JVM 可以把字段扁平化内联进数组和字段。 - 让原始类型也能当泛型实参(JEP 402):把
int变成一种值类型,于是List<int>的元素直接内联,无Integer对象。 - 让类型参数声明”要不要擦除”:
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 装不同类型”。
三条教训:
- 原始类型永远不要出现在容器声明里。
Map<String, List>里的List写成List<?>,一个字符的差别换来编译器的全程保护。 (List<T>)这种 unchecked 强转是”我向编译器保证这是对的”,编译器信你然后什么都不查。写@SuppressWarnings("unchecked")前先问:我拿什么保证?答案是”我觉得”就别写。- 让错误在写入时炸,不要在读取时炸。 污染点就是最好的报错点,缓存、队列、配置中心这类”跨时间传递数据”的组件尤其要注意。
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 属性来补救。