2375 字
约 7 分钟
14
S05-X15 · 栈虚拟机与寄存器虚拟机:字节码为什么这么设计
2024-12-31
2026-11-08

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. 自测

  1. 栈机与寄存器机在"值放在哪"这件事上的编码方式有什么本质差别?
  2. 为什么 a + b * c 在栈式字节码里是 6 条指令,而寄存器式只要 3 条?栈机用"多"换来了什么?
  3. 局部变量表的槽位编号为什么会在 long 参数之后跳号?
  4. StackMapTable 解决的是什么验证难题?JDK 6 起为什么从"推导"改成"检查"?
  5. 字节码增强报 Expecting a stackmap frame 时,正确的处理方式是什么?
  6. Dalvik 选寄存器式、JVM 选栈式,各自的约束条件是什么?
S05-X15 · 栈虚拟机与寄存器虚拟机:字节码为什么这么设计
https://www.clxhxhhr.top/posts/5766/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。