深入浅出Java虚拟机(JVM)-从栈帧结构到执行引擎的内存模型剖析​

248 阅读8分钟

文章基于学习《深入理解 Java 虚拟机:JVM 高级特性与最佳实践》和宋红康老师《JVM系列》课程进行总结。

1、引言

要理解栈帧(Stack Frame)的运作机制,我们需要从Java虚拟机(JVM)的方法执行模型切入。你应该对方法调用(Method Invocation)和方法执行(Method Execution)这两个概念并不陌生。而栈帧正是JVM实现这些操作的核心数据结构,它作为虚拟机栈(Virtual Machine Stack)的基本存储单元存在于运行时数据区(Runtime Data Area)中。

具体而言,每个栈帧都承载着对应方法的运行时状态,包括局部变量表(Local Variable Array)、操作数栈(Operand Stack)、动态链接(Dynamic Linking)和返回地址(Return Address)等关键数据。当某个线程开始执行方法时,JVM会在其对应的虚拟机栈中创建一个新的栈帧并执行入栈操作;当方法执行完成(无论是正常返回还是异常终止),该栈帧则会执行出栈操作,这个过程完美映射了方法从调用到终止的生命周期。

简而言之,栈帧的本质是JVM为每个方法执行过程建立的"执行上下文容器",通过这种后进先出(LIFO)的栈式管理机制,JVM得以实现方法调用的层级推进和资源隔离。理解这种数据结构对分析Java程序的调用栈、调试StackOverflowError等运行时问题具有重要价值。

JVM-运行时数据区-栈帧数与方法.png

2、栈帧的内部结构

栈帧里会存储方法的局部变量表、操作数栈、动态连接、方法返回地址等信息。

栈帧的大小是在程序编译时就确定占用多少内存。一个线程的方法调用链可能非常长,对于执行引擎来说,活动线程中,只有栈顶的栈帧是有效的,称为当前栈帧(Current Stack Frame),对应的方法称为当前方法(Current Method)。栈帧结构如图:

JVM-运行时数据区-栈帧结构.png

栈帧是有默认大小,也可通过-Xss配置,官网文档数据,如下

1.Linux/x64(64-bit):1024KB
2.macOS(64-bit):1024KB
3.Oracle Solaris/x64(64-bit):1024KB
4.Windows:The default value depends on virtual memory

2.1、局部变量表

局部变量你肯定不陌生,局部变量表你是否了解呢?

局部变量表是一组变量值的存储空间,用于存放方法参数和方法内部定义的局部变量。局部变量表的大小(方法的Code属性locals)是在Java程序被编译时确定的。局部变量表是建立在线程的栈上,是线程私有数据,不存在线程安全问题。

public class LocalVariableTableTest {

    public static void main(String[] args) {
        int a = 2;
        System.out.println(a);
    }
}

JVM-运行时数据区-栈的局部变量表.png

局部变量表的容量以变量槽(Variable Slot)为最小单位。每个Slot占用32位长度的空间。对于64为的数据类型,虚拟机为其分配两个连续的Slot空间。

32位以内的数据类型:boolean、byte、char、short、int、float、reference和returnAddress八种类型

64位的数据类型:long和double两种(reference类型则可能是32位也可能是64位)。

JVM-运行时数据区-栈的局部变量表-1.png

构造方法或实例方法(非static的方法)的局部变量表中第0位索引的Slot默认是用于传递方法所属对象实例的引用,在方法中通过关键字“this"访问这个隐含的参数,其他参数则按照参数表的顺序来排列。

JVM-运行时数据区-栈的局部变量表-实例方法.png

另外,局部变量表中的Slot是可重用的。定义了3个变量+this,只占3个Slot,如下:

	public void test(){
        int a = 1;
        {
            int b= 1;
        }
        {
            int c = 2;
        }
    }

JVM-运行时数据区-栈的局部变量表-2.png

局部变量必须进行显示赋值,否则,编译不通过。像下面的代码,编译器会有错误提示:

	public void test(){
        int a;
        System.out.println(a);
    }

注:

  • 在栈帧中,与性能调优关系最为密切的部分就是局部变量表。在方法执行时,虚拟机使用局部变量表完成方法的传递。
  • 局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都不会被回收。

2.2、操作数栈

操作数栈是一个后入先出(LIFO)栈。操作数栈的最大深度也是在编译的时候被写入到Code属性的stack数据。

一个方法刚刚开始执行时,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令向操作数栈中写入或提取内容。

在概念模型中,两个栈帧相互之间是完全独立的。但是在大多数虚拟机实现令两个栈帧出现部分重叠。让下面的栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠在一起,这样在进行方法调用是就可以共用一部分数据。如图:

JVM-运行时数据区-栈帧数据共享.png

栈顶缓存技术

基于栈式架构的虚拟机所使用的零地址指令更加紧凑,但完成一项操作必须要入栈和出栈指令,这样使得指令分派次数和内存读写次数更多。

通过将栈顶元素全部缓存在物理CPU的寄存器中,降低对内存的读写次数,提升执行引擎的执行效率。

2.3、动态链接

每一个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用的目的是为了支持当前方法的代码能够实现动态链接,例如:invokevirtual。

在Java源文件被编译成class文件是,所有的变量和方法引用都作为符号引用保存到class文件的常量池中。

