前言
很多人知道方法调用会压入栈帧,但栈帧内部靠什么存放变量?算个加法 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)。它在纯软件层面抽象出了两个专属于当前方法的存储结构:
- 局部变量表:专门负责存放方法参数以及方法内部定义的局部变量,充当数据的大本营。
- 操作数栈:专门负责在执行字节码指令时充当临时计算工作台,数据的加载、相加、比较全部在这里完成。
这种设计的代价是显而易见的:物理机一步到位的计算,在 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 个 Slot),JVM 就会自动读取连续的两个 Slot。
这种连续分配在绝大多数现代操作系统和 64 位硬件上都是平稳高效的。
2.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[]placeholder=newbyte[64*1024*1024];// 64MB 临时数组}intcount=100;System.gc();}}在这个例子中:
- 进入代码块内部时,
placeholder引用被分配在局部变量表里。 - 离开代码块后,
placeholder的作用域已经结束。 - 紧接着声明了
int count = 100;,由于count只需要 1 个 Slot,它会直接复用之前placeholder释放出来的那个 Slot。 - 此时
placeholder原本引用的 64MB 堆内存对象因为在局部变量表中已经没有任何 Slot 指向它,在随后的System.gc()中就会被顺利回收。
假如我们把int count = 100;这行代码删掉,只保留代码块和System.gc()呢?
很多人会理所当然地认为:代码块已经走完了,placeholder已经失效了,GC 肯定会把它回收掉。
事实恰恰相反:如果不声明新的局部变量去复用那个 Slot,局部变量表里的这个 Slot 仍然完整地保留着指向堆内存数组的指针,GC Roots 可达性分析依然判定该数组存活,这 64MB 内存根本无法被回收。
这是理解 JVM 栈帧内存模型时最生动、最反常识的一个细节。
三、操作数栈是如何协同算数的
搞懂了局部变量表的数据存储,我们再来看操作数栈。
操作数栈也是一个标准的栈数据结构(LIFO,后进先出)。它的作用非常纯粹:给执行引擎提供操作数的输入,并承接操作数执行后的输出结果。
3.1 从一段最简单的加法指令看起
为了把操作数栈的工作机制讲透,我们写一段极其干净的加法代码,并用javap -c将其编译后的字节码打出来看:
packagecom.crayontech.jvm;publicclassCalcDemo{publicintadd(){inta=2;intb=3;intc=a+b;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 字节码指令在栈帧里的完整流转
为了建立最直观的空间流动感,我们把上述指令执行过程中的栈帧状态切片画出来:
整个过程严格遵循先进后出:
iload_1先执行,数值 2 先进栈,沉在栈底;iload_2后执行,数值 3 后进栈,浮在栈顶;iadd启动时,直接弹出栈顶的 3 作为第二个加数,再弹出栈底的 2 作为第一个加数,执行运算后压入 5;istore_3将 5 弹出,写入当前栈帧局部变量表的 Slot 3。
这种基于栈的指令设计,使得指令本身非常短小(很多指令只有 1 个字节长),没有冗长的操作数地址字段,非常利于网络传输与快速解释执行。
四、栈深与局部变量表大小并不是动态扩容的
很多人在学习集合类(比如ArrayList、HashMap)时建立了一种思维惯性:方法里定义的变量越多,局部变量表就会动态扩容;方法里调用的嵌套表达式越长,操作数栈就会动态变深。
这是一个非常普遍的误解。
在 Java 代码被javac编译为.class文件的那一刻起,每个方法对应的栈帧所需要的最大局部变量表槽位数和最大操作数栈深度就已经是铁板钉钉的固定值了。
我们使用javap -v CalcDemo.class查看更详细的类元数据信息:
public int add(); descriptor: ()I flags: (0x0001) ACC_PUBLIC Code: stack=2, locals=4, args_size=1 0: iconst_2 1: istore_1 2: iconst_3 ...注意看Code属性里的关键参数:stack=2, locals=4, args_size=1。
编译器在对语法树进行静态分析时,就已经把代码的所有执行路径彻底推演了一遍:
locals=4:局部变量表只需要 4 个 Slot。- Slot 0:给当前对象的
this指针(因为这是实例方法); - Slot 1:给变量
a; - Slot 2:给变量
b; - Slot 3:给变量
c。
即便方法体内有多个分支分支,编译器也会结合 Slot 复用机制计算出整段方法生命周期内同一时刻所需的最大槽位数。
- Slot 0:给当前对象的
stack=2:操作数栈的最大深度永远不会超过 2。- 在上述计算链路中,栈里同时存在的最多元素就是执行
iadd前的那一刻,栈底是 2,栈顶是 3,深度刚好为 2。 - 后面执行
iadd时弹出两个压入一个,深度降为 1。因此给该方法分配深度为 2 的操作数栈,就绝无溢出风险。
- 在上述计算链路中,栈里同时存在的最多元素就是执行
args_size=1:方法的外部入参只占 1 个 Slot,即默认传入的this。
这种在编译期就把空间算死的确定性,为 JVM 运行期的极致性能提供了强有力的保障:
- 线程在调用方法并压入栈帧时,JVM 不需要边执行边判断“内存够不够用、要不要扩容”;
- 而是拿着
stack和locals的数值,直接在连续的栈内存上切出一块精准大小的内存块; - 读写局部变量表完全可以通过基址加固定偏移量(Offset)来寻址,速度几乎等同于原生数组。
五、写在最后
总结一下局部变量表与操作数栈的分工关系:
- 局部变量表是方法的静态数据底座。它以 Slot 为最小单元组织存储,实例方法默认把
this锁死在 Slot 0,作用域结束的槽位会被后续变量积极复用; - 操作数栈是字节码指令的动态操作台。它用先进后出的栈结构承接所有的运算输入与输出,消除了对硬件底层物理寄存器的依赖;
- 它们的大小在编译阶段就由编译器静态推演并写入了 Class 文件的
Code属性中,运行期按图索骥、一次性分配,没有任何动态扩容的拖泥带水。
理解了这两个核心组件在微观层面的每一次加载、入栈、计算与写回,再去看复杂的 JVM 字节码指令、异常处理表以及即时编译器 JIT 的栈上分配,很多原来看起来晦涩的机制就会瞬间变得顺理成章。
如果这篇拆解帮你搞懂了局部变量表和操作数栈的协同本质,欢迎点个关注,追更本系列的后续更新,我们下一篇接着聊栈帧里的其他秘密!