JVM 内存基础概念之程序计数器与 Java 虚拟机栈和本地方法栈

2022-09-09 10:25:44

前言

在上篇文章中,我们了解到 JVM 运行时数据区有五个区域,分别是:程序计数器、Java 虚拟机栈、本地方法栈、Java 堆、方法区。在这篇文章中,我们就来了解下程序计数器与 Java 虚拟机栈和本地方法栈。

程序计数器

程序计数器(Program Counter Register)区域是所有 Java 运行时数据区中最小的一块,它是一块很小的内存空间,它的作用可以看作是当前线程所执行的字节码的行号指示器。

通俗的说,我们可以把它看作一个指针。它指着当前程序(字节码)正在运行的那一行代码。

程序计数器是线程私有的内存区域,如果当前线程所执行的是一个 Java 方法,那么这个计数器记录的是正在执行的虚拟机字节码指令的地址,如果执行的是 Native 方法,这个计数器则为空。

这个内存区域非常小,它是唯一一个在 Java 虚拟机规范中没有规定任何异常的内存区域。

Java 虚拟机栈

Java 虚拟机栈和程序计数器一样,是线程私有的内存区域,其生命周期与线程相同。

Java 虚拟机栈描述的是 Java 方法执行时候的内存概念模型,每个方法在执行的时候都会创建一个栈帧,用来创建这个方法的操作数栈、局部变量表、方法出口、动态链接等信息。每一个方法在调用和结束的过程,就对应了一个栈帧在虚拟机栈中入栈和出栈的过程。

Java 虚拟机栈是一个后进先出(LIFO)栈,靠后执行的方法会优先完成,后面进入虚拟机栈的栈帧会优先被出栈。这与我们平时所执行 Java 方法的印象是一致的。

在程序执行中,Java 方法的调用、执行和退出,都与 Java 虚拟机栈中存储的栈帧有着密切的联系。

在《Java 虚拟机规范》中对 Java 虚拟机栈规定了两种异常状况,分别是 OutOfMemoryError 异常和 StackOverflowError 异常。如果线程所请求的栈深度大于了 Java 虚拟机所允许的最大深度,则会抛出 StackOverflowError 异常。如果 Java 虚拟机栈被设计为可以动态扩展,而动态扩展时又无法申请到足够的内存,那么则会抛出 OutOfMemoryError 异常。

栈帧

栈帧是栈中的一种元素,它被设计为一种用于存储数据和部分过程结果的数据结构,同时也被用来处理动态链接、方法返回值和异常分派。

一个完整的栈帧都包含有:局部变量表、操作数栈、动态连接、方法返回地址和异常信息。

在编译代码的时候,栈帧需要多大的局部变量表,需要多深的操作数栈,这时候在编译器就完全确定了。 Java 虚拟机编译器会将这些信息写入到 class 文件的 code 表中。 因此,一个栈帧需要分配多大的内存是不会受程序运行期间变量数据的影响,而仅仅取决于具体的 Java 虚拟机。

在一个线程里面,方法调用的调用链可能会很长,很多方法可能都同时处于执行的状态,对于执行引擎而言,在活动线程之中,只有位于 Java 虚拟机栈栈顶的那个栈帧才是有效的。这个栈帧被成为当前栈帧,与这个栈帧相关联的方法被成为当前方法。虚拟机执行引擎中所有执行的字节码指令都针对当前栈帧和当前方法进行操作。

接下来,继续来了解下栈帧中的局部变量表和操作数栈这两个最重要的结构。

局部变量表

局部变量表是一组变量值的存储空间,它用于方法间参数的传递,以及方法执行过程中存储基本数据类型的值和对象的引用。

在 Java 编译器编译 class 文件的时候,就在该方法的 code 属性中确定了该方法所需要局部变量表的最大容量。 局部变量表的容量是以变量槽(Slot)为最小单位的,单个 Slot 可以存储一个类型为 boolean、byte、char、short、float、reference 和 returnAddress 的数据,两个 Slot 可以存储一个类型为 long 或 double 的数据。

  • reference:表示一个对象实例的引用。Java 虚拟机规范中并没有说明一个 reference 的长度和具备什么样的数据结构。 但是一般而言,Java 虚拟机应该至少能通过一个 reference 引用完成两件事情,一是可以通过这个 reference 直接或者间接的查找到这个 Java 对象存放在 Java 堆之中的实例数据地址。二是可以通过这个 reference 直接或者间接的查找到这个对象所属的数据类型在 Java 方法区之中的类型信息。否则无法实现在 Java 语言规范之中定义的语法约束。
  • returnAddress:目前已基本不再使用。

