类文件、字节码与类加载
javac 编译一个类后,方法体变成指令,字段和方法的类型变成描述符,对其他类型的访问变成符号引用。这些内容共同组成 .class。JVM 读取它们,建立运行时类型,在方法调用时执行相应计算。
BytecodeProbe.java
│ javac
▼
BytecodeProbe.class
├─ 类型结构:名称、父类、接口、字段和方法
├─ 常量池:数值、字符串及类型/成员的符号信息
├─ 方法 Code:指令、栈容量、局部变量容量、异常表
└─ 属性:调试信息、泛型、注解、动态调用引导信息等
│ 加载、链接与必要的初始化
▼
运行时类型与方法执行一个 Java 源文件可以生成多份 class,例如外部类与嵌套类各有自己的二进制表示。JAR 再把这些 class 与资源打包。运行时生成的类也可以直接以字节数组交给 JVM,无需先保存成磁盘文件。
class 文件保存了哪些内容
头部、常量池与成员表
JVMS 的 class 格式使用按顺序排列的二进制项,多字节数值采用大端序。u1、u2、u4 分别表示 1、2、4 字节的无符号量。结构中的数量字段告诉解析器随后要读取多少项;常量池条目长度随类型变化,编号不能直接当文件偏移。
ClassFile
├─ magic 0xCAFEBABE
├─ minor_version / major_version 格式版本
├─ constant_pool_count
├─ constant_pool 常量池,条目用 #编号引用
├─ access_flags public、final、interface 等标志
├─ this_class / super_class 指向常量池中的类型名称
├─ interfaces 直接实现/继承的接口
├─ fields 名称、descriptor、标志、字段属性
├─ methods 名称、descriptor、标志、方法属性
└─ attributes 当前类型的属性Java 17 的普通 class 主版本是 61,Java 25 是 69。编译器与输出版本可以不同:JDK 25 使用 --release 17 仍会生成主版本 61,并限制可使用的语言特性与标准 API。Preview class 还有特殊的次版本和运行限制,不能仅比较主版本大小;版本组合见 Java 平台、JDK 与工程版本基线。
常量池既保存字面量,也保存解析程序所需的符号结构:
| 常见条目 | 保存的内容 | 使用位置 |
|---|---|---|
Integer、Long、Float、Double | 数值 | 常量装载指令等 |
Utf8、String | 名称、descriptor、字符串内容的编码与引用 | 类型/成员命名及字符串常量 |
Class | 类型名称索引 | 类型引用、对象创建、强制转换 |
NameAndType | 成员名称和 descriptor | 被字段/方法引用复用 |
Fieldref、Methodref、InterfaceMethodref | owner 与 NameAndType 的组合 | 字段访问和方法调用 |
MethodHandle、MethodType | 方法句柄及方法类型描述 | 动态调用的引导信息 |
InvokeDynamic、Dynamic | 引导方法索引、名称与类型 | 动态调用点、动态计算常量 |
文件常量池在加载时参与构造每个类型自己的运行时常量池。字符串驻留池则负责共享字符串实例;三者分别是文件结构、运行时符号结构和字符串对象共享机制,观察位置不同。
descriptor 怎样编码类型
字段 descriptor 描述一个字段的类型。方法 descriptor 把参数放在括号内,把返回类型放在括号后:
long invoice(long cents, String currency)
↓
(JLjava/lang/String;)J
│ └─ 第二个参数 String
└─ 第一个参数 long 括号后的 J:返回 long| 编码 | 类型 | 示例 |
|---|---|---|
B、C、D、F | byte、char、double、float | D 是 double 字段 |
I、J、S、Z | int、long、short、boolean | J 是 long,Z 是 boolean |
L名称; | 引用类型 | Ljava/lang/String; |
[ | 一层数组 | [I 是 int[],[[I 是 int[][] |
V | 方法无返回值 | ()V;不能用作字段类型 |
实例方法的 descriptor 不包含隐含的 this。返回类型属于 JVM descriptor;Java 源码却不允许仅靠返回类型重载。这一区别会直接影响库升级:将返回值 long 改为 Long,旧 class 中保存的 descriptor 不会自动变化。
List<String> 与 List<Long> 擦除后具有相同的 Ljava/util/List; descriptor。需要反射读取泛型参数时,通常再查 Signature 属性;桥接方法如何适配擦除后的调用见 Java 泛型与公共 API。
Code 与其他属性各自保存什么
普通 Java 方法的 Code 包含指令字节、操作数栈最大深度 max_stack、局部变量槽容量 max_locals、异常处理表和嵌套属性。abstract、native 方法没有 Java 字节码方法体,也没有这样的 Code。
| 属性或结构 | 作用 | 对调试与框架的影响 |
|---|---|---|
Code.exception_table | 哪段指令由哪个异常处理器接管 | 决定 catch 的跳转位置 |
StackMapTable | 部分指令位置的验证类型状态 | 字节码增强改变控制流后需要保持一致 |
LineNumberTable | 指令位置与源码行号的映射 | 影响异常栈的行号显示 |
LocalVariableTable | 局部变量的名称、作用区间与槽位 | 用于调试;运行时槽的存在不依赖名称表 |
MethodParameters | 方法参数名及标志 | 可由 javac -parameters 生成,供反射使用 |
Signature、注解属性 | 泛型与注解元数据 | 反射框架按对应 API 读取 |
ConstantValue | 特定静态字段的常量初值 | 与编译期常量内联和类初始化有关 |
BootstrapMethods | 动态调用/常量的引导方法信息 | 连接 lambda、字符串拼接等调用点 |
StackMapTable 参与类型验证,不能与可删除的调试名称一概而论。方法上的 Exceptions 属性记录声明的 throws,也不是执行 catch 的异常表。注解与反射需要哪些元数据,见 Java 注解、反射与运行时元数据。
用 javap 观察一次方法执行
准备源码与完整 JDK
下载 完整实验包,解压后进入 classfile-linkage 目录。实验不需要 Maven、网络服务或第三方依赖,包含以下源码:
classfile-linkage/
├─ bytecode/example/bytecode/BytecodeProbe.java
├─ api-v1/example/api/Pricing.java
├─ api-v2-bad/example/api/Pricing.java
├─ api-v2-fixed/example/api/Pricing.java
├─ client/example/client/OrderClient.java
├─ init/example/init/InitializationProbe.java
└─ run.sh下面命令在 Linux Bash 中以普通用户执行。使用完整 Temurin JDK 25,JAVA_HOME 指向实际安装目录;需要安装时按前述版本基线篇准备。实验已在 Temurin 25.0.4+7 与 17.0.20+8 上复测,输出统一以 --release 17 编译的 class 为例。
export JAVA_HOME=/opt/jdk-25
test -x "$JAVA_HOME/bin/javac" && test -x "$JAVA_HOME/bin/javap"
unset JAVA_TOOL_OPTIONS JDK_JAVA_OPTIONS _JAVA_OPTIONS
"$JAVA_HOME/bin/java" -version
"$JAVA_HOME/bin/javac" -version
export LAB_OUT="$(mktemp -d /tmp/classfile-learning.XXXXXX)"先确认两个版本输出对应同一 JDK;工具不存在时修正 JAVA_HOME。三个环境变量在当前实验 Shell 中清除,防止 Agent 或额外 JVM 参数改变结果。LAB_OUT 是本轮唯一的编译目录,后续命令始终沿用它,避免旧 class 混入。
没有本机 JDK 时,可从已经安装 Docker 的 Linux 主机进入容器完成相同命令。宿主用户需要已获授权的 Docker 使用权限;以下容器明确使用宿主 UID/GID,源码只读,编译写入临时内存文件系统。Docker daemon 的身份由主机配置决定。
export JDK_IMAGE=eclipse-temurin:25.0.4_7-jdk
docker pull "$JDK_IMAGE"
docker run --rm -it --network none --read-only \
--user "$(id -u):$(id -g)" --cap-drop ALL \
--security-opt no-new-privileges \
--mount "type=bind,src=$(pwd),dst=/lab,readonly" \
--tmpfs /tmp:rw,nosuid,nodev,size=128m,mode=1777 \
--workdir /lab "$JDK_IMAGE" bash进入后确认 id -u 非零,JAVA_HOME 已由镜像设置,不再执行本机的 /opt/jdk-25 赋值;从上面的工具检查开始。镜像获取失败时,可由联网环境使用 docker save 导出同一可信镜像,校验传输文件后在内网 docker load;不要临时改用来源不明的镜像。容器退出会删除临时编译文件,宿主源码不变。
编译、运行与反汇编
BytecodeProbe.java包含算术、实例字段、lambda 和异常处理。先编译并运行:
"$JAVA_HOME/bin/javac" --release 17 -g -Xlint:all -Werror \
-d "$LAB_OUT/bytecode" bytecode/example/bytecode/BytecodeProbe.java
"$JAVA_HOME/bin/java" -cp "$LAB_OUT/bytecode" example.bytecode.BytecodeProbe预期输出为:
total=43
adjust=42
operator=42
divide=-1-d 指定 class 根目录,-cp 在运行时以该目录查找 example/bytecode/BytecodeProbe.class。-g 保留调试信息,-Xlint:all -Werror 使编译警告也能中止实验;javac 手册给出了这些选项及 --release 的约束。
反汇编同一份 class:
"$JAVA_HOME/bin/javap" -classpath "$LAB_OUT/bytecode" \
-p -c -s -v example.bytecode.BytecodeProbejavap的 -p 显示所有成员,-c 显示指令,-s 显示 descriptor,-v 展示常量池、属性和栈容量。反汇编可以准确显示文件里的结构;反编译工具重建的 Java 源码则是另一种表示,局部变量命名与语法形态可能已改变。
局部变量槽与操作数栈
源码中的 total 很短:
static int total(int price, int count) {
int amount = price * count;
return amount + 3;
}实验输出中的对应方法为:
static int total(int, int);
descriptor: (II)I
Code:
stack=2, locals=3, args_size=2
0: iload_0
1: iload_1
2: imul
3: istore_2
4: iload_2
5: iconst_3
6: iadd
7: ireturn每次调用都建立相应的逻辑栈帧。局部变量槽保存参数与局部值,操作数栈承接指令之间的临时计算,帧还关联当前方法所属类型的运行时常量池。JVMS 的栈帧定义描述的是执行语义,JIT 可以把部分值放进寄存器或消除物理栈帧。
调用 total(10, 4) 时,槽 0 是 10,槽 1 是 4。以下栈内容按“左侧栈底、右侧栈顶”书写:
| 指令 | 局部变量变化 | 执行后的操作数栈 |
|---|---|---|
iload_0 | 读取 price | [10] |
iload_1 | 读取 count | [10, 4] |
imul | 无 | [40],弹出两个 int,压入乘积 |
istore_2 | 槽 2 保存 amount=40 | [] |
iload_2 | 读取 amount | [40] |
iconst_3 | 无 | [40, 3] |
iadd | 无 | [43] |
ireturn | 当前调用结束 | 将 43 返回调用方 |
最大深度是 2,局部槽容量是 3。这里的容量按 JVM 槽计算,不能换算成两个或三个固定宽度的机器地址。long、double 在局部变量中占两个连续槽,在操作数栈深度计算中也占两个单位;int 和引用等占一个单位。
实例方法 adjust(int) 的槽 0 则是 this,参数从槽 1 开始。getfield offset:I 从对象引用读取字段,再把字段值放回操作数栈。源码中的对象引用、对象本体和字段值由此分开:栈上传递的是引用或值,对象布局见 运行时数据区与对象布局。
常用指令与控制流
指令偏移是方法 Code 中的字节位置,不是源码行号。带操作数的指令会占多个字节,所以示例的 getfield 后偏移从 2 跳到 5。
| 操作 | 常见指令 | 读取时关注什么 |
|---|---|---|
| 常量与局部值 | iconst_*、bipush、ldc、iload、aload、istore | 值来自指令、常量池还是局部槽 |
| 算术与转换 | iadd、imul、idiv、i2l、checkcast | 数值类型、计算顺序与运行时类型检查 |
| 字段与数组 | getfield、putfield、getstatic、iaload、aastore | 接收者、索引和字段 descriptor |
| 条件与循环 | ifeq、if_icmp*、goto、iinc | 哪个条件跳向哪个指令偏移 |
| 多分支 | tableswitch、lookupswitch | case 键与跳转位置;源码 switch 可能先被转换 |
| 创建与调用 | new、newarray、anewarray、invoke* | 类型、构造过程与调用目标 |
| 返回与抛出 | ireturn、areturn、return、athrow | 正常返回还是异常展开 |
| 同步 | monitorenter、monitorexit | 同步块的进入与退出;同步方法也可由标志表达 |
完整操作数格式和各指令的异常条件可查 JVMS 指令集。i、l、f、d、a 常分别提示 int、long、float、double 和引用操作;byte、short、char 的不少计算按 int 形式执行,不能期待每种源码类型都有一套同名算术指令。
safeDivide(42, 0) 的 -1 来自异常处理器,而非除法指令自身的特殊返回值:
0: iload_0
1: iload_1
2: idiv
3: ireturn
4: astore_2
5: iconst_m1
6: ireturn
Exception table:
from to target type
0 3 4 Class java/lang/ArithmeticException受保护范围是 [0, 3);偏移 2 的 idiv 抛出整数除零异常后,JVM 找到匹配项,清空当前操作数栈,把异常引用压入栈,再跳到偏移 4。astore_2 保存异常,随后返回 -1。找不到匹配处理器时,当前调用异常结束,继续在调用方寻找处理器。
对应 StackMapTable 记录了处理器入口的栈上有一个 ArithmeticException。验证器据此检查不同控制流汇合时的类型是否一致。Agent 插入分支、移动异常区间后若没有正确更新这些信息,即使文件仍能被 javap 展开,加载验证也可能失败。
调用指令怎样找到方法
符号引用、解析与方法选择
BytecodeProbe 的常量池中有下面的引用;编号属于这一次编译,不必与另一套 JDK 完全相同:
#26 = Methodref #8.#27 // example/bytecode/BytecodeProbe.total:(II)I
#27 = NameAndType #28:#29 // total:(II)Iinvokestatic #26 引用这项结构。owner 指明查找方法所依托的类型,name 与 descriptor 指明需要的方法。解析会按规范检查目标及其继承关系、成员访问权限和加载约束;owner 未必是最终声明方法的那个类,不能只在 owner 的源码里搜同名方法就结束。
实例调用还要区分解析和方法选择。以父类类型保存子类对象时,调用方可以引用父类的方法;运行时根据实际接收者选择覆写实现。这样,符号引用可以保持不变,执行的实现却随接收者变化。JVMS 的解析与选择规则分别规定了这两个步骤。
| 指令 | 主要用途 | 探针中的对应位置 |
|---|---|---|
invokestatic | 调用静态方法,不传接收者 | total(10, 4) |
invokevirtual | 类实例方法调用,按适用规则选择实现 | probe.adjust(40)、println |
invokeinterface | 接口实例方法调用 | twice.applyAsInt(21) |
invokespecial | 构造器、父类调用及其他特殊调用语义 | Object.<init>、BytecodeProbe.<init> |
invokedynamic | 使用引导方法链接动态调用点 | lambda 创建、运行时字符串拼接 |
new 与构造器调用也分成两步。探针的 main 开头是:
0: new #8 // BytecodeProbe
3: dup
4: invokespecial #15 // "<init>":()V
7: astore_1new 产生未初始化对象的引用,dup 为构造器调用保留另一份引用,invokespecial 执行实例初始化方法,随后把可用引用保存到局部槽。验证器限制未初始化引用的用途:满足相应初始化规则前,不能任意传递它或调用实例方法。这与构造器自身代码中何时可以使用 this 的规则需要分别理解。实例构造器 <init> 每次构造对象时执行;类初始化方法 <clinit> 则按运行时类型协调,两者职责不同。
lambda 与字符串拼接的动态调用点
探针写下 IntUnaryOperator twice = value -> value * 2 后,反汇编得到一个返回 IntUnaryOperator 的 invokedynamic,同时生成保存计算体的 lambda$main$0(int)。真正执行 twice.applyAsInt(21) 时,再通过 invokeinterface 调用接口方法。
lambda 表达式的调用点
└─ BootstrapMethods → LambdaMetafactory
└─ 实现方法句柄 → lambda$main$0:(I)I
"total=" + 运行时 int
└─ BootstrapMethods → StringConcatFactory
└─ 拼接配方与运行时参数引导方法参与链接调用点,得到后续调用使用的目标。invokedynamic 并不意味着每次都按字符串名称做反射查找;调用点链接方式与目标变化由对应机制定义。以上形态来自这套 javac 的实际输出,换编译器或编译目标后应重新查看 BootstrapMethods,不要假定每个 lambda 都对应一份预先落盘的匿名内部类文件。
动态调用的细节可由 LambdaMetafactory与 StringConcatFactory承接。进入 JIT 后,虚调用还可能被内联或去虚拟化;这些优化必须保留程序语义,见 JIT、逃逸分析与 Code Cache。
加载、链接和初始化怎样衔接
文件检查、验证与静态存储
类加载器寻找二进制表示,JVM 从这些字节派生运行时类型并创建相应 Class 对象。类型的身份由二进制名与定义加载器共同决定;两个加载器定义同名字节产生的类型关系,见 类加载器与委派边界。数组类由 JVM 创建,不要求磁盘上存在对应 .class。
链接包括验证、准备和解析,但解析不要求在初始化前全部完成。实现可以提前解析,也可以在使用符号引用时解析;错误必须按规范在程序使用相关类型或引用的位置报告。因此,启动成功后仍可能在某个新执行分支报出链接错误。
取得二进制表示
↓
派生运行时类型:格式、版本、类型名等检查
↓
验证:指令、类型与控制流约束
↓
准备:静态字段存储与默认值
↓
满足初始化触发条件 → 初始化
解析:针对符号引用进行,可提前,也可延迟
不要求所有引用都在初始化前解析完验证检查的是程序能否合法执行,例如分支是否跳到指令边界、栈上类型能否满足当前指令、异常处理器入口是否合法、未初始化对象是否被错误使用。它不会替业务检查订单金额是否正确,也不使不可信代码自动具备安全隔离。
准备阶段建立静态字段并赋默认值,例如 int 为 0、引用为 null。static int count = calculate() 的 Java 赋值表达式要在初始化时执行。实例字段的存储随对象分配产生,不由类的准备阶段为每个将来对象预先分配。
什么操作会触发初始化
JLS 的初始化规则按主动使用定义触发点。常见情况如下:
| 操作 | 对目标类型初始化的影响 |
|---|---|
new C() | 初始化 C 后构造实例 |
| 调用 C 声明的静态方法 | 初始化声明该方法的类型 |
| 读写静态字段 | 普通写入、读取非编译期常量会触发声明类型初始化 |
| 读取编译期常量 | 可直接使用调用方内联的值,不因此初始化声明类型 |
C.class、new C[10] | 不因类字面量或创建引用数组初始化 C |
ClassLoader.loadClass(name) | 默认加载,不请求初始化 |
Class.forName(name, false, loader) | 不请求初始化 |
Class.forName(name) 或显式传 true | 请求初始化;还可能因查找失败而异常 |
反射的具体行为取决于调用的 API,仅取得元数据与反射调用静态方法不同。Class.forName API明确给出了初始化参数的含义。特定 MethodHandle 的首次调用也有相应初始化要求,不能把表中的常见入口当成全部规范条目。
类初始化会先处理父类以及规范要求的、声明默认方法的相关父接口。单独初始化一个接口,不自动初始化它的所有父接口。通过子类名字读取父类声明的静态字段,触发的是实际声明类型,而非把名称路径上的类型逐个初始化。
常量内联与运行时字段
实验里的常量类如下,完整上下文位于 InitializationProbe.java:
private static final class Constants {
static final int COMPILE_TIME = 7;
static final int RUNTIME = initialize();
private static int initialize() {
System.out.println("constants-clinit=entered");
return 11;
}
}两者都声明了 static final,但只有 COMPILE_TIME 是这里的编译期常量。方法调用 initialize() 不是常量表达式;包装类型对象或动态配置也不能因为加了 final 就被当成编译期常量。
"$JAVA_HOME/bin/javac" --release 17 -g -d "$LAB_OUT/init" \
init/example/init/InitializationProbe.java
"$JAVA_HOME/bin/java" -cp "$LAB_OUT/init" example.init.InitializationProbe constants
"$JAVA_HOME/bin/javap" -classpath "$LAB_OUT/init" -p -c -v \
'example.init.InitializationProbe$Constants'程序输出顺序是:
compile-time=7
before-runtime
constants-clinit=entered
runtime=11反汇编中,COMPILE_TIME 带 ConstantValue: int 7;<clinit> 里调用 initialize(),再用 putstatic 写入 RUNTIME。初始化过程会处理 ConstantValue,随后执行初始化方法,而常量的使用方已经可以把 7 放进自己的 class。把库中的 7 改为 8 后只替换库,旧客户端可能仍打印 7;升级时还要重新编译内联该值的调用方。
同一类型的初始化状态
JVM 按运行时类型协调初始化。一个线程执行初始化时,其他请求初始化同一类型的线程需要等待;同一线程重入初始化请求可直接返回,因此循环初始化可能观察到尚未完成赋值的默认值。对不同类形成交叉等待时,还可能造成初始化死锁,静态初始化中应避免复杂跨类依赖及不可控阻塞。
已验证、已准备
│ 首次主动使用
▼
初始化中
├─ 正常结束 → initialized:后续使用不再执行初始化体
└─ 异常结束 → erroneous:后续请求初始化失败失败实验在同一进程里两次读取 Broken.VALUE:
"$JAVA_HOME/bin/java" -cp "$LAB_OUT/init" example.init.InitializationProbe failurebroken-clinit=entered
attempt=1 error=ExceptionInInitializerError cause=IllegalStateException
attempt=2 error=NoClassDefFoundError初始化体抛出的 IllegalStateException 首次被包装为 ExceptionInInitializerError;如果原异常已经是 Error,则按规则直接传播。包装分配本身失败等极端情况也可能改变首次异常。无论后续外部配置是否恢复,这份运行时类型都已进入错误状态,第二次不会重新执行初始化体。
因此 Could not initialize class 应回查最早一次失败及 cause。把可失败的数据库连接、远程配置请求放到显式启动或请求生命周期中,才能定义超时、重试和关闭;单纯捕获后续 NoClassDefFoundError 不能恢复同一类型的初始化。
保留旧客户端,替换运行时依赖
只对 v1 编译一次
Pricing 用一个简单计算区分调用是否成功:金额加上币种字符串长度,100 + 3 = 103,不作为业务定价规则。v1 的公开方法为:
public static long invoice(long cents, String currency) {
return cents + currency.length();
}OrderClient.specialOrder() 调用 Pricing.invoice(100L, "CNY"),普通分支只打印接收消息,不调用 Pricing。沿用前面的 LAB_OUT,先编译 v1,再将它作为客户端的编译依赖:
"$JAVA_HOME/bin/javac" --release 17 -d "$LAB_OUT/v1" \
api-v1/example/api/Pricing.java
"$JAVA_HOME/bin/javac" --release 17 -cp "$LAB_OUT/v1" \
-d "$LAB_OUT/client" client/example/client/OrderClient.java
"$JAVA_HOME/bin/java" -cp "$LAB_OUT/client:$LAB_OUT/v1" \
example.client.OrderClient special
sha256sum "$LAB_OUT/client/example/client/OrderClient.class"预期打印 special=103。记录客户端 hash,后续始终运行这一个文件。Linux classpath 以冒号分隔多个根目录;客户端目录只放客户端,API 目录只放 API,避免同名类在多个位置重复出现。
v2 保留方法名,改变参数类型
v2-bad 只有 invoice(int, String)。将它编译到另一目录,运行时替换 classpath 的 API 部分:
"$JAVA_HOME/bin/javac" --release 17 -d "$LAB_OUT/v2-bad" \
api-v2-bad/example/api/Pricing.java
"$JAVA_HOME/bin/java" -cp "$LAB_OUT/client:$LAB_OUT/v2-bad" \
example.client.OrderClient regular普通分支仍输出 regular=accepted-without-pricing-call。再执行特殊分支,显式保存退出码和异常:
if "$JAVA_HOME/bin/java" -cp "$LAB_OUT/client:$LAB_OUT/v2-bad" \
example.client.OrderClient special > "$LAB_OUT/bad.out" 2> "$LAB_OUT/bad.err"; then
printf 'unexpected success\n'
else
status=$?
printf 'java exit=%s\n' "$status"
fi
sed -n '1,5p' "$LAB_OUT/bad.err"该 Java 进程应非零退出,异常首行与首帧为以下形态,源码行号取决于实际文件:
java.lang.NoSuchMethodError:
'long example.api.Pricing.invoice(long, java.lang.String)'
at example.client.OrderClient.specialOrder(...)比较调用方和 v2 的实际成员:
"$JAVA_HOME/bin/javap" -classpath "$LAB_OUT/client" -p -c -s \
example.client.OrderClient
"$JAVA_HOME/bin/javap" -classpath "$LAB_OUT/v2-bad" -p -s example.api.Pricing| 位置 | 实际内容 |
|---|---|
| 旧客户端调用指令 | invokestatic … Pricing.invoice:(JLjava/lang/String;)J |
| v2-bad 的方法 | invoice(int, java.lang.String) |
| v2-bad 的 descriptor | (ILjava/lang/String;)J |
旧客户端期待 long 参数,v2 只提供 int 参数,JVM 不会重新执行源码层的重载选择或自动改写调用点。普通分支通过,是因为该次执行未使用失败引用;它不能确定 HotSpot 内部究竟在什么时候尝试解析,也不能覆盖特殊分支的兼容性。
恢复旧 descriptor 后复测
v2-fixed 同时保留两个重载:
public static long invoice(long cents, String currency) {
return cents + currency.length();
}
public static long invoice(int cents, String currency) {
return invoice((long) cents, currency);
}"$JAVA_HOME/bin/javac" --release 17 -d "$LAB_OUT/v2-fixed" \
api-v2-fixed/example/api/Pricing.java
"$JAVA_HOME/bin/java" -cp "$LAB_OUT/client:$LAB_OUT/v2-fixed" \
example.client.OrderClient special
sha256sum "$LAB_OUT/client/example/client/OrderClient.class"输出恢复为 special=103,客户端 hash 与前面相同。修复来自 API 恢复了旧 descriptor。真实接口还要保持旧方法的业务含义;保留签名却更改金额单位、异常约定或副作用,仍会影响调用方。
JLS 的二进制兼容章节区分了旧二进制能否继续链接与源码能否重新编译。新增重载可能保持旧 class 可用,却让某些源码调用产生歧义;改变常量值可能成功链接,却因内联继续使用旧值。库升级测试需要同时保留旧客户端运行测试与新源码编译测试。
需要一次性回归全部探针时,在实验根目录运行 run.sh:
bash run.sh它检查普通计算、调用指令、异常表、旧客户端字节不变、缺失方法、兼容修复与两次初始化失败。脚本只接受上文已经验证的两套 Temurin 版本,并单独建立临时输出目录;其他 JDK 可执行正文步骤,但应重新记录实际输出。
按异常阶段定位运行时失败
先判断查找、链接还是初始化
| 异常 | 常见触发条件 | 第一项检查 |
|---|---|---|
ClassNotFoundException | 显式类查找没有找到定义 | 传入名称、所用 loader、可见路径 |
NoClassDefFoundError | 执行所需定义不可用、名称不匹配,或类型已初始化失败 | 详细消息与最早 cause,区分缺失定义和 Could not initialize class |
UnsupportedClassVersionError | 当前 JVM 不支持 class 版本 | 实际运行 JDK、目标 class 的 major/minor |
ClassFormatError、VerifyError | 文件结构或字节码验证约束不满足 | 运行时读取的 class、增强器和栈映射 |
NoSuchMethodError、NoSuchFieldError | 符号引用无法找到相应成员 | 调用方引用与目标成员的 name/descriptor |
IncompatibleClassChangeError | 静态/实例、类/接口等形态冲突 | 成员标志、类型形态和依赖版本 |
AbstractMethodError | 所需实例方法没有可执行的具体实现 | 接口/父类与实际实现是否配套 |
IllegalAccessError | 字节码访问成员时不满足运行时访问规则 | 可见性、运行时包与模块关系 |
BootstrapMethodError | 动态调用点或动态常量的引导失败 | 原始 cause、BootstrapMethods 和相关库版本 |
ExceptionInInitializerError | 初始化中的非 Error 异常被包装 | 首次失败的静态字段赋值或初始化块 |
NoSuchMethodError 等本身属于 IncompatibleClassChangeError 的子类,UnsupportedClassVersionError 属于 ClassFormatError 的子类;表格按排查任务细分,不能当作互斥的继承层级。类型转换失败的 ClassCastException 则要进一步检查实际对象类型与定义加载器。
找到 JVM 实际加载的那份 class
依赖树显示某个版本,只说明构建解析结果;容器中仍可能带旧 JAR,启动脚本也可能追加了另一个目录。对于前面的实验,可用加载日志直接确认 Pricing 的来源:
"$JAVA_HOME/bin/java" \
'-Xlog:class+load=info:stdout:uptime,level,tags' \
-cp "$LAB_OUT/client:$LAB_OUT/v2-fixed" \
example.client.OrderClient special查找 example.api.Pricing source: file:.../v2-fixed/,并确认仍输出 special=103。-Xlog 的标签、输出目标与装饰字段见 java 命令手册。生产服务可在获准的复现或重启窗口添加同类日志;日志包含路径,保存和分享时需按环境处理敏感信息。
普通 JAR 可以单独列目录并检查指定类型:
export API_JAR=/opt/myapp/lib/pricing.jar
"$JAVA_HOME/bin/jar" tf "$API_JAR" | grep 'example/api/Pricing.class'
"$JAVA_HOME/bin/javap" -classpath "$API_JAR" -p -s example.api.Pricing
sha256sum "$API_JAR"这里的路径需替换为实际部署文件。类不存在时检查打包和运行时依赖;多个 JAR 都有同名类时,结合加载来源决定哪份被使用。Spring Boot 嵌套 JAR、自定义加载器、Multi-Release JAR 或 Agent 转换还会影响最终字节:应检查真正选中的嵌套依赖/版本条目,必要时使用转换器支持的类导出能力。直接对外层可执行 JAR 使用普通 classpath,不能保证得到内层实际加载结果。
修复后重跑发生错误的调用点
版本过新时,让编译目标、依赖和运行 JDK 重新配套,禁止手工改 class 头部版本号冒充兼容。VerifyError 则保留原始与增强后的 class,在隔离环境中用同一输入对照启用/停用或升级 Agent;不要用关闭验证作为生产修复。
方法或字段缺失时,恢复兼容版本或成套更新调用方与被调用方,再执行曾失败的分支。初始化失败时先解决首次 cause,并通过重启受影响实例或受控的新加载器生命周期创建新的类型;替换磁盘文件不会重置已定义类型的错误状态。
制品来源、调用指令和错误栈应指向同一份运行代码。保留旧客户端测试能及早发现二进制不兼容;对动态加载或低频分支,还需要让回归请求真正执行到相应调用点。
本机逐步实验结束后,先打印路径,再仅删除本轮临时目录;不删除源码:
printf 'temporary output: %s\n' "$LAB_OUT"
case "$LAB_OUT" in
/tmp/classfile-learning.*) rm -r -- "$LAB_OUT" ;;
*) printf 'unexpected path; keep files for inspection\n' >&2 ;;
esac
unset LAB_OUT权威资料与规范地址
class 格式与执行规则
| 资料 | 完整地址 |
|---|---|
| JVMS 25 class 格式 | https://docs.oracle.com/javase/specs/jvms/se25/html/jvms-4.html |
| JVMS 25 栈帧 | https://docs.oracle.com/javase/specs/jvms/se25/html/jvms-2.html#jvms-2.6 |
| JVMS 25 指令集 | https://docs.oracle.com/javase/specs/jvms/se25/html/jvms-6.html |
| JVMS 25 加载、链接与初始化 | https://docs.oracle.com/javase/specs/jvms/se25/html/jvms-5.html |
| JLS 25 执行与初始化 | https://docs.oracle.com/javase/specs/jls/se25/html/jls-12.html |
| JLS 25 二进制兼容 | https://docs.oracle.com/javase/specs/jls/se25/html/jls-13.html |
工具与动态调用 API
| 资料 | 完整地址 |
|---|---|
| javac | https://docs.oracle.com/en/java/javase/25/docs/specs/man/javac.html |
| javap | https://docs.oracle.com/en/java/javase/25/docs/specs/man/javap.html |
| java 与统一日志 | https://docs.oracle.com/en/java/javase/25/docs/specs/man/java.html |
| Class.forName | https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/lang/Class.html#forName(java.lang.String,boolean,java.lang.ClassLoader) |
| LambdaMetafactory | https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/lang/invoke/LambdaMetafactory.html |
| StringConcatFactory | https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/lang/invoke/StringConcatFactory.html |
