尧图精选

【从0到1学习JVM · 12】三行加法代码,JVM解释器在底层究竟是怎么跑完的?

🕒 发布时间:2026/10/1 20:19:12 📁 来源:尧图网络
前言很多人觉得看字节码像看天书其实只要拿最简单的三行加法代码走一遍JVM 解释器的运行机制就全明白了。本文用真实指令流和状态变化带你一步步看透数据在栈帧里是怎么跳动的。文章目录前言一、为什么算个加法需要8条指令二、执行前先看清栈深与槽位两张底牌三、数据是怎样在栈和表之间跳跃的3.1 赋值阶段为什么存个变量非要先入栈3.2 计算阶段加法指令是怎样把栈顶清空的3.3 归仓阶段计算结果如何落进目标槽位四、执行到return时栈帧是怎样瞬间消失的五、写在最后一、为什么算个加法需要8条指令平时写业务代码三行加法随手就敲出来了packagecom.crayontech.jvm;publicclassBytecodeExecutionDemo{publicstaticvoidmain(String[]args){inta10;intb20;intcab;}}在高级语言层面这不过是两次赋值加上一次求和。如果放到 x86 或 ARM 这种真实的物理 CPU 上跑编译器往往只需要两条汇编指令把数字加载到寄存器然后执行一次add计算就完成了。但如果你用javap -c -v反编译这段代码会发现 JVM 生成的指令足足有 9 条0: bipush 10 2: istore_1 3: bipush 20 5: istore_2 6: iload_1 7: iload_2 8: iadd 9: istore_3 10: return除去最后的return光是完成这两次赋值和一次加法解释器就老老实实执行了 8 条指令。为什么搞得这么繁琐因为 JVM 是一台纯软件实现的虚拟计算机它的指令集是基于栈的架构。在物理硬件上CPU 可以直接命令“把 1 号寄存器和 2 号寄存器的值相加结果写回 1 号寄存器”。但 JVM 不能这么干。不同 CPU 架构的寄存器设计千差万别如果字节码里写死了具体寄存器的编号Java 的跨平台承诺当场就会作废。为了彻底摆脱硬件平台的绑架JVM 把所有运算都设计成了“零地址指令”。像iadd这种指令本身不带任何参数它在执行时永远只认一个地方操作数栈的栈顶。这就决定了解释器的工作模式不管你想算什么数据都必须先从外界搬到操作数栈由解释器在栈顶完成计算再把结果从栈顶搬回局部变量表。当前方法栈帧JVM 执行引擎解释器指示下一条指令偏移读取变量入栈 iload / bipush提供数据源执行运算 iadd运算结果回写 istore程序计数器记录当前字节码行号指令译码器取指与解析操作码局部变量表数据仓库 / Slot 槽位操作数栈计算工作台 / LIFO 栈看似多跑了几步但它换来了极致的通用性。只要宿主机上有兼容的 JVM这段字节码拿到任何芯片上都能跑出完全一致的结果。二、执行前先看清栈深与槽位两张底牌在解释器真正开始执行第 0 行指令之前这间“计算工作室”的格局就已经彻底定死了。很多人以为栈帧里的局部变量表和操作数栈会随着代码运行动态扩容其实并不是。在javac把 Java 源码编译成.class文件的那一刻这个方法需要占用多大的内存空间就已经被静态计算得清清楚楚。来看javap输出里的这行核心元数据Code: stack2, locals4, args_size1这行元数据给出了三个关键数字args_size1传入方法的参数个数。虽然我们在main方法里没用到入参但签名的String[] args依然是一个合法的引用类型参数。locals4局部变量表分配了 4 个 Slot槽位。stack2操作数栈的最大深度为 2。无论后续指令怎么折腾栈里最多同时容纳 2 个计算单元。在方法被线程调用的那一瞬间JVM 为其创建的栈帧初始状态如下图所示初始栈帧结构指令执行前操作数栈max_stack2栈顶空栈底空局部变量表locals4Slot 0: args入参引用已就位Slot 1: 未分配准备留给 aSlot 2: 未分配准备留给 bSlot 3: 未分配准备留给 c注意 Slot 0 的位置。因为main是一个static方法没有隐藏的this引用所以入参args独占了第 0 号槽位。接下来的局部变量a、b、c顺理成章地瓜分了 1、2、3 号槽位。此时操作数栈空空如也程序计数器指针正对着偏移量为 0 的位置。一切准备就绪解释器的取指执行循环正式启动。三、数据是怎样在栈和表之间跳跃的整个执行过程可以分成三个清晰的阶段变量初始化赋值、数据提取与求和计算、结果保存与方法返回。3.1 赋值阶段为什么存个变量非要先入栈先看前两行指令它们负责完成int a 10;0: bipush 10 2: istore_1执行第 0 行时程序计数器读取到bipush 10。bipush是 byte immediate push 的缩写。因为常量 10 处于单字节带符号整数范围-128 到 127之内JVM 会把这个 byte 扩展为一个标准的 32 位 int 类型并直接压入操作数栈栈顶。此时栈里有了第一个数字10栈深度变为 1。紧接着程序计数器跳到偏移量为 2 的位置执行istore_1。istore_1是一条专门针对 1 号槽位做过体积优化的单字节指令。它的作用非常干脆把当前操作数栈栈顶的 int 整数弹出来直接写入局部变量表的第 1 号 Slot。局部变量表 (Slot 1)操作数栈程序计数器局部变量表 (Slot 1)操作数栈程序计数器PC 0栈深变为 1栈顶元素 10PC 2操作数栈重新恢复为空bipush 10将常数 10 压入栈顶1istore_1弹出栈顶数据2将 10 写入 Slot 1 (变量 a)3做完这一步栈顶的 10 被移走了栈重新变回空的而局部变量表 Slot 1 里正式拥有了数值 10。接下来执行的第 3 行和第 5 行3: bipush 20 5: istore_2逻辑完全如出一辙。先用bipush 20把常量 20 压入操作数栈再通过istore_2把它弹出来稳稳存进局部变量表的第 2 号 Slot。到这里代码里的前两行变量定义全部执行完毕。局部变量表里整整齐齐地躺着args、10和20而操作数栈依然是空的。3.2 计算阶段加法指令是怎样把栈顶清空的有了数据之后代码迎来了最核心的运算int c a b;。直觉上既然两个数字已经在局部变量表里放好了为什么解释器不能直接拿 Slot 1 和 Slot 2 的值相加前面提过JVM 的设计原则决定了所有算法逻辑单元都不能越过操作数栈去操作内存。要算加法必须先把参与计算的原材料重新搬出来。于是解释器执行了接下来两条指令6: iload_1 7: iload_2iload_1读取局部变量表 Slot 1 里的值也就是变量 a 的 10复制一份压入操作数栈。此时栈深为 1。iload_2读取局部变量表 Slot 2 里的值也就是变量 b 的 20同样复制一份压入操作数栈。此时栈深为 2。注意看在执行完iload_2的这一瞬间操作数栈里同时存在 10在栈底和 20在栈顶栈深正好达到了编译期预设的上限值stack2。执行 8: iadd执行 7: iload_2复制值弹出 20 与 10 求和写回结果执行 6: iload_1复制值Slot 1: 10栈深 1: [10]Slot 2: 20栈深 2: [20 (栈顶), 10]加法运算10 20 30栈深 1: [30 (栈顶)]原材料全部到位程序计数器推进到偏移量 8迎来关键指令iadd。iadd动手时毫不犹豫从操作数栈顶连续弹出两个 int 整数先弹出 20后弹出 10。交给执行引擎的算术逻辑部件完成物理加法算出结果 30。把算出来的最终结果 30 重新压回操作数栈。原先栈里的两个操作数瞬间消失取而代之的是崭新的计算结果 30栈深重新降回 1。3.3 归仓阶段计算结果如何落进目标槽位现在加法虽然算完了但变量c还没拿到值因为 30 还悬在操作数栈里。最后一步收尾顺理成章9: istore_3解释器执行istore_3把栈顶的 30 弹出直接塞进局部变量表的第 3 号 Slot。至此局部变量表 4 个槽位全员满编Slot 0args引用Slot 110变量 aSlot 220变量 bSlot 330变量 c操作数栈再次清空整个方法的数据流转全部执行完毕。为了方便直观对比我们可以把这 8 条指令的执行全貌汇成一张表指令偏移字节码指令程序计数器行为操作数栈状态从栈底到栈顶局部变量表核心变化0bipush 10PC 指向 0读取 1 字节参数[10]Slot 1 仍为空2istore_1PC 跳到 2执行单字节存表[]空Slot 1 写入 103bipush 20PC 跳到 3读取 1 字节参数[20]Slot 2 仍为空5istore_2PC 跳到 5执行单字节存表[]空Slot 2 写入 206iload_1PC 跳到 6读取 Slot 1[10]局部变量表只读不变7iload_2PC 跳到 7读取 Slot 2[10, 20]达到最大栈深 2局部变量表只读不变8iaddPC 跳到 8执行两数相加[30]消耗两个推入一个局部变量表只读不变9istore_3PC 跳到 9弹出结果落袋[]空Slot 3 写入 30四、执行到return时栈帧是怎样瞬间消失的算完加法后第 10 行指令是return10: return因为main方法声明为void返回值所以这里的return指令不需要像ireturn那样把计算结果推到调用者的操作数栈上。在很多刚接触 JVM 的同学印象中既然变量 a、b、c 都分配了内存方法结束之后是不是需要垃圾回收器GC来把这几个变量清理掉答案是根本不需要GC 连看都不会看它们一眼。因为这 4 个变量全都在栈帧内部而栈帧是直接分配在线程私有的虚拟机栈上的。当解释器执行到return指令时JVM 只做了一件非常轻量的事情出栈Pop Frame。当前 main 栈帧 (a, b, c)调用者栈帧线程虚拟机栈当前 main 栈帧 (a, b, c)调用者栈帧线程虚拟机栈当前栈顶指针指向 main 栈帧局部变量表与操作数栈内存瞬间作废执行 10: return 指令1栈帧弹出销毁Pop Frame2栈顶指针回退恢复调用者上下文3所谓栈帧销毁在底层只是让线程的栈顶指针往回移动了一个栈帧的偏移量。一旦指针后移原先分配给main方法的局部变量表、操作数栈所占用的那块栈内存在逻辑上就已经不复存在了。下次再调用新方法时新栈帧会直接覆盖这块连续的内存区域。整个销毁过程耗时只有O ( 1 ) O(1)O(1)干净利落完全不产生内存碎片更不需要垃圾收集线程跑过来扫描标记。这就是为什么无论系统高并发调用多少次方法虚拟机栈自身永远不需要 GC 的根本原因。五、写在最后回过头来看这 8 条看似笨拙的字节码指令JVM 解释器的设计哲学其实非常务实它宁愿在内存和寄存器之间多走几趟“数据倒腾”也不愿意把任何一条算术指令和底层的硬件寄存器深度绑定。正是这种看似牺牲局部效率的抽象设计换来了 Java 生态二十多年来在各种服务器、芯片架构之间如履平地的跨平台自由。如果你觉得本文把执行引擎的指令链路讲透了记得点个关注。后面我们会顺着执行引擎继续深挖 JIT 即时编译器的逃逸分析与热点探测看 JVM 是如何把这些看似繁琐的字节码指令在运行时优化成极致原生机器码的。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →