[Java] JVM内存模型

JDK1.8虚拟机规范下的HotSpot VM

Posted by Penistrong on January 17, 2023

Java Virtual Machine

参考Java虚拟机规范,JVM的结构根据不同的实现有些许不同,这篇笔记主要讨论官方实现的HotSpot VM及其提出的通用垃圾回收机制

JDK8的JVM架构如下图所示,与JDK1.7及之前的JVM相比,差别主要在堆的垃圾回收方法区实现

JVM1.8

运行时数据区域

Run-Time Data Areas是JVM规范里的概念,JVM会在运行时将它管理的内存划分为若干职责不同的数据区域,不同的线程Thread在同一个Java进程Process创建的虚拟机里运行,共享堆和方法区中的内容

线程私有:

  • 程序计数器 Program Count Register
  • 虚拟机栈 Java Virtual Machine Stacks
  • 本地方法栈 Native Method Stack

线程间共享:

  • 堆 Heap
  • 方法区 Method Area
  • 直接内存 Direct Memory

程序计数器

记录当前线程正在执行方法的字节码指令地址,字节码解释器在工作时通过改变程序计数器的值来选取下一条需要执行的字节码指令,其作用类似于汇编中的CS和IP这两个用于存放CPU执行指令地址的寄存器

Java虚拟机栈

线程的虚拟机栈负责处理所有Java方法的调用,注意Native方法调用是通过本地方法栈实现。虚拟机栈执行的单位为栈帧,线程在执行每个Java方法时会创建一个栈帧压入虚拟机栈中,每个栈帧包括局部变量表、操作数栈、动态链接、方法返回地址

局部变量表

存放栈帧对应方法的局部变量,包括编译期可知的八大基本数据类型和对象引用类型,最小管理单位为Slot(槽),每个Slot长度都为4字节。基本数据类型中只有longdouble是64位类型,因此它们需要2个Slot组合表达为1个变量,而对象引用类型包含句柄和指针两种:

  • 句柄: 存放在堆里的句柄池中,每个句柄包括1个到对象实例数据的指针和1个到对象类型数据的指针,对象实例都是在堆中创建并被管理,对象类型数据即类元数据是存放在方法区中(实际上存放在本地内存的元空间中)

    对象引用类型-句柄

  • 指针: 直接指向堆里的对象实例数据(堆中的对象地址),对象实例中存放指向方法区其对应类元数据的指针

    对象引用类型-直接指针

使用句柄的好处:在对象被移动时(比如GC),只需要修改句柄池中的对象实例数据指针,不用修改栈中局部变量表的对象引用

使用直接指针的好处:由于Java需要频繁访问对象,节省一次指针定位开销可以使访问速度更快

局部变量表具体存放了啥?(或者说,每个栈帧的局部变量有哪些?)

由于每个栈帧就是个方法,而每个方法都有其参数变量列表,JVM就会使用栈帧的局部变量表完成参数值(实参)到参数变量列表(形参)的传递

如果当前JVM执行的是实例方法,那么该方法对应栈帧的局部变量表中:

  • 索引为0的变量槽默认是该实例方法所属对象的引用,即this
  • 该方法的其他形参按照方法参数表的顺序放入变量槽中,从索引为1开始
  • 方法的参数表分配完毕后,再对方法体内部定义的其他变量,按照出现顺序和作用域分配剩余的变量槽

为了减少栈帧所占的内存空间,局部变量中的变量槽是可复用的,因为方法体中不同变量的作用域不一定会覆盖整个方法体,如果PC计数器正在执行的字节码指令已超过某个变量的作用域后,就可以将该变量对应的变量槽复用给后面出现的其他局部变量

在JAVA中,方法内定义的局部变量是一定要给出初始值的,如果只声明了类型而不赋初值,就会导致形成栈帧时局部变量表里的该变量不可用(编译器也不会通过,类加载时也会做检查)

但是类中的成员变量即使不赋初值,也仍然有默认零值,所以可以直接使用

类变量(static成员变量): 类加载时的准备阶段就会给类变量赋以其对应类型的初始零值,在之后的初始化阶段可能会在<clinit>()收集的静态语句块中被赋以初始值

实例变量(non-static成员变量): 对象创建过程中的初始化零值阶段也会赋以初始零值,在之后的对象初始化阶段可能会在<init>()构造函数中被赋以初始值

这种特性是Java独有的,C/C++的局部变量就可以不赋初值直接使用

操作数栈

存放方法执行过程中产生的中间计算结果和临时变量。比如执行整数加法$1+2$对应的字节码指令iadd,它要求操作数栈中栈顶已存放这2个int类型操作数,执行该指令会将这两个操作数出栈并相加,再把结果5压入栈中

