在 Java 虚拟机(JVM)中,栈帧(Stack Frame)是用于支持方法调用和方法执行的关键数据结构。每个方法从调用开始到执行完成,都对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。本文将详细介绍 JVM 栈帧的结构及其组成部分。
1 栈帧的组成
每个栈帧包含以下几个主要部分:
- 局部变量表(Local Variables Table)
- 操作数栈(Operand Stack)
- 动态链接(Dynamic Linking)
- 方法返回地址(Return Address)
- 附加信息(Additional Information)
2 局部变量表(Local Variables Table)
局部变量表用于存储方法中的局部变量和方法参数。在 Java 源代码被编译成字节码文件时,局部变量表的最大容量就已经确定。
2.1 示例代码
考虑以下代码片段:
public class LocalVariablesTable {private void write(int age) {String name = "沉默王二";}
}
write()
方法有一个参数 age
和一个局部变量 name
。
2.2 使用 jclasslib 查看字节码
使用 jclasslib
工具查看编译后的字节码文件 LocalVariablesTable.class
,可以看到 write()
方法的 Code
属性中,Maximum local variables
的值为 3。
理论上,局部变量表的最大容量应该是 2(一个 age
,一个 name
),但实际值为 3。这是因为非静态方法的局部变量表中,第 0 个位置存储的是调用该方法的对象引用 this
。因此,调用 write(18)
实际上是调用 write(this, 18)
。
查看 Code
属性中的 LocalVariableTable
,可以看到详细信息:
- 第 0 个是
this
,类型为LocalVariablesTable
对象。 - 第 1 个是方法参数
age
,类型为整型int
。 - 第 2 个是方法内部的局部变量
name
,类型为字符串String
。
2.3 局部变量表的大小与作用域
局部变量表的大小不仅取决于局部变量的数量,还与变量的类型和作用域有关。当一个局部变量的作用域结束时,它占用的局部变量表位置会被后续的局部变量取代。
考虑以下代码:
public static void method() {// ①if (true) {// ②String name = "沉默王二";}// ③if (true) {// ④int age = 18;}// ⑤
}
method()
方法的局部变量表大小为 1,因为它是静态方法,不需要添加this
作为局部变量表的第一个元素。- 在 ② 处,局部变量
name
的作用域开始,局部变量表的大小为 1。 - 在 ③ 处,局部变量
name
的作用域结束。 - 在 ④ 处,局部变量
age
的作用域开始,局部变量表的大小为 1。 - 在 ⑤ 处,局部变量
age
的作用域结束。
《Effective Java》中的第 57 条建议:
将局部变量的作用域最小化,可以增强代码的可读性和可维护性,并降低出错的可能性。
为了节省栈帧的内存空间,局部变量表中的槽是可以重用的。合理的作用域设计有助于提高程序性能。
2.4 局部变量表的槽(Slot)
局部变量表的容量以槽(slot)为最小单位,一个槽可以容纳一个 32 位的数据类型(如 int
)。对于占用 64 位的数据类型(如 double
和 long
),会占用两个紧挨着的槽。
考虑以下代码:
public void slot() {double d = 1.0;int i = 1;
}
使用 jclasslib
查看 slot()
方法的字节码,可以看到 Maximum local variables
的值为 4。
为什么是 4 呢?加上 this
也只有 3 个变量。查看 LocalVariableTable
可以看到,变量 i
的下标为 3,这意味着变量 d
占了两个槽。
3 操作数栈(Operand Stack)
操作数栈是 JVM 栈帧中的一个重要组成部分,用于在方法执行过程中存储操作数和中间计算结果。与局部变量表类似,操作数栈的最大深度在编译时就已经确定,并写入到方法表的 Code
属性的 maximum stack size
中。
3.1 示例代码
考虑以下代码片段:
public class OperandStack {public void test() {add(1, 2);}private int add(int a, int b) {return a + b;}
}
OperandStack
类包含两个方法:test()
和 add()
。test()
方法调用了 add()
方法,并传递了两个参数。
3.2 使用 jclasslib 查看字节码
使用 jclasslib
工具查看 test()
方法的字节码,可以看到 maximum stack size
的值为 3。
这是因为调用成员方法时,会将 this
和所有参数压入操作数栈中。调用完毕后,this
和参数会依次出栈。通过 Bytecode
面板可以查看对应的字节码指令:
aload_0
:将局部变量表中下标为 0 的引用类型的变量(即this
)加载到操作数栈中。iconst_1
:将整数 1 加载到操作数栈中。iconst_2
:将整数 2 加载到操作数栈中。invokevirtual
:调用对象的成员方法。pop
:将栈顶的值出栈。return
:为void
方法的返回指令。
3.3 add()
方法的字节码指令
再来看一下 add()
方法的字节码指令:
iload_1
:将局部变量表中下标为 1 的int
类型变量(即参数a
)加载到操作数栈上(下标为 0 的是this
)。iload_2
:将局部变量表中下标为 2 的int
类型变量(即参数b
)加载到操作数栈上。iadd
:用于int
类型的加法运算。ireturn
:为返回值为int
的方法返回指令。
3.4 数据类型匹配
操作数栈中的数据类型必须与字节码指令匹配。例如,iadd
指令只能用于整型数据的加法运算。在执行 iadd
指令时,栈顶的两个数据必须是 int
类型,不能出现一个 long
型和一个 double
型的数据进行 iadd
命令相加的情况。
4 动态链接(Dynamic Linking)
动态链接是 JVM 栈帧中的一个重要概念,它允许在运行时根据对象的实际类型来解析方法调用。每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,这个引用用于支持方法调用过程中的动态链接。
4.1 运行时常量池与方法区
在深入理解动态链接之前,我们需要了解两个关键概念:
-
方法区(Method Area):
- 方法区是 JVM 的一个运行时内存区域,用于存储已被虚拟机加载的类信息、常量、静态变量以及即时编译器编译后的代码等。
- 不同版本的 JDK 对方法区的实现可能有所不同,但其主要功能是相同的。
-
运行时常量池(Runtime Constant Pool):
- 运行时常量池是方法区的一部分,用于存放编译期生成的各种字面量和符号引用。
- 在类加载后,这些字面量和符号引用会被加载到运行时常量池中。
4.2 示例代码
考虑以下代码片段:
public class DynamicLinking {static abstract class Human {protected abstract void sayHello();}static class Man extends Human {@Overrideprotected void sayHello() {System.out.println("男人哭吧哭吧不是罪");}}static class Woman extends Human {@Overrideprotected void sayHello() {System.out.println("山下的女人是老虎");}}public static void main(String[] args) {Human man = new Man();Human woman = new Woman();man.sayHello();woman.sayHello();man = new Woman();man.sayHello();}
}
在这段代码中,Man
和 Woman
类继承了 Human
类,并重写了 sayHello()
方法。运行结果如下:
男人哭吧哭吧不是罪
山下的女人是老虎
山下的女人是老虎
从面向对象编程的角度来看,这个结果是符合预期的。man
和 woman
的引用类型都是 Human
,但它们分别指向 Man
和 Woman
对象。之后,man
被重新指向 Woman
对象。
4.3 字节码分析
使用 jclasslib
工具查看 main
方法的字节码指令:
- 第 1 行:
new
指令创建了一个Man
对象,并将对象的内存地址压入栈中。 - 第 2 行:
dup
指令将栈顶的值复制一份并压入栈顶。因为接下来的invokespecial
指令会消耗掉一个当前类的引用,所以需要复制一份。 - 第 3 行:
invokespecial
指令用于调用构造方法进行初始化。 - 第 4 行:
astore_1
指令将栈顶的Man
对象引用弹出,并存入下标为 1 的局部变量man
中。 - 第 5-8 行:与第 1-4 行类似,不同的是创建了
Woman
对象。 - 第 9 行:
aload_1
指令将局部变量man
压入操作数栈中。 - 第 10 行:
invokevirtual
指令调用对象的成员方法sayHello()
,注意此时的对象类型为com/itwanger/jvm/DynamicLinking$Human
。 - 第 11 行:
aload_2
指令将局部变量woman
压入操作数栈中。 - 第 12 行:与第 10 行相同,调用
sayHello()
方法。
4.4 动态链接的解析过程
从字节码的角度来看,man.sayHello()
和 woman.sayHello()
的字节码指令是完全相同的,但它们最终执行的目标方法却不同。这是因为 invokevirtual
指令在运行时的解析过程如下:
- 找到操作数栈顶的元素所指向的对象的实际类型,记作 C。
- 在类型 C 中查找与常量池中的描述符匹配的方法:
- 如果找到匹配的方法,则进行访问权限校验。如果通过,则返回该方法的直接引用,查找结束。
- 如果未通过校验,则抛出
java.lang.IllegalAccessError
异常。
- 如果未找到匹配的方法,则按照继承关系从下往上依次对 C 的各个父类进行第二步的搜索和验证。
- 如果始终没有找到合适的方法,则抛出
java.lang.AbstractMethodError
异常。
4.5 动态链接的本质
invokevirtual
指令在第一步就确定了运行时的实际类型,因此它并不是简单地将常量池中的符号引用解析为直接引用就结束了。它会根据方法接收者的实际类型来选择方法版本,这个过程就是 Java 重写的本质。我们把这种在运行期根据实际类型确定方法执行版本的过程称为动态链接。
5 方法返回地址
在 JVM 中,方法的退出有两种方式:正常退出和异常退出。无论哪种方式,方法退出后都需要返回到方法最初被调用时的位置,以便程序能够继续执行。方法返回地址是栈帧中的一个重要组成部分,用于记录方法退出后控制流应返回的位置。
5.1 正常退出
当方法正常退出时,可能会有返回值传递给上层的方法调用者。方法是否有返回值以及返回值的类型由方法返回的指令决定。常见的返回指令包括:
ireturn
:用于返回int
类型。lreturn
:用于返回long
类型。freturn
:用于返回float
类型。dreturn
:用于返回double
类型。areturn
:用于返回引用类型。return
:用于void
方法的返回。
在方法正常退出时,PC(Program Counter)计数器的值会作为返回地址,栈帧中通常会保存这个计数器的值。返回地址记录了方法调用后下一条指令的位置,以便程序能够继续执行。
5.2 异常退出
当方法在执行过程中遇到异常且未被妥善处理时,方法会异常退出。在这种情况下,方法不会给上层调用者返回任何值。异常退出时,PC 计数器的值不会作为返回地址保存,因为异常处理机制会接管控制流,跳转到异常处理代码。
5.3 方法退出的过程
方法退出的过程实际上等同于将当前栈帧从栈中弹出(出栈)。出栈后,JVM 会执行以下操作:
- 恢复上层方法的局部变量表和操作数栈:当前方法的栈帧出栈后,上层方法的栈帧成为当前栈帧,JVM 会恢复上层方法的局部变量表和操作数栈。
- 将返回值压入调用者栈帧的操作数栈中:如果当前方法有返回值,JVM 会将返回值压入调用者栈帧的操作数栈中。
- 调整 PC 计数器的值:PC 计数器的值会被更新为方法调用后的下一条指令的地址,以便程序能够继续执行。
5.4 PC 计数器
PC 计数器是 JVM 运行时数据区的一部分,用于跟踪当前线程执行字节码的位置。每个线程都有自己的 PC 计数器,记录当前执行的字节码指令的地址。
5.5 方法退出的示例
考虑以下代码片段:
public class MethodReturnExample {public static void main(String[] args) {int result = add(1, 2);System.out.println(result);}public static int add(int a, int b) {return a + b;}
}
在 main
方法中,调用了 add(1, 2)
方法。add
方法的返回值为 3
,并通过 ireturn
指令返回给 main
方法。main
方法接收到返回值后,将其存储在局部变量 result
中,并打印输出。
5.6 异常退出的示例
考虑以下代码片段:
public class ExceptionExample {public static void main(String[] args) {try {throwException();} catch (Exception e) {System.out.println("Exception caught: " + e.getMessage());}}public static void throwException() throws Exception {throw new Exception("Test exception");}
}
在 throwException
方法中,抛出了一个异常。由于 main
方法中捕获了该异常,throwException
方法会异常退出。异常退出时,throwException
方法不会返回任何值给 main
方法,而是由异常处理机制接管控制流,跳转到 catch
块中执行。
6 附加信息
虚拟机规范允许具体的虚拟机实现增加一些额外的信息到栈帧中,例如与调试相关的信息。这些信息完全取决于具体的虚拟机实现。
7 StackOverflowError
StackOverflowError
是 Java 中常见的运行时异常,通常发生在递归调用过深或方法调用链过长,导致栈内存溢出时。下面通过两个示例代码来分析 StackOverflowError
的产生原因及堆栈信息。
7.1 示例代码 1:无限制递归调用
public class StackOverflowErrorTest {public static void main(String[] args) {StackOverflowErrorTest test = new StackOverflowErrorTest();test.testStackOverflowError();}public void testStackOverflowError() {testStackOverflowError();}
}
运行结果
运行上述代码时,会抛出 StackOverflowError
异常。
异常原因
testStackOverflowError()
方法是一个递归方法,它在方法体内直接调用了自身,且没有终止条件。每次调用 testStackOverflowError()
方法时,JVM 都会为该方法创建一个新的栈帧,并将其压入当前线程的栈中。由于递归调用没有终止条件,栈帧会不断累积,最终导致栈内存溢出,抛出 StackOverflowError
异常。
堆栈信息
异常堆栈信息如下:
Exception in thread "main" java.lang.StackOverflowErrorat com.yunyang.javabetter.jvm.stackframe.StackOverflowErrorTest.testStackOverflowError(StackOverflowErrorTest.java:17)at com.yunyang.javabetter.jvm.stackframe.StackOverflowErrorTest.testStackOverflowError(StackOverflowErrorTest.java:17)at com.yunyang.javabetter.jvm.stackframe.StackOverflowErrorTest.testStackOverflowError(StackOverflowErrorTest.java:17)at com.yunyang.javabetter.jvm.stackframe.StackOverflowErrorTest.testStackOverflowError(StackOverflowErrorTest.java:17)at com.yunyang.javabetter.jvm.stackframe.StackOverflowErrorTest.testStackOverflowError(StackOverflowErrorTest.java:17)...
从堆栈信息中可以看到,testStackOverflowError()
方法被重复调用,且每次调用的位置都在第 17 行(即方法体内的递归调用)。
7.2 示例代码 2:带计数的递归调用
为了更好地观察递归调用的次数,我们对代码进行了简单改造:
public class StackOverflowErrorTest1 {private static AtomicInteger count = new AtomicInteger(0);public static void main(String[] args) {while (true) {testStackOverflowError();}}public static void testStackOverflowError() {System.out.println(count.incrementAndGet());testStackOverflowError();}
}
运行结果
在运行上述代码时,程序会不断递归调用 testStackOverflowError()
方法,并打印递归调用的次数。当递归调用达到一定次数时,栈内存溢出,抛出 StackOverflowError
异常。
在我的本地环境中,递归调用次数达到 11457 次时,抛出了 StackOverflowError
异常。
异常原因
与示例代码 1 类似,testStackOverflowError()
方法是一个递归方法,它在方法体内直接调用了自身,且没有终止条件。每次调用时,JVM 都会为该方法创建一个新的栈帧,并将其压入当前线程的栈中。由于递归调用没有终止条件,栈帧会不断累积,最终导致栈内存溢出,抛出 StackOverflowError
异常。
堆栈信息
异常堆栈信息如下:
11456
11457
Exception in thread "main" java.lang.StackOverflowErrorat sun.nio.cs.UTF_8$Encoder.encodeLoop(UTF_8.java:691)at java.nio.charset.CharsetEncoder.encode(CharsetEncoder.java:579)at sun.nio.cs.StreamEncoder.implWrite(StreamEncoder.java:271)...
从堆栈信息中可以看到,递归调用次数达到 11457 次时,栈内存溢出,抛出 StackOverflowError
异常。
8 总结
栈帧是 JVM 中用于方法执行的核心数据结构,每个方法调用都会创建一个栈帧,并在方法执行完毕后销毁。栈帧的局部变量表和操作数栈的大小在编译时确定,动态链接支持方法调用中的多态性,方法返回地址记录了方法结束后的控制流位置。理解栈帧对于深入理解 Java 程序的运行机制至关重要。
9 思维导图
10 参考链接
深入理解 JVM 的栈帧结构