
前言很多人知道方法调用会压入栈帧但栈帧内部靠什么存放变量算个加法 JVM 为什么非要来回倒腾数据本文带你顺着字节码指令把局部变量表与操作数栈的协同机制彻底拆透。文章目录前言一、为什么Java方法要拆成两套存储结构二、局部变量表到底长什么样2.1 槽位的基本划分与64位类型处理2.2 实例方法里隐藏的0号槽位2.3 槽位复用对内存和GC的影响三、操作数栈是如何协同算数的3.1 从一段最简单的加法指令看起3.2 字节码指令在栈帧里的完整流转四、栈深与局部变量表大小并不是动态扩容的五、写在最后一、为什么Java方法要拆成两套存储结构在 x86 或者 ARM 这些真实的物理硬件上CPU 执行一段加法指令非常直接。比如汇编指令add eax, ebx把两个寄存器里的值碰一下结果直接留在寄存器里耗时通常只有几个时钟周期。但 Java 虚拟机从诞生第一天起核心设计目标就是跨平台和可移植。如果 JVM 的执行引擎直接绑定具体的 CPU 寄存器马上就会遇到一个无法调和的矛盾不同架构的 CPU内部寄存器的数量、命名、位宽差别巨大。x86-64 架构有 16 个通用寄存器ARM64 有 30 多个通用寄存器而在一些嵌入式芯片上可能只有少得可怜的几个寄存器。一旦字节码指令里写死了“把值放到第 8 号寄存器”这份字节码到了寄存器数量不够的芯片上就彻底趴窝了。为了让同一份字节码在任何硬件平台上都能跑通JVM 采用了基于栈的架构Stack-based Architecture。它在纯软件层面抽象出了两个专属于当前方法的存储结构局部变量表专门负责存放方法参数以及方法内部定义的局部变量充当数据的大本营。操作数栈专门负责在执行字节码指令时充当临时计算工作台数据的加载、相加、比较全部在这里完成。Java 虚拟机 / 基于栈的架构跨平台抽象加载指令iload_x运算指令iadd / isub / imul存储指令istore_x局部变量表Local Variable Table方法参数与局部变量的常驻仓库操作数栈Operand Stack指令执行时的动态计算工作台JVM 执行引擎Execution Engine传统物理机 / 寄存器架构依赖具体硬件直接读取寄存器直接读取寄存器直接写回寄存器CPU 寄存器 A如 EAX算术逻辑单元 ALUCPU 寄存器 B如 EBX这种设计的代价是显而易见的物理机一步到位的计算在 JVM 里需要先从局部变量表复制到操作数栈由执行引擎算完后再从操作数栈复制回局部变量表。但它换来的好处更加根本指令集极度紧凑而且完全不需要关心底层芯片到底有多少个物理寄存器。接下来我们分别来看这两个组件的内部构造。二、局部变量表到底长什么样局部变量表是一组变量值存储空间的数组主要用于存储方法参数和方法内部定义的局部变量。它的最小存储单元被称为变量槽Variable Slot简称 Slot。2.1 槽位的基本划分与64位类型处理Java 虚拟机规范并没有强制规定一个 Slot 必须占用几个字节的物理内存尽管在大多数 32 位和 64 位的 JVM 实现中一个 Slot 通常被映射为 4 字节 / 32 位的大小。它规范的是数据类型的存放规则占用 1 个 Slot 的类型boolean、byte、char、short、int、float、reference对象引用和returnAddress。其中boolean、byte、char、short在编译期或运行期都会被转换成int来处理。占用 2 个 Slot 的类型64 位的long和double。对于 64 位的数据类型JVM 会以高位对齐的方式为其连续分配两个 Slot。比如一个long类型的变量占用了第 2 和第 3 两个槽位当字节码指令想要读取它时只需要指定起始的索引值比如第 2 个 SlotJVM 就会自动读取连续的两个 Slot。这种连续分配在绝大多数现代操作系统和 64 位硬件上都是平稳高效的。局部变量表槽位数组以 Slot 为单元Slot 0: int a (32位占用1个槽)Slot 1: reference user (引用类型占用1个槽)Slot 2: long timestamp (低32位)Slot 3: long timestamp (高32位与前一槽联合使用)Slot 4: double score (连续占用2个槽)Slot 5: double score2.2 实例方法里隐藏的0号槽位我们在写 Java 实例方法时随时随地都可以使用this关键字来访问当前对象的成员变量或调用其他方法。你有没有想过方法明明没有定义任何叫this的入参这个this到底是从哪里来的答案就在局部变量表的 Slot 0 里。编译器在把 Java 代码编译为字节码时如果检测到这是一个实例方法即非 static 方法就会悄悄在局部变量表的最前头空出第 0 个槽位专门用来存放调用当前方法的那个对象实例引用。packagecom.crayontech.jvm;publicclassSlotDemo{// 实例方法隐含 this 参数publicvoidinstanceMethod(intx,Stringname){// Slot 0: this// Slot 1: int x// Slot 2: String name}// 静态方法没有 this 参数publicstaticvoidstaticMethod(intx,Stringname){// Slot 0: int x// Slot 1: String name}}正因为实例方法的 Slot 0 被this牢牢占据了所以参数列表里声明的参数只能从 Slot 1 开始依次存放接着才是方法内部定义的局部变量。而对于static静态方法由于它属于类本身而不是某个具体的对象实例所以不需要传入this局部变量表的 Slot 0 就会直接分配给方法的第一个实际参数。这也解释了一个高频的语法现象为什么静态方法内部绝对无法使用this关键字因为从栈帧初始化的那一刻起局部变量表里根本就没有存过当前对象的引用。2.3 槽位复用对内存和GC的影响局部变量表并不是为方法里出现的每一个变量都单独开辟一个独立的 Slot。为了尽可能节省栈帧所占用的内存空间局部变量表引入了Slot 复用机制。当方法内部的一段代码执行完毕某个局部变量的作用域Scope已经结束该变量所占用的 Slot 就可以直接分配给后续声明的其他局部变量使用。packagecom.crayontech.jvm;publicclassSlotReuseDemo{publicvoidtestReuse(){{byte[]placeholdernewbyte[64*1024*1024];// 64MB 临时数组}intcount100;System.gc();}}在这个例子中进入代码块内部时placeholder引用被分配在局部变量表里。离开代码块后placeholder的作用域已经结束。紧接着声明了int count 100;由于count只需要 1 个 Slot它会直接复用之前placeholder释放出来的那个 Slot。此时placeholder原本引用的 64MB 堆内存对象因为在局部变量表中已经没有任何 Slot 指向它在随后的System.gc()中就会被顺利回收。阶段二离开作用域并声明新变量已无指针指向Slot 1: int count 100槽位被直接复用覆盖64MB 堆内存数组引用断开变为不可达阶段一代码块执行中强引用持有Slot 1: byte[] placeholder64MB 堆内存数组假如我们把int count 100;这行代码删掉只保留代码块和System.gc()呢很多人会理所当然地认为代码块已经走完了placeholder已经失效了GC 肯定会把它回收掉。事实恰恰相反如果不声明新的局部变量去复用那个 Slot局部变量表里的这个 Slot 仍然完整地保留着指向堆内存数组的指针GC Roots 可达性分析依然判定该数组存活这 64MB 内存根本无法被回收。这是理解 JVM 栈帧内存模型时最生动、最反常识的一个细节。三、操作数栈是如何协同算数的搞懂了局部变量表的数据存储我们再来看操作数栈。操作数栈也是一个标准的栈数据结构LIFO后进先出。它的作用非常纯粹给执行引擎提供操作数的输入并承接操作数执行后的输出结果。3.1 从一段最简单的加法指令看起为了把操作数栈的工作机制讲透我们写一段极其干净的加法代码并用javap -c将其编译后的字节码打出来看packagecom.crayontech.jvm;publicclassCalcDemo{publicintadd(){inta2;intb3;intcab;returnc;}}执行javap -c CalcDemo.class得到add()方法的字节码指令流如下public int add(); Code: 0: iconst_2 // 将常数 2 压入操作数栈 1: istore_1 // 弹出栈顶常数 2存入局部变量表 Slot 1 (对应变量 a) 2: iconst_3 // 将常数 3 压入操作数栈 3: istore_2 // 弹出栈顶常数 3存入局部变量表 Slot 2 (对应变量 b) 4: iload_1 // 从局部变量表 Slot 1 复制数值 2压入操作数栈 5: iload_2 // 从局部变量表 Slot 2 复制数值 3压入操作数栈 6: iadd // 弹出栈顶的两个整数相加将结果 5 重新压回操作数栈顶 7: istore_3 // 弹出栈顶结果 5存入局部变量表 Slot 3 (对应变量 c) 8: iload_3 // 从局部变量表 Slot 3 复制数值 5压入操作数栈 9: ireturn // 将操作数栈顶的整数 5 返回给方法调用方初看这段字节码大家的第一反应往往是这简直是脱裤子放屁把 2 和 3 存进局部变量表紧接着又把它们 load 到操作数栈加完之后存进局部变量表最后为了 return 又 load 了一遍为什么一定要这么做因为操作数栈是严格的计算执行中心。像iadd这种算术指令它的设计规范就是不接受任何显式参数它只与操作数栈顶的元素交互从栈顶弹出第一个操作数从栈顶弹出第二个操作数送入执行引擎做相加把求和结果压回栈顶。所有运算指令都不直接与局部变量表发生物理接触。局部变量表是静态数据仓库操作数栈是动态生产流水线。要生产就必须把原料搬上流水线生产完毕再把成品搬回仓库封存。3.2 字节码指令在栈帧里的完整流转为了建立最直观的空间流动感我们把上述指令执行过程中的栈帧状态切片画出来第 7 步istore_3 存储到局部变量 c写回局部变量表操作数栈弹出 5局部变量表Slot 0: thisSlot 1: 2 (a)Slot 2: 3 (b)Slot 3: 5 (c)第 6 步执行 iadd 计算操作数栈弹出 3 和 2执行引擎相加 (2 3 5)操作数栈压入结果【栈顶】5第 4 步iload_1 与 iload_2 加载完成复制值入栈局部变量表Slot 0: thisSlot 1: 2 (a)Slot 2: 3 (b)操作数栈深2【栈顶】3【栈底】2整个过程严格遵循先进后出iload_1先执行数值 2 先进栈沉在栈底iload_2后执行数值 3 后进栈浮在栈顶iadd启动时直接弹出栈顶的 3 作为第二个加数再弹出栈底的 2 作为第一个加数执行运算后压入 5istore_3将 5 弹出写入当前栈帧局部变量表的 Slot 3。这种基于栈的指令设计使得指令本身非常短小很多指令只有 1 个字节长没有冗长的操作数地址字段非常利于网络传输与快速解释执行。四、栈深与局部变量表大小并不是动态扩容的很多人在学习集合类比如ArrayList、HashMap时建立了一种思维惯性方法里定义的变量越多局部变量表就会动态扩容方法里调用的嵌套表达式越长操作数栈就会动态变深。这是一个非常普遍的误解。在 Java 代码被javac编译为.class文件的那一刻起每个方法对应的栈帧所需要的最大局部变量表槽位数和最大操作数栈深度就已经是铁板钉钉的固定值了。我们使用javap -v CalcDemo.class查看更详细的类元数据信息public int add(); descriptor: ()I flags: (0x0001) ACC_PUBLIC Code: stack2, locals4, args_size1 0: iconst_2 1: istore_1 2: iconst_3 ...注意看Code属性里的关键参数stack2, locals4, args_size1。编译器在对语法树进行静态分析时就已经把代码的所有执行路径彻底推演了一遍locals4局部变量表只需要 4 个 Slot。Slot 0给当前对象的this指针因为这是实例方法Slot 1给变量aSlot 2给变量bSlot 3给变量c。即便方法体内有多个分支分支编译器也会结合 Slot 复用机制计算出整段方法生命周期内同一时刻所需的最大槽位数。stack2操作数栈的最大深度永远不会超过 2。在上述计算链路中栈里同时存在的最多元素就是执行iadd前的那一刻栈底是 2栈顶是 3深度刚好为 2。后面执行iadd时弹出两个压入一个深度降为 1。因此给该方法分配深度为 2 的操作数栈就绝无溢出风险。args_size1方法的外部入参只占 1 个 Slot即默认传入的this。精确预先规划运行期JVM 线程执行方法根据 Class 元数据直接一次性开辟栈帧内存固定步长偏移访问零动态扩容开销编译期javac 静态分析分析代码语法树与控制流图推演 Slot 复用与最大栈操作峰值写死在 Class 文件 Code 属性中stack2, locals4, args_size1这种在编译期就把空间算死的确定性为 JVM 运行期的极致性能提供了强有力的保障线程在调用方法并压入栈帧时JVM 不需要边执行边判断“内存够不够用、要不要扩容”而是拿着stack和locals的数值直接在连续的栈内存上切出一块精准大小的内存块读写局部变量表完全可以通过基址加固定偏移量Offset来寻址速度几乎等同于原生数组。五、写在最后总结一下局部变量表与操作数栈的分工关系局部变量表是方法的静态数据底座。它以 Slot 为最小单元组织存储实例方法默认把this锁死在 Slot 0作用域结束的槽位会被后续变量积极复用操作数栈是字节码指令的动态操作台。它用先进后出的栈结构承接所有的运算输入与输出消除了对硬件底层物理寄存器的依赖它们的大小在编译阶段就由编译器静态推演并写入了 Class 文件的Code属性中运行期按图索骥、一次性分配没有任何动态扩容的拖泥带水。理解了这两个核心组件在微观层面的每一次加载、入栈、计算与写回再去看复杂的 JVM 字节码指令、异常处理表以及即时编译器 JIT 的栈上分配很多原来看起来晦涩的机制就会瞬间变得顺理成章。如果这篇拆解帮你搞懂了局部变量表和操作数栈的协同本质欢迎点个关注追更本系列的后续更新我们下一篇接着聊栈帧里的其他秘密