操作数栈的最大深度在编译器编译时就写入到字节码的方法表持有的Code属性表的max_stacks数据项中

动态链接

主要服务于栈帧对应方法中字节码指令的读取,每个栈帧都包含一个指向运行时常量池中存储的该栈帧对应方法的引用,主要是为了支持方法调用过程中(字节码的方法调用指令invokexxx等)的动态连接(Dynamic Linking)过程: 有些方法的符号引用需要在每一次运行期间转化为直接引用

由于方法对应的字节码都存放在方法区的元空间中,获取字节码指令实际地址的动态连接(v.)过程为:

  1. 找到栈帧中动态链接(n.)区域里保存的指向该方法的符号引用(在 运行时常量池 中)
  2. 将该符号引用转化为指向该方法实际字节码所在内存地址的直接引用,尔后便可读取其中的字节码指令并执行

动态链接

动态(Dynamic)二字源于Java的继承与多态的基本机制,有的类继承了父类并重写了父类的某些方法,因此在运行时需要”动态”识别要连接的实际类(父类或者子类)及需要执行的具体方法(父类方法或子类重写的方法)

方法返回地址

Java方法有两种返回方式:

  • 正常调用完成(Normal Method Invocation Completion): JVM遇到任一个表示正常方法返回的字节码指令,可能会携带返回值传递给上层的方法调用者

    按照方法调用的逻辑,本栈帧对应的方法正常返回时应该回到上层方法调用者执行的字节码指令位置,所以本栈帧的方法返回地址通常是方法调用者调用本栈帧对应方法时的PC计数器的值

  • 异常调用完成(Abrupt Method Invocation Completion): 方法执行时出现异常,本方法的异常表中没有该异常对应的异常处理器(包括JVM运行时碰到的异常或者使用athrow字节码指令主动抛出的异常),向上抛出异常

无论是哪种方式返回都会导致栈帧从虚拟机栈里弹出,即栈帧随着方法调用而创建,随着方法结束而销毁,完成返回时都需要回到本方法最初被调用时的地址

方法调用相关具体看另一篇笔记JVM方法调用

运行时栈错误类型

  • StackOverFlowError: 如果不允许动态扩展栈的内存大小,当线程请求的栈深度超过最大值就会抛出栈溢出错误SOF

  • OutOfMemoryError: 如果允许动态扩展栈的内存大小,当虚拟机尝试动态扩展栈大小时如果无法申请到足够的内存空间,就会抛出内存超出错误OOM

注意,在HotSpot VM中,栈不允许动态扩展,不会抛出OOM异常,但是如果在线程创建栈时申请栈空间失败仍会抛出OOM异常

本地方法栈

本地方法是由非Java语言编写并编译的方法,执行本地方法时会创建1个栈帧并压入本地方法栈,栈帧结构与虚拟机栈中的栈帧结构相同,也包括该本地方法的局部变量表、操作数栈、动态链接和方法返回地址

本地方法由JNI(Java Native Interface, Java本地接口)发起调用,JVM控制不同线程传递不同的JNI接口指针(JNI Interface Pointer),本地方法将JNI接口指针当作参数来接受

由于线程调用Native方法其实就是调用JNI函数,HotSpot虚拟机将本地方法栈和虚拟机栈合二为一,也就是说虚拟机栈的栈帧有概念上的两种: Java方法栈帧、本地方法栈帧,但是统一进行管理,仅在实际方法调用方式上存在不同

Heap是所有线程共享的内存区域,JVM启动时便会创建,几乎所有的对象实例及数组都在堆中分配内存,自JDK7以来堆中还会开辟字符串常量池,JDK8之后还有普通常量池和静态变量池

随着JIT编译器发展和逃逸分析技术的逐渐成熟,由于采用了栈上分配、标量优化等技术,并不是所有对象都会被分配到堆上,如果方法中的对象引用没有被返回或者未被上层作用域使用,那么对象可以直接在栈上分配内存,栈对应的方法执行结束后一起销毁。当然,超出栈可分配内存空间的大对象不得不在堆上创建其实例

堆作为垃圾收集器管理的主要区域,也常被称为GC堆(Garbage Collected Heap)。从垃圾回收的角度上看,现代垃圾收集器基本采用分代收集算法,针对不同类型对象采取不同的垃圾回收算法。JDK8的堆可分为2部分:

  • 新生代 Young Generation
  • 老年代 Old Generation

堆所占内存可以是不连续的,并且可以动态增加,堆扩容失败或者创建对象实例时空间不足都会抛出OOM异常

