目录
[2.6、生命周期 & 作用域](#2.6、生命周期 & 作用域)
[4、动态链接(Dynamic Linking)](#4、动态链接(Dynamic Linking))
[5、方法返回地址(Return Address)](#5、方法返回地址(Return Address))
[5.5、动态链接 vs 符号引用 vs 直接引用](#5.5、动态链接 vs 符号引用 vs 直接引用)
前沿
在 Java 虚拟机中,每一次方法调用都不是凭空发生的------JVM 会为它创建一个专属的运行时上下文,这个上下文就是 栈帧(Stack Frame)。
如下所示:

那么,一个方法要顺利完成它的使命,到底需要哪些"基础设施"?
首先,它必须能存放自己的变量:参数从哪里来?局部变量存在哪?
→ 于是有了 局部变量表(Local Variable Table) ------ 它是方法的"变量仓库"。
如下所示:

其次,光有变量还不够,计算总得有个"操作台"。比如 a + b * c,中间结果放哪儿?
→ 于是有了 操作数栈(Operand Stack) ------ 它是 JVM 执行字节码的"运算工作台"。
再者,Java 是动态语言,方法调用往往不能在编译期确定目标。比如 list.add(),到底是 ArrayList 还是 LinkedList 的实现?
→ 于是有了 动态链接(Dynamic Linking) ------ 它让栈帧能回溯到运行时常量池,实现多态与符号解析。
最后,方法执行完不能"一走了之",必须知道回到哪里继续执行。A 调用 B,B 结束后要精准跳回 A 的下一行。
→ 于是有了 方法返回地址(Return Address) ------ 它是控制流的"归途信标"。
如下所示:

正是这四个核心组件------存(局部变量表)、算(操作数栈)、链(动态链接)、返(返回地址)------共同构成了栈帧的完整骨架,支撑起 Java 方法调用的动态性、安全性与高效性。
可以说:栈帧虽小,五脏俱全;四要素协同,方成执行闭环。
1、栈帧
1.1、组成
如下所示:

1.2、工作原理
四要素协同全景图如下所示:

核心作用总结:

方法需要存储自己的变量
-
参数怎么传进来?
-
局部变量存在哪?
-
→ 引出 局部变量表(Local Variable Table)
💬 "方法不能凭空计算,它需要地方存放输入参数和中间变量。"
2、局部变量表
栈帧(Stack Frame)中的局部变量表(Local Variable Table) 是 JVM 运行时数据区中非常核心的概念,它直接关系到方法如何存储和访问局部变量。
下面我们从 结构、作用、生命周期、实战示例 四个维度深入讲解。
如下所示:

2.1、介绍
每个方法调用都会在 Java 虚拟机栈(JVM Stack) 中创建一个栈帧:
用于存储方法参数和方法内部定义的局部变量。
如下所示:
bash
+-----------------------------+
| 局部变量表 | ← 本章主角
+-----------------------------+
| 操作数栈 |
+-----------------------------+
| 动态链接 | (指向运行时常量池的方法引用)
+-----------------------------+
| 方法返回地址 |
+-----------------------------+
是栈帧的"寄存器文件" ------ 虽然 JVM 是基于栈的,但局部变量表提供了类似寄存器的快速访问能力。
每个 栈帧 对应一个 正在执行的方法:
-
每个栈帧在创建时,会根据方法的字节码 预先分配固定大小的局部变量表
-
表中的每个"槽位"(Slot)可存储:
-
一个 32 位 数据类型:boolean,byte,char,short,int,float, 引用类型(reference)
-
两个连续槽位存储 64 位 类型:long , double
-
示例:.class
- 非 static:[0:this, 1:a(int), 2-3:b[long]] → 共 4 个 Slot
- static:[0:a(int), 1-2:b(long)] → 共 3 个 Slot
📌 注意 :局部变量表的大小在 编译期就确定了 ,写入.class 文件的
Code属性中。
2.2、内存结构
- 物理存储
-
局部变量表是 连续的内存区域 ,由若干 Slot(槽) 组成
-
每个 Slot 大小 = 虚拟机字长(word size) ,通常是 32 位(4 字节)
-
即使
byte只需 1 字节,也占满 1 个 Slot(JVM 规范要求)
- 索引(Index)从 0 开始
-
索引 0 ~ n-1 对应变量
-
对于非 static 方法:
-
index=0存放this引用(隐式参数) -
index=1开始存放显式参数
-
-
对于 static 方法:
index=0直接存放第一个参数
- 槽位(Slot)复用
JVM 会 复用不再使用的槽位,以节省空间。
2.3、最佳实践
示例代码:
java
public class LocalVarDemo {
public void test(int a, int b) {
int c = a + b;
{
int d = 10;
System.out.println(d);
}
// d 已超出作用域
int e = 20;
System.out.println(e);
}
}
编译后查看字节码:
bash
javac LocalVarDemo.java
javap -v LocalVarDemo
关键输出(简化):
bash
public void test(int, int);
descriptor: (II)V
flags: ACC_PUBLIC
Code:
stack=2, locals=5, args_size=3
0: iload_1 // 加载 a (index=1)
1: iload_2 // 加载 b (index=2)
2: iadd
3: istore_3 // c = a + b → 存入 index=3
4: bipush 10
6: istore 4 // d = 10 → 存入 index=4
8: getstatic #2 // Field out:Ljava/io/PrintStream;
11: iload 4 // 加载 d
13: invokevirtual #3 // Method println:(I)V
16: bipush 20
18: istore 4 // e = 20 → **复用 index=4(d 已失效)**
20: getstatic #2
23: iload 4
25: invokevirtual #3
28: return
LocalVariableTable:
Start Length Slot Name Signature
0 29 0 this LLocalVarDemo;
0 29 1 a I
0 29 2 b I
3 26 3 c I
4 12 4 d I ← d 的作用域:PC=4 到 PC=16
16 13 4 e I ← e 复用 slot=4
2.4、字节码指令如何操作局部变量表
JVM 提供两类指令访问局部变量表:
A. 加载指令(Load):从局部变量表 → 操作数栈 this
| 指令 | 说明 |
|---|---|
| iload_n | 加载 int 型变量(n=0~3,快速指令) |
| iload index | 加载 int 型变量(任意 index) |
| iload, fload, dload, aload | 对应 long, float, double, reference |
| aload_0 | 常用于加载 this |
B. 存储指令(Store):从操作数栈 → 局部变量表
| 指令 | 说明 |
|---|---|
| istore_n | 存储 int 到局部变量(n=0~3) |
| istore index | 存储 int 到任意 index |
| istore, fstore, dstore, astore | 对应其他类型 |
📌 注意 :long/double 的 load/store 指令会操作 两个连续 Slot
- locals = 5是什么意思?
-
表示该方法最多同时使用 5 个局部变量槽位
-
包括:this(0),a(1),b(2),c(3),d/e(4)
2. 为什么e和d共用 slot=4?
-
因为
d的作用域在{}内结束(字节码位置 16) -
JVM 智能复用已释放的槽位给新变量 e
-
这是编译器优化,不影响语义
JIT 编译后的优化
-
HotSpot JIT 会将频繁访问的局部变量 提升到 CPU 寄存器
-
局部变量表在 JIT 后可能被完全优化掉(仅存在于解释执行阶段)
3. LocalVariableTable 属性的作用
-
仅用于 调试和反编译(如 IDE 显示变量名)
-
运行时 JVM 不依赖它 !实际访问靠 索引(index)
-
如果用 javac -g:none 编译,此表会被移除,但程序仍正常运行
特殊类型处理:
| 类型 | 占用槽位 | 说明 |
|---|---|---|
| int,float, 引用 | 1 | 标准 32 位 |
| long,double | 2 | 使用两个连续槽位(如 index=3 和 4) |
| boolean,byte 等 | 1 | 虽小,仍占 1 槽(JVM 规范要求) |
⚠️ 注意:long/double 的操作是 非原子的(除非用 volatile),因为涉及两个槽位。
2.6、生命周期 & 作用域
-
创建:方法被调用时,栈帧入栈,局部变量表分配 volatile
-
初始化:
-
参数由调用者传入(存入对应 slot)
-
局部变量 不会自动初始化!必须显式赋值(否则编译报错)
-
-
销毁:方法返回时,栈帧出栈,整个局部变量表释放
💡 Java 要求局部变量 必须先赋值再使用,而成员变量有默认值 ------ 正是因为局部变量表不初始化!
JVM 规范对局部变量表的定义(JSR 924),根据《The Java® Virtual Machine Specification》:
"Each frame contains an array of local variables... The length of the local variable array is determined at compile-time."
关键点:
-
大小在编译期确定 → 写入 .class 文件的 code 属性的 max_locals
-
不初始化 → 局部变量必须显式赋值(否则编译失败)
-
作用域由编译器管理 → JVM 只认 Slot index,不关心变量名或作用域
2.7、与操作数栈的关系
JVM 是 基于栈的虚拟机 ,指令通过 操作数栈(Operand Stack) 运算:
bash
iload_1 → 将 local[1] 压入操作数栈
iload_2 → 将 local[2] 压入操作数栈
iadd → 弹出两个 int,相加,结果压栈
istore_3 → 弹出栈顶,存入 local[3]
局部变量表 ↔ 操作数栈:数据在这两者之间流动。
陷阱 1:未初始化的局部变量
java
public void bad() {
int x;
System.out.println(x); // 编译错误!"variable x might not have been initialized"
}
原因:局部变量表不会自动初始化为 0,必须显式赋值。
陷阱 2:作用域结束 ≠ 立即释放
java
public void scopeDemo() {
{
Object big = new byte[1024*1024]; // 1MB 对象
} // big 超出作用域
// 此时 big 引用仍在 Slot 中!对象无法被 GC
System.gc();
// 解决方案:手动置 null
// big = null; (但变量已不可见)
}
解决方案 :将大对象放在独立方法中,或显式设为 null(如果还能访问)。
💡 JVM 不会在作用域结束时清空 Slot!Slot 内容直到被新变量覆盖才消失。
陷阱 3:64 位类型的非原子性
java
// 线程 A
long value = 0x1122334455667788L;
// 线程 B
long read = value;
⚠️ 在 32 位 JVM 上,read 可能读到 高 32 位来自旧值,低 32 位来自新值!
✅ 解决:用 volatile 修饰 long/double,保证原子性(JVM 规范要求)。
总结如下:
| 误区 | 正确理解 |
|---|---|
| "局部变量存在堆里" | 局部变量(基本类型)存在 栈帧的局部变量表 中;只有对象实例在堆 |
| "局部变量表 = 变量名列表" | 运行时只认 索引,变量名仅用于调试 |
| "每个变量独占一个 slot" | JVM 会复用作用域结束的 slot |
| "static 方法没有 this" | 所以参数从 index=0 开始 |
计算过程需要临时空间
-
a + b * c这种表达式怎么算?
-
JVM 是基于栈的虚拟机,不能像寄存器机器那样直接操作变量
-
→ 引出 操作数栈(Operand Stack)
💬 "光有变量不够,还得有个'运算台'来放中间结果------这就是操作数栈。"
3、操作数栈
3.1、介绍
是 JVM 栈帧中的一个后进先出(LIFO)的栈结构,用于存储计算过程中的中间结果、方法参数传递和返回值。
3.2、核心特点
-
每个栈帧都有自己的操作数栈
-
大小在编译期确定(写入 .class 文件的 max_stack)
-
所有 JVM 指令的操作对象都来自操作数栈
-
不存储变量名,只存值
可以把操作数栈想象成 CPU 的运算寄存器堆栈 ,而局部变量表是 本地变量存储区。
3.3、角色对比
局部变量表 vs 操作数栈如下所示:

JVM 指令如何联动两者,如下所示:

- 从局部变量表 → 操作数栈(Load 指令)
bash
iload_1 → 将 local[1] 压栈(int)
lload_2 → 将 local[2] 和 local[3] 压栈(long 占 2 slot)
aload_0 → 将 local[0](this)压栈
- 从操作数栈 → 局部变量表(Store 指令)
bash
istore_3 → 弹出栈顶 int → 存入 local[3]
astore_1 → 弹出引用 → 存入 local[1]
- 操作数栈内部运算(Arithmetic/Logic)
bash
iadd → pop 2 int, push (a+b)
imul → pop 2 int, push (a*b)
if_icmpeq → pop 2 int, compare, jump if equal
- 方法调用(参数传递)
bash
foo(a, b);
字节码:
iload_1 // a → 压栈
iload_2 // b → 压栈
invokevirtual #5 // 调用 foo,JVM 自动将栈顶参数弹出,传给新栈帧
参数传递本质 :调用者将参数压入自己的操作数栈 → 被调用者从自己的局部变量表读取参数
3.4、如何协同工作
------ 以 a + b 为例,Java 源码:
java
public int add(int a, int b) {
int c = a + b;
return c;
}
字节码(javap -c):
cpp
public int add(int, int);
Code:
0: iload_1 // ① 从局部变量表[1]加载 a → 压入操作数栈
1: iload_2 // ② 从局部变量表[2]加载 b → 压入操作数栈
2: iadd // ③ 弹出栈顶两个 int,相加,结果压栈
3: istore_3 // ④ 弹出栈顶结果 → 存入局部变量表[3](c)
4: iload_3 // ⑤ 加载 c → 压栈(准备返回)
5: ireturn // ⑥ 弹出栈顶 → 作为返回值
执行过程可视化:[]
| 步骤 | 操作 | 局部变量表 | 操作数栈 |
|---|---|---|---|
| 初始 | --- | [this, a=5, b=3, ? ,?] | [ ] |
| ① iload_1 | 加载 a | 不变 | [5] |
| ② iload_2 | 加载 b | 不变 | [5,3] |
| ③ iadd | 相加 | 不变 | [8] |
| ④ istore_3 | 存 c | [this,5,3,8,?] | [ ] |
| ⑤ iload_3 | 加载 c | 不变 | [8] |
| ⑥ ireturn | 返回 | --- | --- |
所有计算都在操作数栈上完成,局部变量表只负责"存"和"取"。
3.5、内存布局与性能
栈帧内存结构(简化):
bash
+---------------------+
| 局部变量表 | ← 固定大小,随机访问
+---------------------+
| 操作数栈 | ← 动态使用,但最大深度固定(max_stack)
+---------------------+
| 其他(动态链接等)|
+---------------------+
性能影响:
-
操作数栈深度过大 → 增加栈帧内存占用
-
频繁 load/store → 增加指令数量(但 JIT 会优化)
-
小方法更高效 → 栈帧小,操作数栈浅,易被 JIT 内联
💡 HotSpot JIT 会将热点代码中的局部变量和操作数栈 优化到 CPU 寄存器,完全绕过内存操作。
方法要知道自己属于哪个类、调用的是哪个方法
-
字节码里写的是 invokevirtual
#5,#5 是什么? -
多态怎么实现?List list=new ArrayList()
;list.add(...)到底调谁? -
→ 引出 动态链接(Dynamic Linking)
💬 "方法不能孤立存在,它必须能回溯到自己的定义------通过动态链接指向运行时常量池。"
4、动态链接(Dynamic Linking)
每个方法调用都会在 JVM 虚拟机栈中创建一个栈帧,包含:
bash
+-----------------------------+
| 局部变量表 | ← 存储参数和局部变量
+-----------------------------+
| 操作数栈 | ← 执行计算的"工作台"
+-----------------------------+
| 动态链接 | ← 指向运行时常量池的方法引用
+-----------------------------+
| 方法返回地址 | ← 方法执行完后跳转的位置
+-----------------------------+
方法返回地址(Return Address) 和 动态链接(Dynamic Linking) 。它们与 局部变量表 、操作数栈 共同构成了方法调用与执行的完整运行时上下文。
4.1、使用背景
**指栈帧中保存的一个指向运行时常量池(Runtime Constant Pool)中该方法引用的指针,**用于支持方法调用过程中的符号解析和动态分派。
🔍 为什么需要它?
Java 是 动态链接语言 ------ 方法调用的目标在编译期可能无法完全确定(如多态、接口调用)。JVM 需要在运行时解析方法的实际地址。
示例:
java
List list = new ArrayList<>();
list.add("hello"); // 编译期只知道是 List.add(),运行时才知道是 ArrayList.add()
4.2、动态链接的作用
1、支持符号引用解析
.class 文件中方法调用使用 符号引用 (如 #5 = Methodref(java/util/List.add:(Ljava/lang/Object;)Z);运行时通过动态链接找到 直接引用(内存地址)
2、实现多态(动态分派)
invokevirtual 指令通过动态链接 + 对象实际类型,找到正确的方法实现
3、支持类加载的懒解析
方法首次调用时才解析符号引用(提高启动速度)
2. 内部机制(以 invokevirtual 为例):
-
JVM 从当前栈帧的 动态链接 找到方法所属类的 运行时常量池
-
根据字节码中的索引(如 #5)定位到 Methodref 结构
-
如果未解析,则触发 类加载 → 链接 → 初始化
-
解析后缓存 直接引用(方法入口地址)
-
根据对象的实际类型,在虚方法表(vtable)中查找具体实现
💡 动态链接 = 栈帧 ↔ 运行时常量池 的桥梁
方法执行完后,得知道回到哪里继续执行
-
A 调用 B,B 执行完后不能"消失",必须回到 A 的下一行
-
返回值怎么传回去?
-
→ 引出 方法返回地址(Return Address)
💬 "调用不是单程票,必须有'回家的路'------这就是返回地址的作用。"
5、方法返回地址(Return Address)
5.1、使用目的
方法返回地址是调用者下一条要执行的字节码指令的地址(或偏移量),存储在被调用者的栈帧中,用于方法执行完毕后正确返回。
🔍 为什么需要它?当 A 调用 B 时:
-
A 执行到 invokevirtual
B -
JVM 创建 B 的栈帧,压入虚拟机栈
-
B 执行完后,必须知道 回到 A 的哪一行继续执行
5.2、返回地址的形式
| 返回方式 | 存储内容 | 说明 |
|---|---|---|
| 普通返回(Normal Return) | PC 计数器的值(或字节码偏移) | 如 ireturn, areturn |
| 异常返回(Exceptional Return) | 异常处理器表(Exception Table)入口 | 如抛出未捕获异常 |
⚠️ 注意:
JVM 规范 不要求 返回地址必须显式存储在栈帧中 ------ 它可以由 JVM 实现自行管理(如 HotSpot 使用 C++ 栈帧隐式保存)。
5.3、最佳实践
Java 代码:
java
public class CallDemo {
public int caller() {
int x = 10;
int y = callee(x); // ← 调用点
return y + 1;
}
public int callee(int a) {
return a * 2;
}
}
执行流程分解:
步骤 1:caller() 开始执行
-
创建 caller 栈帧
-
局部变量表: [this,?,?]
-
操作数栈:空
-
动态链接:指向 CallDemo 的运行时常量池 Callee
-
返回地址:无(main 调用)
步骤 2:执行到 callee(x)
-
iload_1 → 将 x=10 压栈
-
invokevirtual
#callee:-
从当前栈帧的 动态链接 找到 callee 的符号引用
-
解析为直接引用(首次调用时)
-
保存返回地址(caller 中下一条指令的 PC 偏移)
-
创建 callee 栈帧,压栈
-
步骤 3:callee() 执行
-
局部变量表: [this, a=10]
-
执行 iload_1
→iconst_2→imul→ireturn -
ireturn:
-
弹出结果(20)→ 保存到临时位置
-
弹出 callee 栈帧
-
跳转到 caller 的返回地址
-
步骤 4:caller() 继续执行
-
操作数栈:
[20](callee 返回值) -
执行 iconst_1
→iadd→ireturn
动态链接 确保找到正确的 callee 方法,返回地址 确保执行流回到 caller 的正确位置。
5.4、不同方法调用指令
JVM 有 5 种方法调用字节码指令,对动态链接的依赖不同,与动态链接的关系如下:
| 指令 | 解析时机 | 是否需要动态链接 | 例子 |
|---|---|---|---|
| invokestatic | 解析期(类加载时) | 否 | 静态方法 |
| invokespecial | 解析期 | 否 | 构造器、私有方法、super 调用 |
| invokevirtual | 运行时(首次调用) | 是 | 虚方法(多态) |
| invokeinterface | 运行时 | 是 | 接口方法 |
| invokedynamic | 运行时(由 Bootstrap Method 决定) | 是 | Lambda、动态语言 |
💡 只有 invokestatic****和 invokespecial****是静态分派,其余都需要运行时动态链接。
5.5、动态链接 vs 符号引用 vs 直接引用
如下所示:

流程:
符号引用(class 文件) → 解析 → 直接引用(运行时常量池) ← 动态链接(栈帧)
5.6、常见问题与优化
Q1: 动态链接会影响性能吗?
-
首次调用有开销(解析符号引用)
-
后续调用极快(直接跳转)
-
JIT 会内联热点方法,完全消除动态链接开销
Q2: 返回地址会导致安全问题吗?
-
不会。JVM 严格管理栈帧,返回地址由虚拟机控制,无法被 Java 代码篡改
-
与 C/C++ 的"返回地址覆盖"漏洞本质不同
Q3: 异常如何影响返回?
-
异常发生时,JVM 查找当前方法的 异常表(Exception Table)
-
如果找到匹配的 catch 块,则跳转到 handler
-
如果没找到,则 异常返回:弹出当前栈帧,向上传播异常
最后的话:
JVM 栈帧是一个精巧的"执行上下文容器" 。它不仅保存了方法运行所需的数据(局部变量、操作数),还维护了 控制流(返回地址) 和 语义链接(动态链接),使得 Java 的动态性、安全性、跨平台性得以实现。