例如下面的代码:

public class DynamicLinkingTest {
    int i = 10;
    public void methodA() {
        System.out.println("A");
    }

    public void methodB() {
        i++;
        System.out.println("B");
        methodA();
    }
}

如图:

JVM-运行时数据区-栈的动态链接-常量池.png

下面是DynamicLinkingTest类的class文件

Classfile /D:/learn/test/target/classes/com/test/jvm/DynamicLinkingTest.class
  Last modified 2025-4-19; size 676 bytes
  MD5 checksum db94ec231161b76e24e517743b2787bf
  Compiled from "DynamicLinkingTest.java"      
public class com.test.jvm.DynamicLinkingTest   
  minor version: 0                             
  major version: 52                            
  flags: ACC_PUBLIC, ACC_SUPER                 
Constant pool:                                                            
   #1 = Methodref          #9.#23         // java/lang/Object."<init>":()V
   #2 = Fieldref           #8.#24         // com/test/jvm/DynamicLinkingTest.i:I
   #3 = Fieldref           #25.#26        // java/lang/System.out:Ljava/io/PrintStream;
   #4 = String             #27            // A
   #5 = Methodref          #28.#29        // java/io/PrintStream.println:(Ljava/lang/String;)V
   #6 = String             #30            // B
   #7 = Methodref          #8.#31         // com/test/jvm/DynamicLinkingTest.methodA:()V
   #8 = Class              #32            // com/test/jvm/DynamicLinkingTest
   #9 = Class              #33            // java/lang/Object
  #10 = Utf8               i
  #11 = Utf8               I
  #12 = Utf8               <init>
  #13 = Utf8               ()V
  #14 = Utf8               Code
  #15 = Utf8               LineNumberTable
  #16 = Utf8               LocalVariableTable
  #17 = Utf8               this
  #18 = Utf8               Lcom/test/jvm/DynamicLinkingTest;
  #19 = Utf8               methodA
  #20 = Utf8               methodB
  #21 = Utf8               SourceFile
  #22 = Utf8               DynamicLinkingTest.java
  #23 = NameAndType        #12:#13        // "<init>":()V
  #24 = NameAndType        #10:#11        // i:I
  #25 = Class              #34            // java/lang/System
  #26 = NameAndType        #35:#36        // out:Ljava/io/PrintStream;
  #27 = Utf8               A
  #28 = Class              #37            // java/io/PrintStream
  #29 = NameAndType        #38:#39        // println:(Ljava/lang/String;)V
  #30 = Utf8               B
  #31 = NameAndType        #19:#13        // methodA:()V
  #32 = Utf8               com/test/jvm/DynamicLinkingTest
  #33 = Utf8               java/lang/Object
  #34 = Utf8               java/lang/System
  #35 = Utf8               out
  #36 = Utf8               Ljava/io/PrintStream;
  #37 = Utf8               java/io/PrintStream
  #38 = Utf8               println
  #39 = Utf8               (Ljava/lang/String;)V
{
  int i;
    descriptor: I
    flags:

  public com.test.jvm.DynamicLinkingTest();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: aload_0
         5: bipush        10
         7: putfield      #2                  // Field i:I
        10: return
      LineNumberTable:
        line 3: 0
        line 4: 4
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      11     0  this   Lcom/test/jvm/DynamicLinkingTest;

  public void methodA();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=1, args_size=1
         0: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;      
         3: ldc           #4                  // String A
         5: invokevirtual #5                  // Method java/io/PrintStream.println:(Ljava/lang/String;
)V
         8: return
      LineNumberTable:
        line 6: 0
        line 7: 8
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       9     0  this   Lcom/test/jvm/DynamicLinkingTest;

  public void methodB();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=3, locals=1, args_size=1
         0: aload_0
         1: dup
         2: getfield      #2                  // Field i:I
        13: ldc           #6                  // String B
        15: invokevirtual #5                  // Method java/io/PrintStream.println:(Ljava/lang/String;
)V
        18: aload_0
        19: invokevirtual #7                  // Method methodA:()V
        22: return
      LineNumberTable:
        line 10: 0
        line 11: 10
        line 12: 18
        line 13: 22
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      23     0  this   Lcom/test/jvm/DynamicLinkingTest;
}
SourceFile: "DynamicLinkingTest.java"

Java虚拟机提供四条方法调用字节码指令:

  • invokestatic:调用静态方法。
  • invokespecial:调用实例构造器方法、私有方法和父类方法。
  • invokevirtual:调用所有的虚方法。
  • invokeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象。

2.4、方法返回地址

方法返回地址存放调用该方法的PC寄存器的值。

方法结束,有两种方式:

  • 正常执行完成
  • 出现未处理的异常,非正常退出

无论哪种方式退出,在方法退出时都应该返回该方法被调用的位置。正常退出时,调用者的PC计数器的值作为返回地址,栈帧中很可能保存这个计数器的值(调用该方法指令的下一条指令地址)。而方法异常退出,返回地址要通过异常处理器表来确定,栈帧一般不会保存这部分信息。

2.5、附加信息

虚拟机规范允许具体的虚拟机实现增加一些规范里面没有描述的信息到栈帧中,例如与调试相关的信息。在实际开发中,一般会把动态链接、方法返回地址、与其他附加信息统称为栈帧信息。