javap -c 的输出对多数人是"看得懂每个助记符、说不清为什么这样写"。比如一个加法为什么要 iload_1 iload_2 iadd 四条指令?为什么局部变量有编号、操作数栈却没有?这些不是巧合,而是栈式虚拟机这套模型的直接后果。理解模型,字节码就从"记忆负担"变成"可推导的结构"。
1. 两种执行模型
| 维度 | 栈虚拟机(JVM) | 寄存器虚拟机(Dalvik) |
|---|---|---|
| 操作数来源 | 操作数栈顶 | 显式编号的寄存器 |
| 指令形态 | 隐含操作数(默认取栈顶) | 每个指令带寄存器编号 |
| 指令长度 | 短(1–3 字节为主) | 长(需要编码寄存器对) |
| 指令数量 | 多(load/store 占大头) | 少(一条搞定) |
| 可移植性 | 高,与硬件无关 | 与寄存器数量/宽度相关 |
| 验证难度 | 低,栈模型简单 | 高,需数据流分析 |
核心差别只有一句:栈机把"值放哪"编码在指令的隐含约定里,寄存器机把它写进指令操作数里。
2. 一个表达式的两种展开
源码:
int calc(int a, int b, int c) {
return a + b * c;
}
JVM 栈式(javap -c,JDK 17 输出截取):
int calc(int, int, int);
Code:
0: iload_1
1: iload_2
2: iload_3
3: imul
4: iadd
5: ireturn
栈的变化逐步推演:
| 指令 | 操作数栈(自底向上) | 说明 |
|---|---|---|
iload_1 |
a |
局部变量 1 入栈 |
iload_2 |
a, b |
局部变量 2 入栈 |
iload_3 |
a, b, c |
局部变量 3 入栈 |
imul |
a, b*c |
弹出两个、压回一个 |
iadd |
a+b*c |
同上 |
ireturn |
空 | 弹出返回值 |
寄存器式(Dalvik smali 风格)对应形态:
.method public calc(III)I
mul-int v0, p2, p3
add-int v0, p1, v0
return v0
.end method
两相对照:栈式用了 6 条指令、无操作数;寄存器式 3 条、每条带寄存器编号。栈式的"多"换来的是"短":iload_1 是单字节指令(0x1b),add-int v0, p1, v0 在 Dalvik 里通常要 2 个 16 位单元。
3. 栈机的三个优势与一个代价
| 优势 | 机制 | 结果 |
|---|---|---|
| 指令紧凑 | 操作数隐含,无需编码寄存器 | Class 文件小、加载快 |
| 可移植 | 不依赖物理寄存器数量 | 同一份字节码跨架构 |
| 验证简单 | 只需跟踪栈高度与类型 | 验证器实现容易、安全边界清晰 |
| 代价:指令多 | 每次读写都要 load/store | 解释执行时指令分发开销大 |
这个代价正是 JIT 存在的理由之一:C1/C2 把栈式字节码转成 SSA 形式的中间表示后,load/store 对会被消解,栈机在执行期的劣势就不存在了。
$ javap -c -p /tmp/out/Calc.class | head -20
$ javap -v -p /tmp/out/Calc.class | grep -E 'stack=|locals='
javap -v 会给出每个方法的 stack=2, locals=4,这两个数字是验证器与解释器的直接输入:栈最大深度决定帧内操作数区大小,locals 决定局部变量区大小。
4. 局部变量表与操作数栈的配合
一个栈帧里两块区域分工明确:
| 区域 | 内容 | 由谁决定大小 | 读写方式 |
|---|---|---|---|
| 局部变量表 | 方法参数 + 局部变量,按槽位编号 | javap -v 的 locals= |
iload_n / istore_n |
| 操作数栈 | 计算的临时值 | javap -v 的 stack= |
指令隐式 push/pop |
long/double 各占两个槽位,这解释了为什么 long 参数后面的变量编号会跳号:
void m(long a, int b) { } // a 占 1-2,b 占 3
void m(long, int);
descriptor: (JI)V
Code:
0: return
LocalVariableTable:
Start Length Slot Name Signature
0 1 0 this LCalc;
0 1 1 a J
0 1 3 b I
a 是 J(long)占了 slot 1 与 2,b 落在 slot 3。写字节码增强时算错槽位,就是这类"看起来对、验证不过"的根源。
5. 字节码验证与 StackMapTable
栈机的验证难题是合并点的类型一致性:两条分支汇合时,栈上的类型必须能统一。JDK 6 起(class 文件版本 50)引入 StackMapTable 属性,要求编译器在分支目标、异常处理器入口等位置显式写出栈与局部变量的类型状态,验证器从"推导"变成"检查"。
| 版本 | 验证方式 | 后果 |
|---|---|---|
| ≤ 49.0(JDK 5) | 纯数据流推导 | 验证慢、实现复杂 |
| ≥ 50.0(JDK 6) | 检查 StackMapTable |
缺帧直接 VerifyError |
| JDK 17 | 强制 StackMapTable,版本 61 |
手写/增强字节码必须算帧 |
$ javap -v -p /tmp/out/Calc.class | sed -n '/StackMapTable/,/^$/p'
典型输出形态:
StackMapTable: number_of_entries = 2
frame_type = 252 /* append */
offset_delta = 5
locals = [ int ]
frame_type = 250 /* chop */
offset_delta = 12
读法:frame_type 编码"相对上一帧的变化"(same/append/chop/full),offset_delta 是相对字节码偏移。这是压缩表示,不是完整快照。
字节码增强失败最常见的报错就来自这里:
java.lang.VerifyError: Expecting a stackmap frame at branch target 28
处理办法不是手算帧,而是让工具算:ASM 用 ClassWriter.COMPUTE_FRAMES(会自动 getCommonSuperClass,需要能加载相关类),ByteBuddy 默认已处理。
6. javap 的三级读法
| 命令 | 给出什么 | 什么时候用 |
|---|---|---|
javap -c |
反汇编字节码 | 看控制流、指令序列 |
javap -v |
常量池、StackMapTable、属性、LineNumberTable |
查索引、查帧、查版本 |
javap -p -s |
私有成员 + 描述符 | 看签名、桥接方法 |
一个常被问的点:为什么 lambda 在 javap 里看不到方法体?因为它被编译成 invokedynamic + 私有静态方法 lambda$main$0,-p 才能看到。
$ javap -c -p -v /tmp/out/LambdaDemo.class | grep -E 'lambda\$|InvokeDynamic|BootstrapMethods'
7. Dalvik 与 ART 的路径
| 阶段 | 模型 | 编译时机 |
|---|---|---|
| Dalvik | 寄存器式,.dex |
解释 + JIT(后期版本) |
| ART(早期) | 寄存器式,.dex → .oat |
安装时 AOT(dex2oat) |
| ART(Android 7+) | 混合 | 安装时部分 AOT + 运行期 JIT + profile 引导 |
| ART(Android 9+) | 混合 + 云 profile | 更偏向按需编译 |
Dalvik 选寄存器式的原因很实际:移动设备上指令分发与内存带宽是瓶颈,寄存器式指令更少、执行更快,代价是 .dex 更大。ART 保留寄存器式 IR,但把编译时机改成"安装期预编译 + 运行期补充",本质是同一套 AOT/JIT 权衡在移动端的变体。
这也回答了"为什么 JVM 不学 Dalvik":JVM 的首要约束是跨平台字节码分发,文件体积与验证简单性优先;Android 的首要约束是单设备执行效率,两者目标不同,模型自然不同。
8. 排错
| 现象 | 原因 | 处理 |
|---|---|---|
VerifyError: Expecting a stackmap frame at branch target N |
改了分支却没更新 StackMapTable |
让 ClassWriter 用 COMPUTE_FRAMES 重算,不手写帧 |
| 增强后方法语义错但能过验证 | 把字节码当汇编逐条翻译,忽略隐含栈效应 | 按栈高度逐步推演,用 javap -c 对齐偏移 |
long 参数后的局部变量取值错位 |
J/D 占两个槽位 |
按 LocalVariableTable 的 Slot 列核对 |
ClassFormatError: Stack size too large |
手写的 stack= 小于实际峰值 |
交给工具计算,或按指令栈效应累加 |
| 增删方法后异常处理器表错位 | exception_table 的 start/end/handler_pc 未随偏移更新 |
用 ASM 的 visitTryCatchBlock 而非手改二进制 |
加载期报 Unsupported class file major version |
字节码版本与运行 JDK 不匹配 | 按 JDK 17 用 major 61,或用 -Xverify:none 仅定位版本问题 |
$ java -Xverify:all -cp /tmp/out Calc # 强制全量验证,排查期用
$ javap -v -p /tmp/out/Calc.class | sed -n '/StackMapTable/,/^$/p'
9. 边界
| 场景 | 是否需要懂字节码 | 理由 |
|---|---|---|
| 日常业务开发 | 不必 | 编译器的产物无需人工干预 |
| 写 Java Agent / AOP | 必须 | 帧与槽位是硬约束 |
排查 VerifyError |
必须 | 报错直指帧与索引 |
| 理解 JIT 行为 | 有帮助 | 内联的"方法体积"就是字节码大小 |
| 选择语言/平台 | 不必 | 模型差异对使用者透明 |
10. 自测
- 栈机与寄存器机在"值放在哪"这件事上的编码方式有什么本质差别?
- 为什么
a + b * c在栈式字节码里是 6 条指令,而寄存器式只要 3 条?栈机用"多"换来了什么? - 局部变量表的槽位编号为什么会在
long参数之后跳号? StackMapTable解决的是什么验证难题?JDK 6 起为什么从"推导"改成"检查"?- 字节码增强报
Expecting a stackmap frame时,正确的处理方式是什么? - Dalvik 选寄存器式、JVM 选栈式,各自的约束条件是什么?