跳到内容

第 9 卷 / 第 5 章 / 第 1 课

5.1 Class 文件与栈帧:读懂 javap 输出 ​

源码里的一句 total += price,经过编译后会变成加载、算术和存储指令。javap 能把这些字节码连同 class 文件属性展示出来,让装箱、桥接方法和异常处理都有据可查。编译器替你做了不少事,偶尔查一下账很有用。

先分清三个层次:

text
Java 源码 ──javac──> class 文件 ──JVM 实现──> 解释或本机机器码
  JLS 约束             JVMS 约束             实现策略

class 文件和 JVM 指令由 JVMS 规定;即时编译、对象布局和寄存器分配属于具体实现。看到字节码,不等于看到了 CPU 最终执行的全部指令。

建立一个最小实验 ​

把下面的代码保存为 PriceMath.java。使用支持 Java 17 目标版本的 JDK,并显式指定目标:

java
public final class PriceMath {
    public static int addTax(int price, int tax) {
        int total = price + tax;
        return total;
    }
}
bash
javac --release 17 -g PriceMath.java
javap -c -v -p PriceMath

常用参数:

  • -c:反汇编方法的字节码;
  • -v:显示版本、常量池、属性等详细信息;
  • -p:包含非公开成员;
  • -l:显示行号表和局部变量表。

实际输出随编译器和目标版本变化。不要照抄某次结果当永久规则,应在自己的构建参数下重现实验。

ClassFile 里装了什么 ​

JVMS 定义的顶层结构包括:

text
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 使用描述符表达字段和方法类型:

text
I                    int
J                    long
Ljava/lang/String;   java.lang.String
[I                   int[]
(II)I                接收两个 int,返回 int

addTax(int, int) 的方法描述符是 (II)I。实例方法的描述符不会显式写入 this,但调用时会隐式传入接收者。

字段和方法声明中的泛型信息可存放在 Signature 属性中,执行所用描述符则受类型擦除影响。例如 List<String> 字段的描述符是 Ljava/util/List;。Signature 不是 -g 控制的普通调试信息:使用 -g:none 后,它仍可保留,而行号、源文件名和局部变量调试表会被省略。

栈帧:局部变量表与操作数栈 ​

每次方法调用都会创建一个帧。抽象上,帧包含:

  • 局部变量数组;
  • 操作数栈;
  • 指向当前类运行时常量池的引用;
  • 方法正常或异常返回所需信息。

addTax 的核心字节码可能是:

text
0: iload_0
1: iload_1
2: iadd
3: istore_2
4: iload_2
5: ireturn

逐步追踪:

偏移指令局部变量操作数栈
0iload_0[price, tax, ?][price]
1iload_1同上[price, tax]
2iadd同上[price + tax]
3istore_2[price, tax, total][]
4iload_2同上[total]
5ireturn当前帧退出结果传给调用方

静态方法从局部变量槽 0 放第一个参数;实例方法通常在槽 0 放 this。long 和 double 在局部变量数组中占两个连续槽,但不要把“槽”想成物理 CPU 寄存器。

JVMS 的指令集以操作数栈为中心,这不表示具体 JVM 或生成的机器码不能使用寄存器。JIT 编译器最终会按目标 CPU 分配寄存器和栈位置。

分支和循环如何落到偏移量 ​

把下面的方法加入 PriceMath 类,重新编译并查看反汇编:

java
public static int clamp(int value) {
    if (value < 0) return 0;
    return value;
}

可能得到:

text
0: iload_0
1: ifge          6
4: iconst_0
5: ireturn
6: iload_0
7: ireturn

ifge 6 弹出栈顶的 int,值大于等于 0 时跳到方法内的字节码偏移 6,否则继续执行下一条指令。javap 显示目标位置;指令中编码的是相对当前指令的偏移。源码块结构在 class 文件中变成控制流图;行号表只是把偏移映射回源码行,优化和语法糖可能使映射并非一一对应。

验证器为何需要类型状态 ​

JVM 加载不受信任的 class 文件时,不能假设编译器永远正确。验证阶段会检查控制流上的类型和栈状态,例如:

  • 指令操作数类型是否匹配;
  • 跳转目标是否落在合法指令边界;
  • 任一路径到达同一点时,操作数栈是否兼容;
  • 返回指令是否符合方法描述符;
  • 未初始化对象是否按规则使用。

现代 class 文件常包含 StackMapTable,给出关键控制流位置的类型状态,帮助验证器检查,而不必从头推导所有路径。

格式检查、字节码验证和符号解析是不同工作。魔数正确只能说明“像一个 class 文件”,不代表引用的方法存在,也不代表代码通过验证。

异常不是普通的返回指令 ​

try/catch 通常通过 Code 属性中的异常表表示:

text
from  to  target  type
0     10  13      java/lang/ArithmeticException

这里的保护区间是 [0, 10),不包括偏移 10。JVM 按异常表顺序寻找第一个覆盖抛出位置且类型匹配的处理器,清空当前操作数栈,只压入异常对象,再跳到处理器入口;本方法没有匹配项时,异常继续向调用方传播。finally 的具体字节码形状由编译器策略和源码结构决定,可能复制清理代码,也可能配合异常表生成额外路径。

完成检查 ​

写一个包含 if、循环和 try/catch 的短方法,然后:

  1. 用 javap -c -v -p 找到它的方法描述符;
  2. 为一个基本块手工追踪操作数栈;
  3. 找出分支目标和异常表;
  4. 比较 -g 与 -g:none 时的属性差异;
  5. 说明哪些内容来自 JVMS,哪些只是 javac 的生成策略。

参考资料 ​

Built with VitePress | Software Systems Atlas