新生代

HotSpot VM将堆中的新生代划分为3块区域: Eden, From(Survivor 0), To(Survivor 1),它们的默认大小比例为8:1:1,可以通过显示设置内存比例更改:

# 显式设置JVM启动参数,默认为8
# 需要关闭自适应内存分配策略才能设置成功
java -jar xxxx.jar -XX:SurvivorRatio=8 -XX:-UseAdaptiveSizePolicy

老年代

HotSpot VM为老年代划分了一大块称为Tenured Space的区域。新生对象首先在Eden区域分配内存,执行一次新生代区域的垃圾回收后(至少为Minor GC),如果对象仍然存活则最终会进入From区域中(该过程具体见晋升老年代一节),对象的年龄计数器也会+1,当年龄满足晋升阈值后就会被移入Tenured里

只有Full GC时才会对老年代中的对象执行清理,适合存放存活时间较长的对象

分区的好处

为什么不只设置一块Eden区和一块Survivor区,而要分成三区呢?

  1. 降低老年代的内存分配压力,设置两个Survivor区对未达到老年代晋升年龄的年轻对象进行拦截,防止老年代因为剩余空间不足而频繁地进行FullGC,降低Full GC次数

  2. 分成三区能使老年代作为空间分配担保,应付Survivor区大小不足以容纳存活的年轻对象的情况

方法区

JVM规范中对于方法区的描述见JVM-se8-docs §2.5.4,方法区用于存放已被加载的类元数据、字段信息、方法信息、常量、静态变量、即时编译器编译产生的代码缓存等数据,不需要连续的内存且可以动态扩展

注意,方法区是一个概念上的逻辑区域,并不特指堆或者本地内存的具体物理区域,不同的JVM对方法区的实现也不同,以下不同Java版本的HotSpot VM对方法区的实现:

  • JDK1.6及之前的版本中,方法区基于JVM中一块独立于堆的永久代(PermGen)区域实现

  • JDK1.7将常量池和静态变量池放到了堆里开辟的独立空间中,此时方法区由 PermGen + Heap 实现

  • JDK8彻底抛弃虚拟机内存中的永久代,转而在本地内存中开辟元空间(Metaspace),此时方法区由 Metaspace + Heap实现

查看JVM-1.8架构图可知,方法区的构造为:

  1. 位于堆中的常量池、静态变量池、字符串常量池
  2. 位于本地内存里元空间内的类元数据、方法与字段引用、方法与字段数据、方法与构造函数字节码等

它们共同组成了逻辑上的Method Area,元空间大小可以由JVM启动参数控制:

-XX:MetaspaceSize=N     # 设置 Metaspace 初始容量(也为最小容量)
-XX:MaxMetaspaceSize=M  # 设置 Metaspace 最大容量

运行时常量池

运行时常量池是方法区的子集,也是一块跨越不同物理内存区域的逻辑区域,见JVM-se8-docs §2.5.5

注意,.class字节码文件中的常量池表(Constant Pool Table)保存了编译器生成的字面量(Literal,包括整数、浮点数和字符串)和符号引用(Symbolic Reference,包括类、字段、方法、接口方法等的符号引用)

对.class文件执行类加载后,其中记录的常量池表被拆分放入运行时常量池中对应的实际保存位置:

  • final修饰的常量放入 Heap 中的常量池
  • static修饰的静态变量放入 Heap 中的静态变量池
  • 字符串字面量放入 Heap 中的字符串常量池
  • 类元数据描述的类型信息放入 Metaspace,类型信息包括
    1. 类的完整有效名称PackageName.ClassName
    2. 类的直接父类完整有效名称(只有接口和Object没有父类)
    3. 类的修饰符public|abstract|final
    4. 类的直接接口的有序列表
  • 方法和字段的符号引用放入 Metaspace

以上为运行时常量池的大体组成,而.class字节码文件剩余的部分,比如方法与字段数据、方法与构造函数的字节码等,仍被存储到元空间里,但不是运行时常量池的组成部分

字符串常量池

字符串常量池比较特殊,JVM为了提升性能并减少内存消耗,针对String类在堆中专门开辟了一块空间称作字符串常量池,目的是为了避免字符串的重复创建

HotSpot VM的字符串常量池实现位于src/hotspot/share/classfile/stringTable.cpp中,本质是一个HashSet<String>,其容量为StringTableSize,通过设置JVM参数-XX:StringTableSize可以改变大小

注意,StringTable保存的是对字符串对象的引用,指向堆中新生/老年代区域里实际存在的字符串对象 ,比如:

// 堆中没有字符串对象"aaa",创建新的实例并将其引用保存到字符串常量池中
// 返回的是StringTable中的引用地址
String str_1 = "aaa";
// 常量池里有"aaa"对象的引用,直接返回该引用
String str_2 = "aaa";

System.out.println(str_1 == str_2); // true

直接内存

直接内存位于虚拟机外部的本地内存中,通过JNI的方式进行分配,不是运行时数据区域的一部分

JDK1.4起新加入了NIO类,引入了基于通道Channel和缓存区Buffer的I/O方式,通过调用Native函数库在本地内存里分配一块直接内存区域,然后通过一个存储在Java堆中的DirectByteBuffer实例作为对这块直接内存的引用,进行I/O操作。这样能够避免在堆和本地内存之间来回复制数据,显著提高一些场景下的性能

与直接内存类似的概念还有堆外内存,后者是将对象实例创建在虚拟机外的本地内存中,这部分内存直接受OS管理,由于GC会导致线程停顿,将某些”永生”对象放在堆外内存中能够避免GC时来回搬运实例,减少线程停顿时间

查看JVM的默认参数

在安装了Java的条件下,可以通过简单的命令查看JVM对各个堆栈大小的默认定义

java -XX:+PrintFlagsFinal -version | grep -iE 'HeapSize|ThreadStackSize|TableSize|'

本机安装的是JDK17,命令输出如下所示

     intx CompilerThreadStackSize                  = 1024                                   {pd product} {default}
   size_t ErgoHeapSizeLimit                        = 0                                         {product} {default}
   size_t HeapSizePerGCThread                      = 43620760                                  {product} {default}
   size_t InitialHeapSize                          = 257949696                                 {product} {ergonomic}
   size_t LargePageHeapSizeThreshold               = 134217728                                 {product} {default}
   size_t MaxHeapSize                              = 4118806528                                {product} {ergonomic}
     intx MaxJumpTableSize                         = 65000                                  {C2 product} {default}
   size_t MinHeapSize                              = 8388608                                   {product} {ergonomic}
     intx MinJumpTableSize                         = 10                                  {C2 pd product} {default}
    uintx NonNMethodCodeHeapSize                   = 5839372                                {pd product} {ergonomic}
    uintx NonProfiledCodeHeapSize                  = 122909434                              {pd product} {ergonomic}
    uintx ProfiledCodeHeapSize                     = 122909434                              {pd product} {ergonomic}
   size_t SoftMaxHeapSize                          = 4118806528                             {manageable} {ergonomic}
    uintx StringTableSize                          = 65536                                     {product} {default}
     intx ThreadStackSize                          = 1024                                   {pd product} {default}
     intx VMThreadStackSize                        = 1024                                   {pd product} {default}
java version "17.0.6" 2023-01-17 LTS
Java(TM) SE Runtime Environment (build 17.0.6+9-LTS-190)
Java HotSpot(TM) 64-Bit Server VM (build 17.0.6+9-LTS-190, mixed mode, sharing)                                                               

对象内存布局

HotSpot VM中,对象在内存中的布局分为三块区域:

  1. 对象头: 存储对象运行时的状态信息(Mark Word)、指向该对象所属类的类元数据指针、可选的数组长度标志

  2. 实例数据: 存储对象中的实例变量,同时也包括其父类中的非类变量字段,字段的存储顺序受数据类型长度和虚拟机分配策略的影响

  3. 对齐填充: JVM规范中规定,64位虚拟机中对象的大小必须向 8 Bytes 对齐,所以当对象大小不足 8 Bytes 的整数倍时,需要在对象内存中进行填充

实例数据和对齐填充比较简单,而对象头会因为虚拟机位数(32bit or 64bit)、是否开启压缩指针等策略而有些许不同

JVM默认启动参数

64位JVM启动时的默认参数如上,红色圈出来的正是压缩指针的参数选项:

  • -XX:+UseCompressedClassPointer: 启用类元数据指针压缩

  • -XX:+UseCompressedOops: 启用普通对象指针压缩

开启类元数据指针压缩会导致该字段由未压缩时的 8 Bytes 压缩为 4 Bytes,而开启普通对象指针压缩,是指在该对象的实例数据中,指向其他对象的引用数据类型也由未压缩时的 8 Bytes 压缩为 4 Bytes

Mark Word的大小不受影响,在64位虚拟机中固定为2个 4 Bytes, 也就是64bit;32位虚拟机中固定为1个 4 Bytes,也就是32bit

Mark Word

64位虚拟机中,对象头的标记字段Mark Word固定为64bit,如下图所示:

MarkWord-64bit