JVM内存结构

137 阅读9分钟

首先要说一下JVM运行java程序时开辟的各种空间,包括线程共享的堆和方法区,以及线程非共享的方法栈和程序计数器(PC寄存器)。

堆:存放类的对象实例。

方法区:专门用来存放已经加载的类信息、常量、静态变量以及方法代码的内存区域。

方法栈又被细分为java方法的java方法栈以及本地方法的本地方法栈,PC寄存器用于存放各个线程执行位置。

image.png

简单介绍一下PC寄存器

JVM中的程序计数寄存器(Program Counter Register)中,Register 的命名源于CPU的寄存器,寄存器存储指令相关的现场信息。CPU只有把数据装载到寄存器才能够运行。

这里,并非是广义上所指的物理寄存器,或许将其翻译为PC计数器(或指令计数器)会更加贴切(也称为程序钩子),并且也不容易引起一些不必要的误会。JVM中的PC寄存器是对物理PC寄存器的一种抽象模拟

PC寄存器用来存储指向下一条指令的地址,也即将要执行的指令代码。由执行引擎读取下一条指令。

image.png

它是一块很小的内存空间,乎可以忽略不记。也是运行速度最快的存储区域。

在JVM规范中,每个线程都有它自己的程序计数器,是线程私有的,生命周期与线程的生命周期保持一致。

任何时间一个线程都只有一个方法在执行,也就是所谓的当前方法。程序计数器会存储当前线程正在执行的Java方法的JVM指令地址;或者,如果是在执行native方法,则是未指定值(undefined) 。

它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。|

字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。

它是唯一一个在Java虚拟机规范中没有规定任何OutOtMemoryError情况的区域。

PC寄存器既没有GC,也没有OOM。

通过反编译代码更能清楚的了解PC寄存器

public class PCRegisterTest {
    public static void main(String[] args) {
        int i = 10;
        int j = 20;
        int k = i + j;
    }
}

image.png

上图反编译的class文件左边的0、2等数字就是PC寄存器存放的地址,右边的字符串就是操作指令。

image.png

常见问题:

(1)使用PC寄存器存储字节码指令地址有什么用?为什么要使用PC寄存器记录当前线程的执行地址呢?

因为CPU需要不停的切换各个线程,这时候切换回来以后,就得知道接着从哪开始继续执行。

JVM的字节码解释器就需要通过改变PC寄存器的值来明确下一条应该执行什么样的字节码指令。

(2)PC寄存器为什么要被设定为线程私有?

为了能够准确地记录各个线程正在执行的当前字节码指令地址,最好的办法自然是为每一个线程都分配一个PC寄存器,这样一来各个线程之间便可以进行独立计算,从而不会出现相互干扰的情况。

运行时的栈帧结构(什么是栈帧)

栈帧是虚拟机进行调用和方法执行的数据结构,简单的说栈帧其实就是JVM运行时数据区虚拟机机栈(JVM Stack)的栈元素,,,每一个方法的执行和调用对应着一个栈帧。举个简单的例子,定义一个Stack ,这个Statck 中放入一些叫做栈帧的对象,这个对象中包含了局部变量表操作数栈动态链接方法返回地址等属性。下面,我们讲一下栈帧这个对象的结构。

首先应当明白,一个线程中一个方法的调用链可能会很长,当中有很多方法都处在执行状态,对于我们的执行引擎来说,只有位于栈顶的栈帧才是有效的,这个叫做当前栈帧(Concurrent Satck Frame),与这个栈帧相关的方法称作当前方法(Concurrent Method),相应的概念模型如下:

image.png

通过栈帧的概念模型,接下来说说,栈帧这个对象的相关属性,有什么作用?数据结构是怎样的

1.局部变量表(Local Variable Table)

它是一组变量的存储空间,用于存放方法上和方法内部的变量。在Java编译期就已完成局部变量表的最大容量分配,说的直白点,局部变量表就是存储局部变量的表,用来存储变量用的;其中它的容量用变量槽(Variable Slot,简称Slot)来衡量,Slot也是最小单位;

image.png

从图中可以看出,基本数据类型除了double和long分割成2个32位(也就是2个Slot)进行存储以外,即高位对齐方式;而其他类型都只占用1个32位的Slot;另外引用类型可能是32位也可能是64位,Java中没有明确规定。

那么虚拟机又是怎样访问局部变量的呢?

虚拟机通过索引定位法的方式使用局部变量表,索引值的范围是从0到Slot的最大数量。在方法执行时,特别是执行实例方法时,那么实例变量表的第0位索引默认是方法所属的实例对象的引用“this”对象,接着是1到Slot参数变量到方法内部的局部变量。另外为了节省栈帧空间,局部变量的Slot是可以复用的,也就是说方法参数+方法内局部变量 !=最大Slot数。由于Slot可以复用,不仅节省了空间的开销,同时也对系统的垃圾回收起到意想不到的作用。

2.操作数栈(Operand Stack)

操作数栈是一个后入先出的栈(LIFO) ,基本原理和存储方式和局部变量一样,32位的数据类型用的栈的容量大小是1,64位的就是2;方法执行的任何时候,操作数栈的深度都不会超过在max_statcks数据项中设置的最大值。

1.栈桢刚创建时,里面的操作数栈是空的。

2.Java虚拟机提供指令来让操作数栈对一些数据进行入栈操作,比如可以把局部变量表里的数据、实例的字段等数据入栈。

3.同时也有指令来支持出栈操作。

4.向其他方法传参的参数,也存在操作数栈中。

5.其他方法返回的结果,返回时存在操作数栈中。

6.栈帧中的部分操作数栈和上一个栈帧的局部变量变存在一定的重叠,主要是为了共享数据而存在。

3.动态连接(Dynamic Linking)

每个栈帧都包含一个指向运行时常量池中该栈帧所属性方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。在Class文件的常量池中存有大量的 符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。这些符号引用一部分会在类加载阶段或第一次使用的时候转化为直接引用,这种转化 称为静态解析。另外一部分将在每一次的运行期期间转化为直接引用,这部分称为动态连接。

4.方法返回地址

当一个方法被执行后,有两种方式退出这个方法。第一种方式是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者(调 用当前方法的的方法称为调用者),是否有返回值和返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法方式称为正常完成出口(Normal Method Invocation Completion)。

另外一种退出方式是,在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,无论是Java虚拟机内部产生的异常,还是代码中使用 athrow字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方式称为异常完成出口(Abrupt Method Invocation Completion)。一个方法使用异常完成出口的方式退出,是不会给它的调用都产生任何返回值的。

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

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

在实际开发中,一般会把动态连接,方法返回地址与其它附加信息全部归为一类,称为栈帧信息。