Java 泛型与公共 API
下面这个签名把三个位置关联起来:来源元素、目标可接收的值、方法内部搬运的值都是同一个 T。
static <T> void copy(
List<? extends T> source,
List<? super T> target) {
for (T item : source) {
target.add(item);
}
}编译器据此检查 source 能否读出 T、target 能否写入 T。进入 class 文件后,JVM 方法描述符里的多数泛型类型会被擦除,声明关系则可能保存在 Signature 属性中;读取参数化值时,编译器还会插入必要的类型转换。
这组机制同时影响日常集合 API 和公共库升级。把 raw List 改成 List<Order> 可以改善新源码的静态约束,却不能清洗历史元素,也不能单凭源码签名判断旧客户端 class 是否仍可链接。泛型设计需要同时看编译期关系、class 文件形状和运行时数据。
类型参数把多个位置关联起来
最小的泛型类只做一件事:在一次具体使用中,让 set 接收的类型与 get 返回的类型保持一致。
final class Box<T> {
private T value;
Box(T value) {
this.value = value;
}
T get() {
return value;
}
void set(T value) {
this.value = value;
}
}在声明 Box<T> 中,T 是类型变量;使用 Box<String> 时,String 是类型实参,Box<String> 是参数化类型。此后 set 只能接收 String,get 也直接得到 String,不需要调用者自行强转。
Box<String> box = new Box<>("order-17");
box.set("order-18");
String id = box.get();单写 Box 得到原始类型。它与 Box<Object> 含义不同:后者明确允许保存任意 Object,raw Box 则放弃类型实参检查,主要用于连接泛型出现前的旧 API。raw 值进入参数化变量时通常产生 unchecked 警告,因为编译器已经无法确认其中的对象满足目标类型。
类型变量也可以出现在方法上。下面的 first 不关心元素究竟是订单还是用户,但保证返回值与传入列表的元素类型相同:
static <T> T first(java.util.List<T> values) {
if (values.isEmpty()) {
throw new IllegalArgumentException("values is empty");
}
return values.get(0);
}JLS 17 的类型变量、参数化类型与原始类型给出了这些关系的精确定义。泛型把原本可能落到读取处的转换错误提前到编译期,并让 API 的输入输出关系可以由工具检查。
不变性决定参数能读什么和写什么
Integer 是 Number 的子类型,但 List<Integer> 不是 List<Number> 的子类型。若下面的赋值成立,调用者就能把 Double 写进一份承诺只含整数的列表:
List<Integer> integers = new ArrayList<>();
List<Number> numbers = integers; // 编译失败
numbers.add(3.14);
Integer first = integers.get(0);这种关系称为不变性。它保护的是容器的读写契约,而不是否认元素类型之间的继承关系。需要接受一族参数化类型时,用通配符明确当前方法真正需要的方向:
| 参数形式 | 安全读取 | 安全写入 | 典型用途 |
|---|---|---|---|
List<T> | T | T | 同一参数既读又写精确类型 |
List<? extends T> | T | 不能写入非 null 值 | 从某种 T 子类型来源读取 |
List<? super T> | 通常只能当 Object | T | 把 T 写入某种父类型容器 |
List<?> | Object | 不能写入非 null 值 | 只依赖元素未知这一事实 |
表中的“能写入”只描述静态类型允许的操作;若实际对象是 List.of(...) 等不可修改实现,即使类型允许,修改仍会在运行时失败。
PECS 是这组关系的便捷记法:Producer Extends,Consumer Super。复制方法从来源读取 T,向目标写入 T,因此签名如下:
static <T> void copy(
List<? extends T> source,
List<? super T> target) {
for (T item : source) {
target.add(item);
}
}类型变量适合表达多个位置共享的关系;通配符适合表达某个参数允许的一族类型。返回值通常使用调用者能直接命名的 List<T>,而不是把 List<? extends T> 的捕获负担传出去。一个参数既要读取又要写回同一种类型时,也通常保留 List<T>。
算法确实需要某项能力时,可以给类型变量加上界:
static <T extends Comparable<? super T>> T max(List<? extends T> source) {
if (source.isEmpty()) {
throw new IllegalArgumentException("source is empty");
}
T best = source.get(0);
for (int i = 1; i < source.size(); i++) {
if (source.get(i).compareTo(best) > 0) {
best = source.get(i);
}
}
return best;
}Comparable<? super T> 允许 T 使用自身或父类型定义的比较契约。若存在交叉上界,类上界必须位于最左侧,后面才能列接口。边界应来自方法真正调用的能力;如果普通调用依赖多层通配符和显式类型提示才能编译,往往应先拆开读写职责,而不是用 unchecked 强转绕过错误。JLS 17 的类型实参与通配符规则是这类签名的规范基础。
class 文件留下擦除类型和必要的桥方法
Java 不会为 List<String> 和 List<Order> 各生成一套集合类。编译器会执行类型擦除:参数化类型 List<String> 擦除为 List;无界 T 擦除为 Object;有界类型变量擦除为它最左侧上界的擦除。读取参数化值时,编译器再在必要位置插入转换。
擦除不能破坏原有的虚方法分派,因此编译器有时会生成 bridge 方法:
class Node<T> {
T value() {
return null;
}
}
final class StringNode extends Node<String> {
@Override
String value() {
return "ok";
}
}Node.value() 擦除后的返回类型是 Object,子类源码方法返回 String。为了让已经按 Object value() 调用的方法仍能分派到子类,编译器会在 StringNode 中合成一个返回 Object 的 bridge,再转调真正返回 String 的实现。这个方法应通过 javap -p -s -v 观察,不需要业务代码手写。
擦除也解释了几项常见限制。运行时能完整表示的类型称为可具体化类型;非泛型类型、raw type、List<?> 和满足条件的数组属于此类,List<String>、T 与 List<String>[] 则不是。因此不能写 new T()、T.class、new List<String>[10] 或 value instanceof List<String>;可以检查 value instanceof List<?>,再验证每个元素。
类文件可以在 Signature 属性中保存声明层面的泛型信息,但一个普通 List 对象不会因此携带“其中每个元素都已验证为订单”的运行时事实。框架可以读取声明或接收显式类型描述,外部数据仍必须逐项校验。JLS 17 的擦除规则与可具体化类型规则划定了这条编译期和运行时边界;JVMS 17 的 Signature 属性和方法访问标志则说明 class 中怎样记录泛型声明与 ACC_BRIDGE。
raw 与 unchecked 会把错误推迟到读取处
当一个参数化类型变量引用了不符合其类型实参的对象时,就发生了 heap pollution。raw type 和 unchecked 转换是最常见的入口:
record Order(String id) {}
List raw = List.of(new Order("order-17"), "legacy-text");
List<Order> orders = raw; // unchecked:编译器无法证明
Order second = orders.get(1); // 读取处插入转换并抛 ClassCastException异常落在 get 的读取处,污染却在更早的 raw 边界已经存在。继续在读取处增加强转只会重复同一个失败;可靠的适配器应先把未知集合当作 List<?>,逐项检查并复制:
static List<Order> decodeOrders(List<?> values) {
List<Order> result = new ArrayList<>(values.size());
for (int i = 0; i < values.size(); i++) {
Object value = values.get(i);
if (!(value instanceof Order order)) {
throw new IllegalArgumentException(
"orders[" + i + "] is not an Order");
}
result.add(order);
}
return List.copyOf(result);
}这样,坏数据在 SDK 入口被拒绝,成功返回的 List<Order> 才具有适配器刚刚建立的含义。若旧库迫使代码做一次 unchecked 转换,应把转换和 @SuppressWarnings("unchecked") 限制在这个小边界内,并在转换前完成能够支撑它的检查;不能让 raw 值继续进入缓存、领域对象或公共返回值。
泛型 varargs 是另一种污染入口,因为以 variable-arity 形式调用时会创建元素类型不可具体化的数组。@SafeVarargs 只允许用于构造器以及 static、final 或 private 的可变参数方法;它是作者对数组操作安全性的承诺,不会提供运行时保护。安全实现只遍历参数并复制元素;需要修改或暴露数组时,应改用普通集合参数。JLS 17 的 heap pollution 定义和 @SafeVarargs 规则说明了这些警告为何不能随意压掉。
公共泛型 API 要分别判断三种兼容
把旧方法从 raw 签名改得更精确,可能不改变 JVM 方法描述符。例如:
v1: static List load(String dataset)
v2: static List<Order> load(String dataset)两者擦除后的描述符都使用 java.util.List。只考虑这次签名变化,已编译客户端仍可能找到同一个方法;新编译的调用者却会看到新的类型约束,历史列表中的元素也不会自动变成 Order。
| 兼容维度 | 要回答的问题 | 典型失败 |
|---|---|---|
| 源码兼容 | 旧源码换用新库后还能否无歧义编译 | 推断失败、新 unchecked 警告、重载歧义 |
| 二进制兼容 | 旧客户端 class 不重编译能否链接新版库 | NoSuchMethodError、AbstractMethodError |
| 数据与行为兼容 | 旧缓存、消息或插件结果是否满足新语义 | 读取处 ClassCastException、结果含义改变 |
擦除相同只能回答二进制链接的一部分,不能替代另外两项。给类型变量增加上界时尤其要看最左上界:它可能改变擦除后的字段或方法描述符。仅靠不同类型实参新增重载会因相同擦除直接产生 name clash;其他新增重载虽然不改变旧二进制已经选定的调用点,却可能让旧源码重新编译时变得歧义。覆盖关系变化还可能增加或移除调用方依赖的 bridge。
因此,一次公共泛型 API 升级至少要分别运行三种观察:用新版库重新编译旧源码并处理新增警告;不重编译旧客户端,直接搭配新版库运行;把真实历史数据送入新版边界,确认它得到合法对象或在适配入口明确拒绝。JLS 17 的二进制兼容规则明确区分二进制兼容与源码兼容,并说明类型变量最左上界变化怎样影响擦除后的成员。
用 Docker 观察 SDK 的链接、晚失败与边界修复
配套实验提供 run-docker.sh 和底层 run.sh。推荐路径使用装有 Docker Engine 的 Linux 开发机,以能够访问 Docker 的普通用户从仓库根目录执行:
export LAB_DIR="$PWD/docs/.vuepress/public/examples/backend-development/generics-api-evolution"
test "$(id -u)" -ne 0 || {
echo '请切换到能够访问 Docker 的普通用户' >&2
exit 1
}
test -r "$LAB_DIR/run-docker.sh" || exit 1
bash "$LAB_DIR/run-docker.sh"包装脚本默认固定 eclipse-temurin:17.0.20_8-jdk@sha256:a27c79d44326d5f689668df5fedfee487652066d2a91e172747056cc7fbee6fc。容器使用宿主 UID/GID、无网络、只读根文件系统和只读源码挂载,只给 /tmp 分配 128 MiB 临时文件系统。该镜像来自 Eclipse Temurin 官方镜像,对应发行物可在 Adoptium Temurin 发布页核对。
底层脚本在容器临时目录中分别编译 v1 SDK、旧客户端、错误的 v2 SDK 和修复后的 v2 SDK,避免同名 class 相互覆盖。旧客户端只针对 v1 编译一次;它把 raw 返回值赋给 List<Order> 时应出现 unchecked 警告,生成的 class 随后保持字节不变地搭配两个 v2 版本运行。脚本还会把同一份旧源码分别对两个 v2 重新编译,确认源码仍可编译且继续暴露 raw/unchecked 警告;新客户端直接接收 List<Order>,必须在 -Xlint:all -Werror 下通过。
javap 的断言会先截取公开 load 的单个方法块,再比较其中的 descriptor 与 Signature,避免被同 descriptor 的私有方法误导。三版 load 的 descriptor 都必须是 (Ljava/lang/String;)Ljava/util/List;,只有两个 v2 的公开 load 带有 List<Order> 泛型 Signature。另一个极小 bridge 探针会同时断言返回 String 与返回 Object 的描述符,以及 ACC_BRIDGE, ACC_SYNTHETIC 标志。
错误 v2 在内部用一次被局部抑制的 unchecked 转换信任历史列表;旧客户端读取混合数据时,异常因此落在自己的遍历代码。修复版改用 List<?> 接住历史数据并逐项校验,相同坏数据应在 OrderSdk.load 边界得到明确拒绝。合法历史数据则返回 List<Order>,同一份旧客户端 class 继续正常运行。
输出先显示 java、javac、javap 都来自 Java 17,然后给出下面的验证摘要:
descriptor-stable=true
v1-signature=raw-list
v2-bad-signature=list-of-order
old-source-v2-bad=compiles-with-unchecked
old-source-v2-fixed=compiles-with-unchecked
new-source-v2-bad=strict-compile
new-source-v2-fixed=strict-compile
old-client-v2-bad-valid=true
v2-bad-history=ClassCastException-at-old-client
v2-fixed-history=rejected-at-sdk-boundary
old-client-v2-fixed-valid=true
new-client-v2-fixed-valid=true
old-client-byte-identical=true
bridge=ACC_BRIDGE+ACC_SYNTHETIC
PASS错误 v2 的运行失败是实验目标,外层脚本捕获它后继续。只有旧源码警告、新源码严格编译、相同擦除描述符、v2 泛型 Signature、bridge 标志、两个异常首帧、合法数据路径和旧客户端字节全部符合预期,脚本才打印 PASS 并以状态 0 退出;其他情况打印 FAIL: ...,状态非零,退出时清理临时产物。
本机已经安装 JDK 17 时,可以绕过 Docker:设置 JDK17_HOME 后直接运行 run.sh。脚本会确认 java、javac、javap 均来自 Java 17,并打印完整工具版本。Docker 报错发生在摘要之前时,先检查守护进程权限、镜像摘要拉取和主机 CPU 平台;输出出现 FAIL: 时,按提示保留对应源码版本,重新运行底层命令定位缺失断言。
编译、链接和读取失败发生在不同阶段
如果只看到 ClassCastException,先沿值的来源回查 raw 声明、unchecked 转换和外部数据入口;如果错误发生在重新编译时,先处理不变性、通配符捕获、推断边界或同擦除重载;如果旧 class 无法链接,则比较 javap -s 中的描述符、最左上界和 bridge。三类现象处在不同阶段,修复位置也不同:编译错误回到签名关系,链接错误回到二进制形状,读取错误回到建立数据可信度的适配边界。
| 现象 | 第一组检查 | 处理位置 |
|---|---|---|
incompatible types / 类型推断失败 | 方法类型变量、参数声明类型、读写方向 | 调整 T、extends、super 或拆分职责 |
name clash: same erasure | 两个候选方法擦除后的名称和 descriptor | 修改 API 形状,不能只靠不同类型实参重载 |
NoSuchMethodError / AbstractMethodError | 新旧 javap -s、最左上界、bridge | 恢复二进制成员或提供兼容适配版本 |
读取处 ClassCastException | raw 来源、unchecked 位置、异常第一帧 | 在最早可信边界逐项校验并复制 |
| 泛型 varargs 警告 | 是否写入或暴露不可具体化数组 | 改用集合参数,或证明实现后局部使用 @SafeVarargs |
公共 API 发布前应保存三份独立结果:旧源码对新库的编译输出、旧客户端 class 对新库的运行输出、历史数据经过新边界的验证输出。它们对应不同兼容问题,缺少其中一份时,另外两份无法代替。
权威资料与规范地址
以下地址用于核对 Java 17 泛型语义、class 元数据和二进制兼容规则。Docker 镜像与发行物地址用于复现实验环境。
类型参数、通配符与污染
| 资料 | 用途 |
|---|---|
| JLS 17:类型变量 | 类型变量、上界与作用域 |
| JLS 17:参数化类型 | 参数化类型基本规则 |
| JLS 17:原始类型 | raw type 与旧 API 兼容 |
| JLS 17:类型实参与通配符 | 上下界通配符与包含关系 |
| JLS 17:heap pollution | 参数化变量指向错误类型对象的定义 |
JLS 17:@SafeVarargs | 泛型 varargs 的声明限制与承诺 |
擦除、class 文件与兼容
| 资料 | 用途 |
|---|---|
| JLS 17:类型擦除 | 类型变量和参数化类型的擦除规则 |
| JLS 17:可具体化类型 | 运行时完整可用的类型范围 |
JVMS 17:Signature 属性 | class 中的泛型声明元数据 |
| JVMS 17:方法访问标志 | ACC_BRIDGE 与 ACC_SYNTHETIC |
| JLS 17:二进制兼容 | 源码与二进制兼容的区别 |
实验发行物
| 资料 | 用途 |
|---|---|
| Eclipse Temurin 官方镜像 | 固定容器镜像的来源与变体 |
| Adoptium Temurin 发布页 | JDK 17 发行物与支持平台 |
