类型、泛型、异常与字节码:语言契约、编译产物与运行边界
结论基线:语言规则以 Java SE 17(JLS 17)为准;编译器源码以 OpenJDK 17u
jdk-17.0.12+7为准。本文区分语言契约、class 文件现象与特定实现。旧 OpenJDK 17.0.12 字节码输出作为历史证据保留;U04 新实验使用 BellSoft Liberica 17.0.19+11-LTS、Darwin 27.0.0 arm64,查询与运行日期 2026-10-24。两个版本不拼成同一次运行。
类型、值与引用:参数复制的是什么
Java 的变量有静态类型;var 由编译器推导这个类型,不使变量变成可任意换类型的动态槽位。基本类型有 boolean 和七种数值类型;char 是 16 位无符号 UTF-16 码元,不保证一个值就是一个完整字符。引用类型包括类、接口和数组,引用值可以指向对象或为 null。字段有默认值,例如 int 为 0、引用为 null;局部变量必须在读取前明确赋值,不能照搬字段规则。
调用方法时复制的是值:基本类型复制数值,引用类型复制引用值。下面是可直接放入类中运行的教学例子,TypeProbe 使用同样输入检验:
static class Order {
int quantity;
Order(int quantity) { this.quantity = quantity; }
}
static void change(Order order, int count) {
order.quantity = 2; // 沿复制的引用修改共同对象
order = new Order(99); // 只重绑方法内的参数
count = 99; // 只修改方法内的数值副本
}
// 调用处:
Order order = new Order(1);
int count = 1;
change(order, count);
// order.quantity == 2,count == 1;调用方的order没有被重绑。据此排查“方法改了参数为何没有生效”:先分清修改的是对象字段还是参数槽位;希望返回新对象时显式 return,由调用方接收。不要用“Java 对对象按引用传递”解释共同对象的突变,它会错误预测重绑也影响调用方。final Order order 只禁止重绑该变量,不禁止 order.quantity = 2;对象不可变需要另一套约束。
引用向上转型不复制对象;向下转型只是运行时类型检查,不会把对象改造成目标类型。若值为不兼容类型,抛 ClassCastException;对 null 转型仍为 null。重载选择依赖编译期类型,覆写方法的调用根据实际接收者分派;后文桥方法正是在擦除后维持这个覆写关系。
来源:JLS 17 §4.1–4.3、§4.12、§15.12.4.5 参数与新栈帧。字符编码与完整 Unicode 输入另由编码/I/O课维护,不以本文简述宣称已验收。
数值转换、装箱与错误输入
| 场景 | 可运行输入及结果 | 修复与成立条件 |
|---|---|---|
| 运算先于拓宽 | int n = Integer.MAX_VALUE; (long)(n + 1) 为 -2147483648L;(long)n + 1 为 2147483648L | 在运算前拓宽;整数溢出通常不抛异常,需要拒绝时用 Math.addExact 等 |
| 显式窄化 | (byte)130 为 -126 | 保留低位不是范围校验;先验证 -128..127,禁止默默截断业务计数 |
| 数值拓宽也有精度边界 | long 到 float/double 可能丢低位;int 到 float 也可能舍入 | 金额优先明确最小单位的整数或 BigDecimal,范围与舍入策略属于接口契约 |
| 自动拆箱 | Integer n = null; int x = n; 抛 NullPointerException | 入口拒绝缺失值,或显式选定默认值;默认 0 不能混同“未提供” |
| 包装类型比较 | 两个 Integer 引用的 == 比身份,发生拆箱的混合表达式按数值比较 | 不用缓存范围推导业务相等;使用明确的数值比较或 Objects.equals(后者不跨包装类型归一) |
| 浮点特殊值 | Double.NaN == Double.NaN 为 false,0.0 == -0.0 为 true | 不用普通相等判断 NaN;包装 Double.equals 的 NaN/正负零规则不同,接口要固定口径 |
整数运算会先做数值提升,例如两个 byte 相加通常得到 int;复合赋值含隐式窄化,byte b=127; b+=1 可编译但变成 -128。parseInt 对非数字/越界字符串抛 NumberFormatException,不是“帮忙截断”。输入校验与业务可重试错误要分开,否则非法请求会被反复重试。
TypeProbe 实际检验了 int 溢出、先后拓宽、窄化、addExact 拒绝、null 拆箱;浮点和复合赋值是规范推导,不冒称本批逐项实跑。来源:JLS 17 §5.1 转换、§15.21 相等操作、Math.addExact。
问题:同一份类型,为什么在三个位置看到三种形态
写下 List<String> names 时,源码层看到的是“只能放字符串的列表”;编译后方法描述符里通常只剩 Ljava/util/List;;读取元素时又可能出现一个 checkcast java/lang/String。同一份类型在源码、class 文件和运行时分派中呈现不同形态,如果混在一起解释,就会得到“泛型运行时完全不存在”或“擦除后什么都保留”这两种都不准确的说法。
先看一个最小例子:
class Parent<T> { T value() { return null; } }
class Child extends Parent<String> {
@Override String value() { return "typed"; }
}
Parent<String> p = new Child();
String s = p.value();源码层有三条契约:Parent<T> 的 value() 返回 T;Child 覆写后返回 String;p.value() 的静态类型是 String,因此不需要显式 cast。到了 class 文件,父类入口擦除为返回 Object,子类却仍有返回 String 的实现;运行时调用还必须在某个位置完成“父入口 → 子实现”的衔接。桥方法与读取端 checkcast 就是为这三层分别收尾的产物。
因此,本主题始终按三层阅读:
| 层 | 回答的问题 | 典型证据 |
|---|---|---|
| 语言契约 | 编译器承诺检查什么、拒绝什么 | JLS 规则、编译错误、类型推导 |
| 编译产物 | 擦除后留下什么、额外生成了什么 | javap -c -p、Signature 属性、桥方法标志 |
| 运行边界 | 分派、cast、反射与序列化实际看到什么 | 运行输出、ClassCastException 的位置、框架行为 |
语言契约:编译期保证与拒绝
泛型是不变的:List<Integer> 不是 List<Number> 的子类型。否则一个接收 List<Number> 的方法就能把 Double 放进原本只允许整数的列表。需要放宽读写方向时,用通配符把“能读什么”和“能写什么”分开:
| 声明 | 可以安全读出 | 可以安全写入 | 不能做什么 |
|---|---|---|---|
List<? extends Number> | Number 或其子类 | 只能写 null | 不能 add(Integer),因为实际元素类型可能是 Double |
List<? super Integer> | 只能按 Object 读 | Integer 及其子类 | 不能假定读出的是 Number |
List<Number> | Number | Number 及其子类 | 不能把 List<Integer> 传进来 |
List<?> | 只能按 Object 读 | 只能写 null | 不能假定元素类型 |
PECS(Producer Extends, Consumer Super)是使用经验,不是完整签名设计规则。若容器同时读写,或者所有权、可变性另有约束,仍要回到具体方法与不变量,不能只贴一个通配符。
数组走的是另一条路:String[] 是 Object[] 的子类型,数组在运行时保留元素类型,因此下面的写入会通过编译,却在运行时失败:
Object[] objects = new String[1];
objects[0] = Integer.valueOf(1); // ArrayStoreException这里对象仍是 String[],向上转型只改变静态入口,数组保留的元素类型在写入时检查。与此不同,(String)(Object)42 会在类型转换时抛 ClassCastException;(String)null 仍是 null,不抛转换异常,但后续解引用会抛 NPE。排障要定位失败发生在写数组、转型还是解引用,修正的是源数据/入口类型,而不是把所有失败统一catch后返回null。
这和泛型擦除不是同一机制。泛型的类型参数大多不可具体化(non-reifiable),所以不能创建 new List<String>[1]。对于静态类型为 Object 的输入,value instanceof List<String> 会被拒绝,value instanceof List<?> 可用,但后者不检查元素都是字符串。Java 16 起不能再笼统说“instanceof 后面只能是可具体化类型”:在不需要 unchecked 转换的条件下,如 List<Integer> x = new ArrayList<>(); x instanceof ArrayList<Integer>,JDK17 可以编译。这里运行时仍只检查实际类,不逐项检查 Integer。run.sh types 用真实 javac 同时验证这两个相反输入。来源:JLS 17 §15.20.2。
不能在无工厂/元数据时 new T()、new T[],也不能让类的静态字段使用该类实例的类型变量 T。异常对象不得是泛型 Throwable 子类,catch(T e) 也不成立;泛型方法可以在声明 T extends Throwable 时 throws T,不能将这些不同限制归成“泛型任何位置都不能用于异常”。
完整的泛型使用例子如下;公开实验真实编译运行,随后分别编译六个必须失败的片段:不变性错误、向 extends 容器写值、Object 到 List<String> 的 instanceof、创建具体参数数组、漏声明受检异常、TWR引用已重新赋值的局部变量。
static <T> void copy(List<? extends T> src, List<? super T> dst) {
for (T value : src) dst.add(value);
}
List<Integer> src = List.of(1, 2);
List<Number> dst = new ArrayList<>();
copy(src, dst); // dst 为 [1, 2];需要dst可写,否则add会抛UnsupportedOperationException。类型正确不承诺容器可变,也不提供线程安全或复合操作原子性。泛型 varargs 会把不可具体化元素放入数组,写入不兼容引用可能产生 heap pollution;@SafeVarargs 是作者对实现安全性的承诺,不是运行时检查。raw/unchecked 的外部边界应立即逐元素验证并复制到类型化容器;不要在整条调用链上压制警告。相关可变参数规则见JLS 17 §4.12.2和§8.4.1。
raw type(如 List)是为旧代码互操作保留的入口。使用 raw 类型会绕过部分泛型检查,编译器给出 unchecked 警告;错误不会在写入时消失,而是被推迟到某个读取端的 cast。后文用实验展示这条延迟链。
擦除:保留和丢失的边界
类型擦除(erasure)把类型参数替换为边界:无界 T 换成 Object,T extends Number 换成 Number;参数化类型换成 raw 类型;方法描述符按擦除后的类型生成。于是 List<String> 和 List<Integer> 在运行时通常都是 java.util.List 的实例,Class 对象也没有元素类型。
但“擦除”不等于“所有泛型信息都消失”:
- class 文件可以保留
Signature属性,记录字段、方法、父类的泛型签名; - 反射可以读取
getGenericSuperclass()、getGenericReturnType()等信息; - 注解、编译器生成的桥方法和局部变量表也可能留下痕迹。
这些信息描述的是声明,不是某个对象的实际类型参数。一个 List 实例在运行时不会告诉你它曾被声明为 List<String>;框架若需要元素类型,通常要靠 TypeReference/TypeToken、方法签名、字段声明或额外元数据来携带。
List<String> a = new ArrayList<>();
List<Integer> b = new ArrayList<>();
System.out.println(a.getClass() == b.getClass()); // true擦除还解释了为什么泛型不能参与某些运行时判断:两个方法若只在类型参数上不同,擦除后签名可能相同;泛型异常类不能按类型参数区分;跨类加载器时,即使全名相同,Class 对象也可能不同,反射与序列化会因此产生额外边界。
图解:从源码类型到运行检查
看图时核对三件事:父类入口擦除成什么;子类实际实现与桥方法各自的方法描述符;调用方在什么位置插入 checkcast。图中不把 Signature 属性画成“运行时类型”,因为声明信息和实例类型是两回事。
原码:javac 何时生成桥
桥方法不是 JVM 自动变出来的,而是 javac 在生成 class 文件前补上的 synthetic 方法。先看它如何构造桥的方法符号。
原码连续摘录(仅统一公共缩进,截取方法局部):TransTypes.java,jdk-17.0.12+7,L243–L252。
Type implTypeErasure = erasure(impl.type);
// Create a bridge method symbol and a bridge definition without a body.
Type bridgeType = meth.erasure(types);
long flags = impl.flags() & AccessFlags | SYNTHETIC | BRIDGE |
(origin.isInterface() ? DEFAULT : 0);
MethodSymbol bridge = new MethodSymbol(flags,
meth.name,
bridgeType,
origin);这段只建立“方法符号 + 标志”,还没有方法体。bridgeType 取被覆写入口的擦除类型,标志里加上 SYNTHETIC 与 BRIDGE;后面才生成转调具体实现的代码。implTypeErasure 在当前片段中没有参与构造,说明读源码时不能把“计算了某个类型”误当成“最终使用了它”。
真正决定“要不要生成”的是另一段条件。
原码连续摘录(仅统一公共缩进,截取方法局部):TransTypes.java,jdk-17.0.12+7,L365–L380。
private boolean isBridgeNeeded(MethodSymbol method,
MethodSymbol impl,
Type dest) {
if (impl != method) {
// If either method or impl have different erasures as
// members of dest, a bridge is needed.
Type method_erasure = method.erasure(types);
if (!isSameMemberWhenErased(dest, method, method_erasure))
return true;
Type impl_erasure = impl.erasure(types);
if (!isSameMemberWhenErased(dest, impl, impl_erasure))
return true;
/* Bottom line: A bridge is needed if the erasure of the implementation
is different from that of the method that it overrides.
*/判断依据是“实现与被覆写方法在目标类型中擦除后是否仍是同一个成员”。桥只在擦除签名出现差异时生成,不是“每个泛型方法都生成一个桥”。addBridgeIfNeeded 还会排除私有、静态、已经 synthetic 的方法,并检查方法是否属于当前类;抽象方法、接口默认方法等场景各有条件。
这说明两点:
- 语言契约与生成策略分开。 源码里的覆写关系由语言规则决定;桥是编译器为保持二进制分派补的实现细节。
- 版本要绑定。 方法名、标志和条件属于 javac 实现,换编译器(如 ECJ)或换 JDK 版本时应重新核对,不能把这段源码当成 JLS 本身。
字节码现象:桥接和读取端检查
labs/java-senior/run.sh types 在编译 TypeProbe 后,对 TypeProbe$Child 执行 javap -c -p。本机实际输出(OpenJDK 17.0.12):
Compiled from "TypeProbe.java"
class TypeProbe$Child extends TypeProbe$Parent<java.lang.String> {
TypeProbe$Child();
Code:
0: aload_0
1: invokespecial #1 // Method TypeProbe$Parent."<init>":()V
4: return
java.lang.String value();
Code:
0: ldc #7 // String typed
2: areturn
java.lang.Object value();
Code:
0: aload_0
1: invokevirtual #9 // Method value:()Ljava/lang/String;
4: areturn
}javap 的普通输出不显示方法标志,但两个同名 value() 的描述符不同:一个返回 String,一个是返回 Object 的 bridge。桥的方法体只有 aload_0 和 invokevirtual value:()Ljava/lang/String;,它把调用转给真实实现。父类 Parent.value() 擦除后也是 ()Object,所以 Parent<String> p = new Child(); p.value() 会先按父签名分派到桥,再由桥回到 String 版本。调用方若把结果赋给 String,编译器还会在调用点插入 checkcast。
TypeProbe 同时验证了桥的数量、record 的浅不可变、suppressed 异常和 raw 类型的延迟失败。实际输出:
注: TypeProbe.java使用了未经检查或不安全的操作。
PASS erased dispatch bridge=1, record is shallow, suppressed exception retained, raw write deferred to caught at read那条 unchecked 警告来自 raw 类型写入,不是运行失败。对应的反例是:
List<String> typed = new ArrayList<>();
List raw = typed;
raw.add(42); // unchecked写入污染同一个列表
String s = typed.get(0); // javac插入checkcast,运行到这里才抛ClassCastException异常发生在读取端的 checkcast,而不是写入端。排障时如果只看到“读取代码抛了 CCE”,还要追到写入来源和中间的 raw/反射/反序列化边界;否则容易把错误归给最后一个无辜的 cast。
这段证据能证明什么、不能证明什么:
- 能证明:在该 JDK 与 javac 版本下,
Child生成了一个 bridge,桥转调String实现;raw 写入的失败延迟到读取端;record的组件引用不冻结嵌套对象;suppressed 异常可被保留。 - 不能证明:任意编译器都生成同样字节码;所有泛型方法都有桥;框架序列化会保留元素类型;并发或跨类加载器场景安全。
equals、hashCode、record 与不可变边界
Object.equals 默认是引用相等;重写 equals 通常必须重写 hashCode,并满足“两个对象 equals 相等,则 hashCode 必须相等”。这不是风格建议,而是哈希容器定位的前提:先用 hash 找桶,再用 equals 确认逻辑键。键参与字段一旦变化,旧桶位置不会自动迁移,get/remove 会按新 hash 去错误的桶里找。集合正文会用 MutableKey 实验展示这条故障链。
equals 应满足自反、对称、传递、一致以及对 null 返回 false;比较字段不变时应得到一致结果。继承中若父类只比较 ID,子类又要求颜色一致,就可能让 base.equals(child) 与 child.equals(base) 不一致。给值类型封闭相等范围(例如 final 类或 record),或用组合表达额外属性;不能只在某一方向补一个 instanceof。哈希冲突允许存在,“hashCode 相同”不能反推 equals。
record 的组件字段隐式为 private final,并生成访问器,不保证深不可变:
record Box(List<String> names) {}
var list = new ArrayList<String>();
var box = new Box(list);
list.add("changed"); // box.names() 能看到变化TypeProbe 断言 box.names().size()==1,证明“组件引用 final”不等于“内部列表不可变”。真正不可变的对象需要防御性复制,并约束嵌套数据的生命周期;List.copyOf 能复制一层,但元素本身若可变,仍需继续处理。
完整的一层防御复制:
record SafeBox(List<String> names) {
SafeBox { names = List.copyOf(names); }
}
var input = new ArrayList<>(List.of("a"));
var safe = new SafeBox(input);
input.add("b"); // safe.names()仍为[a]
// safe.names().add("c") 抛 UnsupportedOperationException。List.copyOf 拒绝 null 容器/元素,返回不允许增删替换的列表;对于已有合适不可变列表可以复用,不承诺总是分配新实例。元素是不可变 String 时,这个边界足够;元素若是 StringBuilder,修改元素仍可被观察到。Collections.unmodifiableList(input) 则是原列表的不可修改视图,持有原引用的写者仍能改变视图内容。入口复制、只读视图、移交独占所有权是不同方案,不能把接口“不允许调用 add”误当没有别名。
缓存键应使用稳定身份与版本,不要把可变业务实体直接作为长期键。Map 的键变异时行为不能依赖;HashMap 的桶查找机制由集合正文维护。BigDecimal("2.0") 与 BigDecimal("2.00") 的 equals 为 false、compareTo 为 0:同样两项进入 HashSet 得到 2 项、自然排序 TreeSet 得到 1 项,公开实验已检验。金额协议要固定 scale/舍入和相等口径,例如按业务最小单位存整数,或在入口用确定的 scale 与 RoundingMode.UNNECESSARY 拒绝多余精度;不要事后修改已作为键的身份。这里不以两种集合差异推导哪一种总是正确。
来源:Object.equals/hashCode、Record、List.copyOf、BigDecimal.equals。
异常、资源与事务:语言分类不等于业务结果
Throwable 分为 Error 和 Exception 等继承分支。RuntimeException 与 Error 及其子类是非受检异常,编译器不要求捕获或声明;其余受检异常必须在适用位置捕获或用 throws 声明。throws 是方法协议,不表示方法必然失败;非受检异常也不是“都可以忽略”。例如输入计数非法可拒绝并修正输入,OOM 等 Error 不应被通用重试器反复捕获重试。
以下是 TypeProbe 中实际执行的完整读取方法;它只读自建临时目录内的合成输入,不接触用户文件。正常输入为 3,缺失文件、非数字 wrong、非法数量 0 分别走不同错误路径:
static class OrderInputException extends Exception {
OrderInputException(String message, Throwable cause) { super(message, cause); }
}
static int readQuantity(java.nio.file.Path path)
throws java.io.IOException, OrderInputException {
String raw = java.nio.file.Files.readString(path).trim();
final int quantity;
try { quantity = Integer.parseInt(raw); }
catch (NumberFormatException cause) {
throw new OrderInputException("quantity must be integer", cause);
}
if (quantity < 1) throw new IllegalArgumentException("quantity must be positive");
return quantity;
}调用方必须处理/声明 IOException 与 OrderInputException;可在用户交互边界将前者映射为读取失败、后者映射为可修正输入,内部日志保留 cause,外部不泄露磁盘路径/栈。实验依次尝试不存在文件、写 wrong、写 0,再改成 3 复验,最后定向删除自己的临时文件和目录。不在 catch 里伪造成功数量或盲目重试未改变的错误输入。多重 catch 按子类在父类前放置,catch(Exception) 放前面会使后续子类处理不可达;catch(IOException | OrderInputException e) 可合并同处理逻辑,但不能并列有父子关系的类型。
受检与非受检是语言/类型分类;数据库事务是否回滚由框架规则、代理边界和异常传播决定。三者不能互相推断:
catch住异常不会自动撤销已经发生的外部效果(消息已发、库存已扣、远程调用已成功)。finally中抛出的新异常可能掩盖原异常;应保留 cause。- try-with-resources 按确定顺序关闭资源,关闭异常可作为 suppressed 附加到主异常;排障时要同时看 cause 与 suppressed。
- 捕获
InterruptedException后若吞掉中断,可能丢失协作取消信号;要么继续抛出,要么恢复中断标记并明确策略。Future/CompletableFuture 的包装、超时与副作用以异步正文为权威,U09复核,不将其算成本票新实验。
来源:JLS 17 第11章异常检查、Files.readString。反射 InvocationTargetException 与代理 UndeclaredThrowableException 的解包见反射正文,不把 cause 和 suppressed 混作同一维度。
事务回滚要核对:异常是否穿过了代理边界、是否被 catch 吞掉、框架是否只对特定异常回滚、远程调用是否已提交。语言层的 try/catch 不是分布式事务协议。
try-with-resources:关闭责任、主异常与恢复
资源必须实现 AutoCloseable(或子接口如 Closeable)。按声明顺序初始化,反向关闭成功初始化且非 null 的资源;第二个构造失败时,第一个会关闭,第二个并没有成功交给 TWR 管理。构造器在抛异常前已申请的内部资源仍需构造器自行清理。AutoCloseable.close 不普遍保证幂等,而 Closeable 的契约不同;不要无意手动 close 后再由 TWR 重复关闭。
在 try(Resource r = open()) 中声明的资源变量隐式为 final;Java9起允许已有 final 或 effectively final(初始化后未重新赋值)变量用作资源;下面使用本票JDK17支持的 var reader = new StringReader("x"); try(reader) { ... },不表示Java9已有var。把 reader 先重新赋值再放进 try(reader) 会编译失败。公开实验实际编译运行了前者,并以同一编译器拒绝后者。close 发生在对应 try 作用域退出时,不等于声明变量那一刻或等到GC时发生。
下面是实验实际使用的可编译合成资源,刻意抛关闭异常以观察真实 Java 语言协议。它不是数据库/网络 mock,不证明远程服务已回滚或文件持久化成功:
static class Resource implements AutoCloseable {
final String name;
final StringBuilder log;
final boolean fail;
Resource(String name, StringBuilder log, boolean fail) {
this.name = name; this.log = log; this.fail = fail;
log.append("open-").append(name).append(';');
}
public void close() throws java.io.IOException {
log.append("close-").append(name).append(';');
if (fail) throw new java.io.IOException("close-" + name);
}
}
static void failingBody(StringBuilder log) throws java.io.IOException {
try (Resource first = new Resource("first", log, true);
Resource second = new Resource("second", log, true)) {
log.append("body;");
throw new java.io.IOException("body");
}
}| 明确输入/时序 | 调用方观察 | 修复与复验责任 |
|---|---|---|
上述 body 抛 body,两个 close 都失败 | 主异常 body;suppressed 按 second、first 顺序;日志 open-first;open-second;body;close-second;close-first; | 查 body 根因,也记录两个关闭失败;修正后用新资源重跑,不复用半关闭对象 |
| body 正常,两个 close 都失败 | close-second 成为主异常,close-first 进入其 suppressed | 不能因正文逻辑完成就上报整个操作成功;是否产生副作用须另外核对 |
second 初始化抛 open-second,first 的 close 也失败 | body 不运行;open-second 为主异常,只有 close-first 被 suppressed | first 已交给 TWR清理;second 构造器负责自身半初始化资源 |
| 资源为 null,另一资源正常 | null 不执行 close;另一资源正常反向关闭 | 缺失资源是否合法由业务先校验,TWR 不替业务补资源 |
| body 抛 A,普通 finally 又抛 B | B 覆盖 A,A 不会自动成为 cause/suppressed | 优先 TWR;必须手写时明确保留原异常,禁止 finally return/throw 吞掉失败 |
编译器按 JLS 17 §14.20.3 的翻译规则将 TWR 转成带主异常保存、关闭及 addSuppressed 的 try/catch/finally 控制流;上述实验验证这个控制流,不只是手工 addSuppressed。cause 表示一次异常包装的因果链,suppressed 表示传播主异常期间伴随清理的其他失败;诊断需遍历二者,不只打印 getMessage()。
对象可达性/GC 不等于及时关闭文件、连接或事务。谁创建且拥有资源,通常谁在明确作用域关闭;借用调用方传入的流是否关闭必须写在接口协议里,不能擅自把 System.out 或共享连接放入 TWR。try 结束后资源变量的 Java 作用域与底层资源是否仍可用也要分开,返回已经关闭的流是典型所有权错误。来源:AutoCloseable、Closeable、Throwable。
设计案例:副本成本与资源上界
假设一个合成批量查询接口每秒 2000 次请求,每次最多 200 个不可变字符串 ID。方案 A 对调用方输入 List.copyOf:最多复制 40 万个引用/秒;若按每引用 8 字节估算是 3.2 MB/秒引用载荷,不含对象头、不复制字符串、不是本机实测布局/吞吐。换成4字节引用或列表复用时这个估算改变。方案 B 提供不可修改视图:减少复制,但原列表写者仍可改内容;必须额外约定独占所有权/同步,调用方违约时需要明确责任。保留快照的 A 更容易保证一次请求中的 ID 集合不变;B 只适合已控制生命周期且有验证的内部路径,不能把省掉的复制当免费并发安全。
再假设每请求同时持有两份文件资源,平均持有 200 ms,稳定到达时用平均值估算并发占用为 2000/s × 0.2s × 2 = 800 句柄。若进程句柄预算 1024、其他用途预留 256,只剩 768,已不满足平均需求,更不用说尾延迟。可选方案是串行打开/关闭两个资源(减少同时持有量但可能增加耗时),或设置不超过预算的接纳上限并排队/拒绝(增加等待、需要排队超时与恢复责任)。没有真实负载验证时不能宣称满足 SLO;实测要收集峰值打开数、持有时间分布、拒绝率及异常后是否回落到基线。TWR 解决各条路径的释放,不能替代容量接纳。
两类评审都围绕不变量:输入快照在本请求内不变,且不泄漏自有资源。接口所有者负责边界校验/关闭契约;调用方负责不复用已移交或关闭的对象。用 TypeProbe 的输入修正、TWR 初始化/关闭失败复验基本语义;吞吐、堆布局、OS句柄指标属于后续真实负载验证,不拿局部 PASS 当容量证明。
计数先做非空/范围校验,容量乘法用足够宽的类型或 multiplyExact,否则溢出可能让接纳阈值变负。缓存若按“数值相等的金额”去重,必须在入缓存前统一金额等价关系,不能一条路径用 equals、一条用 compareTo。SDK 的 Page<T> 让调用者获得编译期约束,传入 Class<T>/转换函数则能增加运行时边界验证但要承担验证成本;不得以擦除为理由跳过外部输入逐元素校验。发布含泛型覆写的新版 SDK 时,同时保留旧编译调用方做二进制冒烟,避免只验证新源码可编译、遗漏桥入口变化。以上都影响同一个每请求200项的边界,并非用容量算例替代具体类型规则。
条件变化与边界
以下结论需要随条件复核:
| 条件变化 | 需要复核的点 |
|---|---|
| 换 JDK 版本 | 桥生成条件、Signature 保留方式、实验输出;语言契约仍以对应 JLS 为准 |
| 换编译器(javac / ECJ / 其他) | 桥的方法体、synthetic 标志、局部变量表可能不同 |
| 使用 GraalVM Native Image / AOT | 反射与 Signature 的可用性受配置和闭包分析影响 |
| 跨类加载器 | 相同全名可能对应不同 Class,instanceof、反射和序列化都会受影响 |
| 经过序列化/反序列化 | 运行时元素类型不会自动恢复,需 schema 或类型元数据 |
| 动态代理 / 字节码增强 | 生成类可能新增桥、转发方法或改变异常包装 |
稳定的部分是语言契约:泛型不变性、通配符读写方向、擦除后的成员签名规则、equals/hashCode 契约。容易变化的是编译器如何实现这些规则,以及某个框架在边界上做了什么。
实验:输入、步骤、预期与边界
实验目录:labs/java-senior(TypeProbe.java,用 run.sh types 运行)。
cd <仓库根>
JAVA_HOME=$(/usr/libexec/java_home -v 17) \
bash labs/java-senior/run.sh types输入与预期:
| 场景 | 输入 | 预期 |
|---|---|---|
| 桥接分派 | Parent<String> p = new Child() | p.value() 返回 "typed";Child 有 1 个 bridge |
| record 浅不可变 | record Box(List<String>),插入后修改原列表 | box.names() 能看到修改 |
| raw 延迟失败 | List raw 写入 Integer,按 String 读取 | 读取端抛 ClassCastException |
| suppressed | 主异常附加 cleanup 异常 | getSuppressed().length == 1 |
| 字节码 | javap -c -p TypeProbe$Child | 同时出现 String value() 与转调它的 Object value() |
| 值与引用 | 原对象quantity=1;方法突变为2再局部重绑99 | 调用方对象quantity=2、调用方count=1 |
| 数值/拆箱/数组错误 | max-int+1、窄化130、null拆箱、String[]写Integer | 溢出结果/正确先拓宽;预期异常被识别;入口退出0 |
| 相等与复制 | 两个Key("order-1");金额2.0/2.00;原列表修改 | 值相等/同hash;HashSet2项/TreeSet1项;SafeBox保持[a] |
| TWR三类失败 | body失败、close-only失败、第二次初始化失败 | 主异常及suppressed顺序与上表一致,恢复新资源正常关闭 |
| 真实文件错误/修正 | 自有目录内缺文件、wrong、0、3 | IO/checked cause/非法数量被区分,修正为3成功,定向清理 |
| 真实编译边界 | PECS、合法typed-instanceof、effectively-final资源 | 正例编译运行0;6个独立反例javac非0,外层识别预期失败后0 |
U04 在 Liberica17.0.19 上通过公开 types 入口实际运行以上新增场景,完整命令、stdout/stderr、退出状态见本批证据。javac 对故意的 raw 写入保留 unchecked 警告;6个失败片段的诊断不是构建事故。入口只接受 all/locks/pool/async/algorithms/types/reflect;未知模式、参数过多、缺失JDK工具或非JDK17均在创建编译目录前退出2。设置JAVA_HOME选择本机已安装JDK17,macOS未设置时由java_home查找,其他平台显式设置。
编译使用唯一临时目录,shell EXIT/INT/TERM 定向清理;强制 SIGKILL 无法执行 trap,需按该次唯一目录定向清理,不能批量删除别人的目录。探针只处理固定小输入,不创建无限线程/网络请求;本批外层验证为每次运行设置时限。原图、原javac固定源码、旧17.0.12输出保留;其他编译器/JDK、框架、跨类加载器、并发和长期资源压力未运行,不能从本地 PASS 推出生产正确性。新反射/注解/代理入口为 run.sh reflect,详见反射正文。
追问链
第一层:为什么 Java 选择擦除,而不是具体化泛型?
历史兼容是主因:泛型引入时要让旧 class 文件、旧库和未泛化的调用继续工作,擦除能让参数化类型落到既有的 raw 类型和调用协议上。代价是运行时看不到元素类型,需要桥、cast 和额外元数据来补。具体化泛型(如 C# 的运行时泛型)能保留更多信息,但会改变 class 文件、反射和互操作模型,不是同一个权衡。
第二层:桥方法为什么不能由 JVM 自动完成?
JVM 的方法分派按名称和描述符匹配。父类入口已经是 value()Object,子类只有 value()String 时,两者描述符不同,不会被视为同一个覆写入口。编译器必须显式生成一个 value()Object 的 bridge,把调用转给 value()String。这是二进制兼容问题,不是语法糖。
第三层:如果框架需要元素类型,应该怎样携带?
可以在 API 签名、字段声明、TypeReference/TypeToken、序列化 schema 或配置元数据中携带;也可以要求调用方显式传入 Class<T>。但任何方案都要说明信任边界:声明可被伪造,反序列化仍要校验字段、大小和嵌套类型。不能因为拿到了 Signature 就认为远端 JSON 一定符合。
第四层:评审一个泛型 API 时,按什么顺序提问?
- 这个类型参数在运行时是否需要被检查?如果需要,元数据从哪里来,谁负责校验?
- 方法签名是否把读写方向说清楚?
List<? extends T>的返回值会不会被调用方误写? - 是否使用了 raw 类型或 unchecked 转换?转换点是否有边界校验,异常会不会延迟到业务深处?
- 桥方法/擦除差异是否影响二进制兼容?发布 SDK 时是否验证过旧调用方?
- 异常与事务边界是否一致?
catch之后是否还有未撤销的副作用?
这些问题不产生“类型绝对安全”的结论,但能把语言契约、编译产物和运行边界分开审查。
技术案例
- 公共 SDK 的分页返回:用
Page<T>表达元素类型,但不要把T当运行时类型做instanceof;需要过滤/转换时由调用方传入函数或Class<T>,并在边界处校验。 - 错误协议:区分业务错误、传输错误和编程错误;保留 cause 与 suppressed,不把
catch当成回滚,不把异常消息直接透给外部。 - 缓存键:用稳定 ID + 版本,不用可变实体;
record作为键时确认所有组件都不可变,必要时做防御复制。 - 序列化边界:反序列化得到的
List不会自动带回List<String>的元素类型;在入口做 schema 校验、大小限制和类型转换,别把 CCE 留给业务深处。
来源与版本
| 来源 | 版本 | 用途 |
|---|---|---|
| Java Language Specification, SE 17 | Java SE 17 | 泛型、擦除、通配符、异常与类型规则 |
| OpenJDK 17u TransTypes.java | jdk-17.0.12+7 | 桥方法生成条件与标志(L243–L252、L365–L380) |
| Java SE 17 Object API | Java SE 17 | equals/hashCode 契约 |
| Java SE 17 Record | Java SE 17 | record 的浅不可变与组件访问器 |
本地 TypeProbe | 历史OpenJDK17.0.12;本批Liberica17.0.19+11 | 桥/浅不可变/raw旧证据;U04值/转换/相等/复制/TWR与文件输入新实测 |
原访问日期2026-10-22;U04复核日期2026-10-24。源码行号以 jdk-17.0.12+7 标签为准,历史与本批运行独立。若升级JDK或更换编译器,重新核对桥生成条件与实验输出;21/25 的语言演进(sealed/模式匹配/record 模式及各预览状态)已由 U12 在现代 Java 语言演进正文按版本边界讲清,本文的 17 基线结论与其互链不冲突。
闭卷验收
本页 19 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。