Java 虚拟机栈通过索引值的方式去定位局部变量表中的数据,索引值的范围是从 0 开始到局部变量表的最大范围结束。 如果访问的是一个 32 位的类型数据,索引 n 就代表了使用第 n 个 Slot 里的数据,如果访问的是一个 64 位的类型数据,那索引 n 就代表了使用第 n 个 和 第 n + 1 个 Slot 里面的数据。两个相邻的 Slot,假如它们用于存储一个 64 位的类型数据,则虚拟机是不允许采用任何的方式单独其中的某一个 Slot,Java 虚拟机规范中明确的提到,如果遇到了这样的字节码操作序列,Java 虚拟机将在 class 文件的类加载阶段抛出明确的异常。

在方法执行中,如果正在调用的方法是一个实例方法,那这个方法的局部变量表的第 0 号 Slot 将默认用于存储这个方法所属对象的实例引用。在方法之中,可以通过 this 关键字来访问到这个隐含的参数。

剩下的方法参数将会按照参数表的顺序进行排列,占用从索引 1 开始的局部变量表空间。参数表分配完毕之后,再根据方法表之中的局部变量定义顺序和它们的作用域来分配剩下的局部变量表 Slot。

为了尽可能节省栈中的空间,局部变量表之中的 Slot 是可以被重用的。方法体中所定义的变量,它们的作用域并不一定会覆盖整个方法体的所有内容。如果当前程序计数器的值的范围已经超过了某个变量的作用域范围,那么这个变量所对应的 Slot 空间就可以被释放出来,交由其他变量所重用。

操作数栈

操作数栈同样是一个后进先出的栈,由若干个 Entry 组成,其最大栈深度在编译器就已经决定,被写入到方法表的 code 属性中。

Entry:操作数栈栈元素。 在操作数栈中,任意一个 Entry 都可以存储任何一种 Java 虚拟机中定义的数据类型值。像 64 位数据的 double、long 也都可以,只不过 64 位数据所占用的栈容量为 2,而 32 位所占用的栈容量为 1。

在方法的执行过程中,操作数栈用于存储方法计算参数和计算结果。在方法调用时,操作数栈也被用来准备方法调用的参数值和接收方法退出时的返回结果。

当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程会遇到各种不同的字节码指令往操作数栈中写入和提取内容。就对应了操作数栈的出栈和入栈操作。举个例子,在执行整数的加法的字节码指令时,Java 虚拟机必须保证最接近操作数栈顶的两个元素必须保存了两个整型的数值。当整数加法指令开始执行时,这两个 int 类型的数据会出栈,相加,然后重新入栈。操作数栈中的数据类型必须与字节码指令中的操作类型严格匹配。在编译程序时,编译器就会对这一点进行严格的校验。在类加载时的校验阶段,Java 虚拟机会通过数据流分析再一次保证这一点。

再拿前面整数加法的例子来说,当整数加法执行时,最接近栈顶的两个元素必须都为整型。Java 虚拟机是不允许两个其他数据类型使用整数加法指令进行相加的情况出现。另外,在该链模型之中,两个栈帧作为 Java 虚拟机中两个独立的元素,它们两者是完全互相独立的。但是,在具体 Java 虚拟机实现中,经常对这种情况进行优化处理。令两个逻辑上独立的栈帧出现一部分的重叠。令下面一个操作数栈与上面一个栈帧的局部变量表重叠在一起,这样,在方法调用发生时,它们就公用一部分的数据,方法参数传递时,就不需要进行额外的参数复制操作。

Java 虚拟机的解释执行引擎被称为栈结构的执行引擎,其中,栈结构所指的就是这里的操作数栈。

实例

下面我们再通过一个具体的例子来演示栈帧的局部变量表和操作数栈的工作方式。 首先,创建一个 Java 类,如下所示:

public class Test {
    public int test(){
        int a = 100;
        int b = 100;
        int c = 100;
        return (a + b) * c;
    }
}

编译该 Java 文件,然后在命令行输入如下命令:

javap -verbose Test

注:javap 的命令是用于解析 class 文件的。

执行完该命令后,我们就能看到 Test 类所生成的 Java 字节码指令了:

         0: bipush        100
         2: istore_1
         3: sipush        200
         6: istore_2
         7: sipush        300
        10: istore_3
        11: iload_1
        12: iload_2
        13: iadd
        14: iload_3
        15: imul
        16: ireturn

