深入理解JVM运行时内存结构(续)

JVM 以方法作为执行的基本单位,栈帧(Stack Frame)则是用于支持 JVM 进行方法调用和执行的数据结构。每个方法在执行的同时都会创建一个栈帧用于存储方法的局部变量表、操作数栈、动态连接、方法返回地址和一些额外的附加信息。每一个方法从调用开始至执行结束,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。当方法调用结束,栈帧即被销毁,局部变量表、操作数栈等也随之消失。

Java 代码在编译时就已经计算出栈帧需要多大的局部变量表,需要多深的操作数栈,并把它们写入到方法表的 Code 属性之中。换句话说,一个栈帧需要分配多少内存,在编译时已经确定,并不会受到程序运行期间变量数据的影响,仅取决于源码和具体的虚拟机是如何实现栈帧的。

来看下面的方法:

public static void foo(String bar) {
    Integer baz = new Integer(bar);
}

其编译后的字节码如下所示,Code 属性中包含 stack、locals、args_size、LocalVariableTable 等属性,这表示上述代码在实际运行过程中栈帧的深度、局部变量表占用内存大小等在编译完成后便已经确定。

public static void foo(java.lang.String);
    descriptor: (Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
        // stack: 堆栈深度
        // locals: 局部变量表中元素个数
        // args_size: 参数个数(如果是非static方法,参数个数=实际参数个数+1)
        stack=3, locals=2, args_size=1
            // 创建执行类型的对象实例,对其默认初始化(null),并将指向该实例的一个引用压入操作数栈顶
            0: new  #2   // class java/lang/Integer
            // 复制栈顶的值,并压入到栈顶
            3: dup
            // 将第0个Slot中为reference类型的本地变量推送到操作数栈顶   
            4: aload_0
            // 栈顶的reference数据是Integer类型,调用其构造方法
            // 这里会创建新的栈帧,并成为当前栈帧,直到Integer的构造方法调用完成后,完成当前栈帧的出栈操作
            5: invokespecial #3   // Method java/lang/Integer."<init>":(Ljava/lang/String;)V
            // 操作数栈出栈,将栈顶的引用保存到局部变量中,即第1个Slot
            8: astore_1
            9: return
        // 局部变量表
        LocalVariableTable:
            Start  Length  Slot  Name   Signature
                0      10     0   bar   Ljava/lang/String;
                9       1     1   baz   Ljava/lang/Integer;

局部变量表

局部变量表是一组变量值的存储空间,用于存放方法参数和方法内部定义的局部变量。它的容量是以变量槽 (Variable Slot)为最小单位的,但 JVM 规范中并未规定一个 Slot 应占用多大空间,只是要求每个 Slot 都至少应该能容纳一个 boolean、byte、char、short、int、float、reference 和 retureAddress 类型的数据,这 8 种数据类型,都可以使用 32 位(4 字节)或更小的物理内存来存储。前面 6 种不需要解释,可以按照 Java 语言中对应数据类型的概念去理解它们,比如:short 取值范围为-32768~32767,占用 2 个字节,32 位的 Slot 肯定可以容纳它。但仅仅是这样理解而已,Java 语言和 JVM 中基本数据类型是有本质差别的,毕竟连语言都不一样。

第 7 种是 reference 类型,它表示对一个对象实例的引用,JVM 规范既没有说明它的长度,也没有指明其应有怎样的结构。一般来说,reference 的职责至少有两点:

  • 直接或间接地找到对象在堆中数据的起始地址
  • 直接或间接地找到对象所属数据类型在方法区中存储的类型信息

只有这样才能实现 Java 语言规范中定义的语法约束。需要注意的是,并不是所有语言中的引用都满足这两点,比如 C++ 在默认情况下就不满足第 2 点,所以其无法提供 Java 语言中常见的反射特性。

第 8 种是 returnAddress 类型,它为字节码指令 jsr,ret 服务,其指向一条指令的地址。但从 JDK1.7 开始,就已经禁用这两指令,因此,这种数据类型已很少使用。

除此之外,还有一种例外的情况。Java 中的 long 和 double 是 64 位数据类型,JVM 会以高位对齐的方式为其分配两个连续的变量槽空间。比如下面这段代码:

public static void foo(double num) {
    BigDecimal dec = new BigDecimal(num);
}

其字节码中局部变量表是:

LocalVariableTable:
  Start  Length  Slot  Name   Signature
      0      10     0   num   D
      9       1     2   dec   Ljava/math/BigDecimal;

从局部变量表中可以看到 JVM 为变量 num 分配的起始 Slot 是 0,为变量 dec 分配的起始 Slot 是 2,也就是说为变量 num 分配的是 2 个连续的 Slot。这里把 long 和 double 数据类型分割存储的做法与“long和double的非原子性协定”中允许把一次 long 和 double 的读写分为两次 32 位读写的做法类似。但由于局部变量表在线程堆栈中,属于线程私有,无论读写两个连续 slot 是否为原子操作,都不会引起数据竞争和线程安全问题。

Java 内存模型要求 lock、unlock、read、load、assign、use、store、write 这八种操作都具有原子性,但是对于 64 位的数据类型( long 和 double ),在模型中特别定义了一条宽松的规定:允许虚拟机将没有被 volatile 修饰的 64 位数据的读写操作划分为两次 32 位的操作来进行,即允许虚拟机实现自行选择是否要保证 64 位数据类型的 load、store、read 和 write 这四个操作的原子性,这就是所谓的 long 和 double 的“非原子性协定”。不过目前各种平台下的商用虚拟机几乎都选择把 64 位数据的读写操作作为原子操作来对待,因此编码时,一般不需要把用到的 long 和 double 变量专门声明为 volatile。

关于局部变量表,还有一点需要注意的是,局部变量不存在类变量那样的“准备阶段”。在《深入理解 JVM 类加载机制》一文中已经详细描述了类变量的两次赋值过程:在准备阶段赋零值,在初始化阶段赋初始值。因此,在初始化阶段,即使在代码中没有为某个类变量赋值,也可以使用该变量。但局部变量不同,如果在定义时没有为局部变量赋初值,则无法使用它。比如,下面这段代码其实不能运行,只不过编译器在编译期间就检查到这一点,现代的 IDE 也能检查到这一点并给出提示。

public static void boo() {
    int a;
    // IDE 会在此提示: Variable 'a' might not have been initialized
    System.out.println(a);
}

如果想了解更多关于局部变量表的内容,可以参考 ( 强烈建议大家阅读 ) :

操作数栈

操作数栈也常称为操作栈,是一个后入先出 (LIFO) 栈,操作数栈的元素可以是任意 Java 数据类型,包括 long 和 double。当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈/入栈操作。操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,在编译程序代码的时候,编译器要严格保证这一点,在类校验阶段的数据流分析中还要再次验证这一点。比如执行iadd指令时,接近栈顶的两个元素数据类型必须是整型的。

关于操作数栈有一点需要注意的是,在概念模型中,不同方法会创建不同的栈帧,是完全相互独立的。但在大多数虚拟机的实现都会做一些优化,让两个栈帧出现一部分重叠。让下面栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠在一起,这样做不仅节约了一些空间,更重要的是在进行方法调用时就可以直接共用一部分数据,无须进行额外的参数复制传递了。

动态链接

每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态链接(Dynamic Linking)。

如果一个方法想要调用其他方法或者访问成员变量,需要通过符号引用来表示,动态链接的作用就是将这些以符号引用所表示的方法转换为对实际方法的直接引用。其中一部分符号引用会在类加载阶段或者第一次使用的时候就被转化为直接引用,这种转化被称为静态解析。另外一部分将在每一次运行期间都转化为直接引用,这部分就称为动态链接。

方法返回地址

当一个方法开始执行后,只有两个方式可以退出这个方法:

  • 正常退出:执行引擎遇到方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者
  • 异常退出:在方法执行过程中遇到异常,且异常没有在方法体内得到处理

无论采用何种退出方式,在方法退出之后,都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态。一般来说,方法正常退出时,调用者的 PC 计数器的值可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中一般不会保存这部分信息。

方法退出的过程实际上就等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整 PC 计数器的值以指向方法调用指令后面的一条指令等。

最后

深入理解 JVM 运行时内存结构 中,由于篇幅限制,对虚拟机栈的讲解并不十分详尽。本文旨在为你补充这方面的知识,帮助你更好地理解虚拟机栈和栈帧。文中也涉及部分 JVM 指令,如果你对此感兴趣,建议查阅《Java 虚拟机规范》,书中详细描述了每条指令的用法、结构、注意事项以及在执行时栈的变化等。

另外,引用是 JVM 中非常重要的概念,我希望这部分内容能够进一步加深你对它的理解。未来,我还将更加全面详细介绍引用类型的相关知识。

参考资料

深入理解 JVM 系列的第 6 篇,完整目录请移步:深入理解 JVM 系列
本站为非交互式网站,根据相关规定,你无法在本站内进行评论,如对本文有任何疑问,可移步到微信公众号评论
在微信客户端内打开右侧链接即可评论:深入理解JVM运行时内存结构(续)
不遗漏重要文章,欢迎关注本站公众号,手机扫描下方二维码或微信搜索 橙子成绩好
微信公众号二维码

回到首页