5.1 Class 文件与栈帧:读懂 javap 输出
源码里的一句 total += price,经过编译后会变成加载、算术和存储指令。javap 能把这些字节码连同 class 文件属性展示出来,让装箱、桥接方法和异常处理都有据可查。编译器替你做了不少事,偶尔查一下账很有用。
先分清三个层次:
Java 源码 ──javac──> class 文件 ──JVM 实现──> 解释或本机机器码
JLS 约束 JVMS 约束 实现策略class 文件和 JVM 指令由 JVMS 规定;即时编译、对象布局和寄存器分配属于具体实现。看到字节码,不等于看到了 CPU 最终执行的全部指令。
建立一个最小实验
把下面的代码保存为 PriceMath.java。使用支持 Java 17 目标版本的 JDK,并显式指定目标:
public final class PriceMath {
public static int addTax(int price, int tax) {
int total = price + tax;
return total;
}
}javac --release 17 -g PriceMath.java
javap -c -v -p PriceMath常用参数:
-c:反汇编方法的字节码;-v:显示版本、常量池、属性等详细信息;-p:包含非公开成员;-l:显示行号表和局部变量表。
实际输出随编译器和目标版本变化。不要照抄某次结果当永久规则,应在自己的构建参数下重现实验。
ClassFile 里装了什么
JVMS 定义的顶层结构包括:
ClassFile
├── magic / minor_version / major_version
├── constant_pool
├── access_flags / this_class / super_class
├── interfaces
├── fields
├── methods
└── attributes魔数是 0xCAFEBABE。版本号决定 class 文件格式兼容性;上面的 Java 17 目标会生成主版本 61;Java SE 26 的主版本是 70,其依赖预览特性的 class 文件使用次版本 65535。预览 class 文件需要对应版本的运行时并启用预览,不能直接当作普通的向后兼容产物。项目若要在旧运行时部署,应通过构建工具明确目标版本,而不是只看本机 javac 能否编译。
常量池不只是字符串池。它可以包含:
- 数值和字符串常量;
- 类与接口的符号名称;
- 字段和方法引用;
- 方法句柄、方法类型;
invokedynamic和动态常量所需信息。
字节码常通过常量池索引引用符号。索引操作数的宽度由具体指令决定,JVM 指令并非全部固定长度。
方法描述符先于源码泛型
JVM 使用描述符表达字段和方法类型:
I int
J long
Ljava/lang/String; java.lang.String
[I int[]
(II)I 接收两个 int,返回 intaddTax(int, int) 的方法描述符是 (II)I。实例方法的描述符不会显式写入 this,但调用时会隐式传入接收者。
字段和方法声明中的泛型信息可存放在 Signature 属性中,执行所用描述符则受类型擦除影响。例如 List<String> 字段的描述符是 Ljava/util/List;。Signature 不是 -g 控制的普通调试信息:使用 -g:none 后,它仍可保留,而行号、源文件名和局部变量调试表会被省略。
栈帧:局部变量表与操作数栈
每次方法调用都会创建一个帧。抽象上,帧包含:
- 局部变量数组;
- 操作数栈;
- 指向当前类运行时常量池的引用;
- 方法正常或异常返回所需信息。
addTax 的核心字节码可能是:
0: iload_0
1: iload_1
2: iadd
3: istore_2
4: iload_2
5: ireturn逐步追踪:
| 偏移 | 指令 | 局部变量 | 操作数栈 |
|---|---|---|---|
| 0 | iload_0 | [price, tax, ?] | [price] |
| 1 | iload_1 | 同上 | [price, tax] |
| 2 | iadd | 同上 | [price + tax] |
| 3 | istore_2 | [price, tax, total] | [] |
| 4 | iload_2 | 同上 | [total] |
| 5 | ireturn | 当前帧退出 | 结果传给调用方 |
静态方法从局部变量槽 0 放第一个参数;实例方法通常在槽 0 放 this。long 和 double 在局部变量数组中占两个连续槽,但不要把“槽”想成物理 CPU 寄存器。
JVMS 的指令集以操作数栈为中心,这不表示具体 JVM 或生成的机器码不能使用寄存器。JIT 编译器最终会按目标 CPU 分配寄存器和栈位置。
分支和循环如何落到偏移量
把下面的方法加入 PriceMath 类,重新编译并查看反汇编:
public static int clamp(int value) {
if (value < 0) return 0;
return value;
}可能得到:
0: iload_0
1: ifge 6
4: iconst_0
5: ireturn
6: iload_0
7: ireturnifge 6 弹出栈顶的 int,值大于等于 0 时跳到方法内的字节码偏移 6,否则继续执行下一条指令。javap 显示目标位置;指令中编码的是相对当前指令的偏移。源码块结构在 class 文件中变成控制流图;行号表只是把偏移映射回源码行,优化和语法糖可能使映射并非一一对应。
验证器为何需要类型状态
JVM 加载不受信任的 class 文件时,不能假设编译器永远正确。验证阶段会检查控制流上的类型和栈状态,例如:
- 指令操作数类型是否匹配;
- 跳转目标是否落在合法指令边界;
- 任一路径到达同一点时,操作数栈是否兼容;
- 返回指令是否符合方法描述符;
- 未初始化对象是否按规则使用。
现代 class 文件常包含 StackMapTable,给出关键控制流位置的类型状态,帮助验证器检查,而不必从头推导所有路径。
格式检查、字节码验证和符号解析是不同工作。魔数正确只能说明“像一个 class 文件”,不代表引用的方法存在,也不代表代码通过验证。
异常不是普通的返回指令
try/catch 通常通过 Code 属性中的异常表表示:
from to target type
0 10 13 java/lang/ArithmeticException这里的保护区间是 [0, 10),不包括偏移 10。JVM 按异常表顺序寻找第一个覆盖抛出位置且类型匹配的处理器,清空当前操作数栈,只压入异常对象,再跳到处理器入口;本方法没有匹配项时,异常继续向调用方传播。finally 的具体字节码形状由编译器策略和源码结构决定,可能复制清理代码,也可能配合异常表生成额外路径。
完成检查
写一个包含 if、循环和 try/catch 的短方法,然后:
- 用
javap -c -v -p找到它的方法描述符; - 为一个基本块手工追踪操作数栈;
- 找出分支目标和异常表;
- 比较
-g与-g:none时的属性差异; - 说明哪些内容来自 JVMS,哪些只是
javac的生成策略。