下面我们就来仔细分析下每个字节码指令的作用和其工作方式。

首先,我们来看一下第一条字节码指令。 在执行这一条字节码指令时,程序计数器指向了这条字节码的偏移量,即 0。 这条指令的名称是:bipush,它的作用是把后面跟的一个整型数据参数(在这里是 100)入栈到操作数栈的栈顶。当这条指令执行完后,操作数栈的栈顶将会存在一个数据:100。

接下来程序计数器指向了偏移量为 2 的指令:istore_1。在这里,偏移量之所以增加了 2,是因为前面一条 bipush 指令占用了一个偏移量,它的参数也占用了一个偏移量。即占用了 0、1 两个偏移量,所以到这里时,偏移量就为 2 了。而这条 istore 指令只有指令本身,并不存在参数,所以自然也就只占用一个偏移量了。istore_1 的作用是把操作数栈的栈顶元素出栈并且将数据存储到局部变量表索引号为 1 的 Slot 之中。这里为什么不是从 0 开始存储呢?前面已经说过了,0 号索引的 Slot 默认用于存储这个方法所属对象的实例引用。

后面还有两组相同作用的指令,它们的作用是把 200 和 300 这两个数据入栈到操作数栈的栈顶,然后把这两个数据从操作数栈存储到局部变量表的 2 号 和 3 号索引的 Slot 中。其中 bipush 和 sipush 的指令名称不同是因为:

  • 当 int 取值为 -1 ~ 5 时采用 iconst 指令。
  • 当 int 取值为 -128 ~ 127 时采用 bipush 指令。
  • 当 int 取值为 -32768 ~ 32767 时采用 sipush 指令。
  • 当 int 取值为 -2147483648 ~ 2147483647 时采用 ldc 指令。

当从 0 到 10 的偏移量执行完毕后,那么此时的程序计数器、局部变量表和操作数栈的状态如下:

  • 程序计时器指向了偏移量为 11 的指令,执行了 iload_1 指令,该指令是将局部变量表索引号为 1 的数据存储到操作数栈的栈顶中。
  • 局部变量表中,索引从 0 到 3 的 Slot 分别存储的是: this、100、200、300。
  • 操作数栈中,栈顶的数据为 100。

下面再继续执行偏移量为 12 的指令:iload_2,既将局部变量表索引号为 2 的 200 数据存储到了操作数栈栈顶。此时的操作数栈中有 2 个数据,分别是栈顶的 200 和其下面的 100。

计数器继续指向偏移量为 13 的指令:iadd(整数加法指令)。其作用是把操作数栈栈顶最近的 2 个元素出栈,然后把这 2 个数据相加的结果重新存入到操作数栈顶中。当这条指令执行完毕后,操作数栈的深度变为 1,并且存入了数据 300。

计数器继续指向偏移量为 14 的指令:iload_3,将局部变量表索引号为 3 的 300 数据存储到了操作数栈栈顶。这时的操作数栈中分别有 300、300 两个数据。

接下来继续执行 imul 指令(整数乘法指令),其作用就是把操作数栈栈顶的 2 个元素出栈,然后把这 2 个数相乘,然后把相乘后的结果数据重新存入到操作数栈中。该指令执行完后,那么操作数栈的深度又重新回到 1 了,栈顶的数为 300 * 300 的结果: 90000。

再往下执行 ireturn 指令,其作用是把操作数栈顶的数出栈,然后把这个值作为方法返回值进行返回。整个方法的执行过程就到此为止结束了。

本地方法栈

本地方法栈和 Java 虚拟机栈是非常相似的。它们之前的区别不过是 Java 虚拟机栈是为了 Java 虚拟机执行字节码所服务的,而本地方法栈则是为了 Java 虚拟机执行 Native 方法所服务的。它的作用就是支撑 Native 方法的执行、调用和退出。

其他像线程私有、异常等都和 Java 虚拟机栈相同,这里就不重复写了。

《Java 虚拟机规范》并没有对本地方法栈所使用的语言、方法的数据结构和方法的形式作任何详细的规定,因此,任何一款具体的 Java 虚拟机实现都可以选择以自己的方法实现本地方法栈。像 Oracle HotSpot ,就将 Java 虚拟机栈和本地方法栈合并实现了。

在下篇文章中,我们再去了解下在 Java 虚拟机运行时数据区中可能是最大的内存区域: Java 堆。

  • 作者:Airsaid
  • 原文链接:https://airsaid.blog.csdn.net/article/details/50619638
    更新时间:2022-09-09 10:25:44