JVM 的结构

JVM结构
JVM 特指用于运行由 Java 虚拟机规范规定的字节码的运行时系统,它具有以下特征:

  • 平台无关性
    JVM 本身是平台相关的,只是对不同平台都有实现,所以我们编译成的字节码才可以在各个平台上执行,因此称 Java 平台无关指的是字节码是平台无关的。
  • 高度抽象
    JVM 提供了编译器、类加载、动态内存管理、线程等一套工具,作为虚拟机,程序经编译后运行在 JVM 上时,几乎完全不必担心平台的兼容性。
    本地方法接口只是作为一个扩展功能提供,并不是必须的,实际上除了对性能严格要求的场合,本地方法一般不会被使用。

    编译器严格来说和虚拟机并无太大关联,JVM 可以供 Groovy、Kotlin、Scala 等一系列语言作为运行时环境,

下图表示 JVM 的系统结构,包括与 Class 文件打交道的类装载系统、进行运行时数据管理的系统、进行方法执行的系统、本地方法接口(JNI)和本地方法库这些部分。

类装载子系统

类装载子系统负责查找并装载类型,类装载器主要包含两种:启动类装载器(Java 虚拟机实现的一部分)用户自定义类装载器(Java 程序的一部分)

启动类装载器 – Java 虚拟机必须有一个启动类装载器,用于装载受信任的类,如 Java API 的 class 文件。
用户自定义类装载器 – 继承自 ClassLoader 类,ClassLoader 的如下四个方法,是通往 Java 虚拟机的通道。

  • protected final Class defineClass(String name, byte data[], int offset, int length); // data 为二进制的 Java Class 文件数据,表示一个新的可用类型,之后把这个类型导入到方法区中
  • protected final Class defineClass(String name, byte data[], int offset, int length, ProtectionDomain protectionDomain);
  • protected final Class findSystemClass(String name); // 参数为全限定名,通过类装载器进行装载
  • protected final void resolveClass(Class c); // 参数为 Class 实例,完成连接初始化操作

类装载子系统负责定位和导入二进制 class 文件,并且保证导入类的正确性,为类变量分配并初始化内存,以及帮助解析符号引用。类装载器必须严格按照如下顺序进行类的装载。

  1. 装载 – 查找并装载类型的二进制数据
  2. 连接 – 执行验证,准备,以及解析(可选),连接分为如下三个步骤
    验证 – 确保被导入类型的正确性
    准备 – 为类变量(static)分配内存,并将其初始化为默认值
    解析 – 把类型中的符号引用转换为直接引用
  3. 初始化 – 把类变量初始化为正确初始值

内存管理系统

方法区

方法区是线程共享的内存区域,用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据,当方法区无法满足内存分配需求时,将抛出 OutOfMemoryError。
类信息包括:

  1. 类型全限定名。
  2. 类型的直接超类的全限定名(除非这个类型是 java.lang.Object,它没有超类)。
  3. 类型是类类型还是接口类型。
  4. 类型的访问修饰符(public、abstract、final 的某个子集)。
  5. 任何直接超接口的全限定名的有序列表。
  6. 类型的常量池。
  7. 字段信息。
  8. 方法信息。
  9. 除了常量以外的所有类(静态)变量。
  10. 一个到类 ClassLoader 的引用。
  11. 一个到 Class 类的引用。

着重介绍常量池,虚拟机必须要为每个被装载的类型维护一个常量池。常量池就是该类型所用常量的一个有序集合,包括直接常量和对其他类型、字段和方法的符号引用。它在 Java 程序的动态连接(运行时解析字节码中使用到的符号引用)中起着核心作用。
除了以上结构外,JVM 的实现者还可以添加一些其他的数据结构,如方法表:对每个加载的非抽象类的类信息中都添加一个方法表,方法表是一组对类实例方法的直接引用——包括从父类继承的方法,JVM 可以通过方法表快速找到实例方法。

C++中 virtual 函数是通过虚函数表实现的,Java 中所有方法都是 virtual 的,因此也就不需要再标注虚拟了,正像 Java 虽然宣称没有指针,但是 Java 里其实全是指针。Java 中类似的检查机制不少,这体现了 Java 的一种设计思想:始终将安全放在效率之上。

元空间

JDK1.8 的一大改动是方法区变成了元空间,其实就是放开了类空间的大小,容量取决于是 32 位或是 64 位操作系统的可用虚拟内存大小,32 位系统本地最多有 4G 虚拟内存空间,物理内存取决于操作系统的配置。新参数MaxMetaspaceSize用于限制本地内存分配给类元数据的大小,如果没有指定这个参数,元空间会在运行时根据需要动态调整,大小随意。对于没用的类及类加载器的垃圾回收将在元数据使用达到 MaxMetaspaceSize 参数的设定值时进行。

一个虚拟机实例只对应一个堆空间,堆是线程共享的,当然还有可能会划分出多个线程私有的分配缓冲区(TLAB)。堆空间是存放对象实例的地方,几乎所有对象实例都在这里分配。堆也是垃圾收集器管理的主要区域,因此也被称为“GC 堆”。
堆可以处于物理上不连续的内存空间中,只要逻辑上连续即可,就像磁盘空间一样。
当堆中没有足够的内存进行对象实例分配时,并且堆也无法扩展时,会抛出 OutOfMemoryError 异常。
对 OOM 的排查通常是先通过内存映像分析工具对 Dump 出来的堆转储快照进行分析,重点是确认内存中的对象是否是必要的,也就是要先分清楚到底是出现了内存泄漏还是内存溢出

  • 如果是内存泄漏,可进一步通过工具查看泄露对象到 GC Roots 的引用链。于是就能找到泄露对象的类型信息及 GC Roots 引用链的信息,就可以比较准确地定位出泄露代码的位置。
  • 如果不存在泄露,就是内存中的对象确实都还必须存活着,那就应当检查虚拟机的堆参数(-Xmx 与 -Xms),与机器物理内存对比看是否还可以调大,从代码上检查是否存在某些对象生命周期过长、持有状态时间过长的情况,尝试减少程序运行期的内存消耗。

程序计数器

每个线程拥有自己的程序计数器,线程之间的程序计数器互不影响,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令。
PC 寄存器的内容总是下一条将被执行指令的”地址”,这里的”地址”可以是一个本地指针,也可以是在方法字节码中相对于该方法起始指令的偏移量。

  • 如果该线程正在执行一个本地方法,则程序计数器内容为 undefined。
  • 此区域在 Java 虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域。

虚拟机栈

我们一般会把 Java 内存区分为堆内存和栈内存,而所指的“栈”就是这里的虚拟机栈,或者说是虚拟机栈中局部变量表部分。
Java 栈也是线程私有的,虚拟机只会对栈进行两种操作,以帧为单位的入栈出栈。每个方法在执行时都会创建一个栈帧,并入栈,成为当前帧,每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。栈帧由三部分组成:局部变量区、操作数栈、帧数据区。
局部变量区被组织为一个以字长为单位、从 0 开始计数的数组。字节码指令通过从 0 开始的索引来使用其中的数据。类型为 int、float、reference 和 returnAddress 的值在数组中只占据一项,而类型为 byte、short 和 char 的值存入时都会转化为 int 类型,也占一项,而 long、double 则连续占据两项。
操作数栈也常被称为操作栈,它是一个后入先出栈。当一个方法刚刚执行的时候,这个方法的操作数栈是空的,在方法执行的过程中,会有各种字节码指令向操作数栈中写入和提取值,也就是入栈与出栈操作。在使用操作数栈时需要注意实例方法和类方法:

1
2
3
4
5
6
7
8
9
10
public class Example {
// 类方法
public static int classMethod(int i, long l, float f, double d, Object o, byte b) {
return 0;
}
// 实例方法
public int instanceMethod(char c, double d, short s, boolean b) {
return 0;
}
}

类方法和实例方法栈帧结构有所不同,从图中可以看到它们之间的区别:

  • 类方法帧里没有隐含的 this 引用,而实例方法帧中会隐含一个 this 引用;

并且注意:

  • byte,char,short,boolean 类型存入局部变量区的时候都会被转化成 int 类型值,当被存回堆或者方法区时,才会转化回原来的类型;
  • returnAddress 类型数据是指向字节码的指针(方法区的字节码指令),它只存在于字节码层面,与编程语言无关,我们在 Java 语言中是不会直接与 returnAddress 类型数据打交道的。

    每个线程所持有的程序计数器(pc)实际上就是 returnAddress 类型的数据,当线程执行 native 方法时,pc 中的值为 undefied。

  • 操作数栈被组织成一个以字长为单位的数组,它是通过标准的栈操作——即入栈和出栈来进行访问,而不是通过索引访问。入栈和出栈也会存在类型的转化;
  • 当一个方法执行完毕之后,要返回之前调用它的地方,因此在栈帧中必须保存一个方法返回地址。方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用都栈帧的操作数栈中,调用 PC 计数器的值以指向方法调用指令后面的一条指令等。
  • 每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。在 Class 文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。这些符号引用一部分会在类加载阶段或第一次使用的时候转化为直接引用,这种转化称为静态解析。另外一部分将在每一次的运行期期间转化为直接引用,这部分称为动态连接。

栈数据区存放一些用于支持常量池解析、正常方法返回以及异常派发机制的信息。即将常量池的符号引用转化为直接地址引用、恢复发起调用的方法的帧进行正常返回,发生异常时转交异常表进行处理。

  • 虚拟机规范允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧中,例如与高度相关的信息,这部分信息完全取决于具体的虚拟机实现。在实际开发中,一般会把动态连接,方法返回地址与其它附加信息全部归为一类,称为栈帧信息

在 Java 虚拟机规范中,规定了两种异常状况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出 StackOverflowError 异常;如果虚拟机栈可以动态扩展,当扩展时无法申请到足够的内存,就会抛出 OutOfMemoryError 异常。

系统分配给每个进程的内存是有限制的,除去 Java 堆、方法区、程序计数器,如果虚拟机进程本身耗费的内存不计算在内,剩下内存就由虚拟机栈和本地方法栈“瓜分”了。每个线程分配到的栈容量越大,可以建立的线程数量自然就越少,建立线程时就越容易把剩下的内存耗尽,出现虚拟机栈溢出的情况。

  • 如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出 StackOverflowError 异常,出现 StackOverflowError 异常时有错误栈可以阅读,栈深度在大多数情况下达到 1000~2000 完全没有问题,对于正常的方法调用(包括递归),这个深度在一半情况下完全够用。
  • 如果虚拟机在扩展栈(建立更多的线程)时无法申请到足够的内存空间,则抛出 OutOfMemoryError 异常,在不能减少线程数或者更换 64 位虚拟机的情况下,就只能通过减少最大堆和减少栈容量来换取更多的线程。

一般可以认为 Java 中的一切变量都是引用,实际的对象存储在堆中,但是基础类型比较特殊,它们可以直接使用操作符进行算数运算,不过基础类型也带来了一些问题,比如装箱拆箱过程中可能产生空指针异常。Java 中引入基础类型的动机应该是性能与空间利用率,毕竟使用引用时还得通过指针碰撞空闲列表方法(根据垃圾收集器不同而不同)来找到实际对象,而且基础类型本身占用的空间不多,如果用引用势必占用的空间会加倍。
基础类型并不是都分配在栈上,具体有以下几种情况:

  1. 在方法中声明的变量,即该变量是局部变量,每当程序调用方法时,系统都会为该方法建立一个方法栈,其所在方法中声明的变量就放在方法栈中,当方法结束系统会释放方法栈,其对应在该方法中声明的变量随着栈的销毁而结束,这就局部变量只能在方法中有效的原因
    在方法中声明的变量可以是基本类型的变量,也可以是引用类型的变量。
    • 当声明是基本类型的变量的时,其变量名及值(变量名及值是两个概念)是放在方法栈中
    • 当声明的是引用变量时,所声明的变量(该变量实际上是在方法中存储的是内存地址值)是放在方法的栈中,该变量所指向的对象是放在堆类存中的。
  2. 在类中声明的变量是成员变量,也叫全局变量,放在堆中的(因为全局变量不会随着某个方法执行结束而销毁)。
    同样在类中声明的变量即可是基本类型的变量 也可是引用类型的变量
    • 当声明的是基本类型的变量其变量名及值是放在堆中的
    • 引用类型时,其声明的变量仍然会存储一个内存地址值,该内存地址值指向所引用的对象。引用变量名和对应的对象仍然存储在相应的堆中

堆外内存

一个例子

1
2
3
4
5
// 分配一块1024字节的堆外内存
ByteBuffer buffer = ByteBuffer.allocateDirect(1024);
System.out.println(buffer.capacity());
buffer.putInt(0, 2018);
System.out.println(buffer.getInt(0));

为什么要使用堆外内存

主要是因为堆外内存在 IO 操作方面的优势,举一个例子:在通信中,将存在于堆内存中的数据 flush 到远程时,需要首先将堆内存中的数据拷贝到堆外内存中,然后再写入 Socket 中;如果直接将数据存到堆外内存中就可以避免上述拷贝操作,提升性能。类似的例子还有读写文件。
但是需要注意的是,直接访问堆外内存并不会比访问 Java 堆更快:哪个更快:Java 堆还是本地内存。堆外内存更适合用于操作大块的数据(>2G)、可以直接使用操作系统提供的本地 IO 进行操作。

可用的堆外内存额度

  1. 可以通过设定 -XX:MaxDirectMemorySize 来显式指定最大的堆外内存。
  2. 设定 -Dsun.nio.MaxDirectMemorySize 来显式指定:如果该属性为-1,则取directMemory = Runtime.getRuntime().maxMemory(),即 JVM 运行时的最大内存;否则,如果指定这个属性等于-1,则默认为 64M。
    如果是 JDK8,具体代码见VM.saveAndRemoveProperties(),如果是 JDK8 之前,则代码见VM.maxDirectMemory()
    另外,Runtime.getRuntime().maxMemory()是一个 native 方法,HotSpot 中对应的 C++代码如下所示:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    JNIEXPORT jlong JNICALL
    Java_java_lang_Runtime_maxMemory(JNIEnv *env, jobject this)
    {
    return JVM_MaxMemory();
    }

    JVM_ENTRY_NO_ENV(jlong, JVM_MaxMemory(void))
    JVMWrapper("JVM_MaxMemory");
    size_t n = Universe::heap()->max_capacity();
    return convert_size_t_to_jlong(n);
    JVM_END
    其中 max_capacity() 实际返回的是 –Xmx 设置值减去一个 survivor space 的预留区大小,与堆内大小存很接近。

源码

  1. 分配
    ByteBuffer.allocateDirect
    DirectByteBuffer(int)
    -> Bits.reserveMemory 在系统中保存总分配内存(按页分配)的大小和实际内存的大小
    -> Bits.tryReserveMemory 判断系统内存(堆外内存)是否足够,Bits 有一个全局 totalCapacity 变量记录当前已经使用的堆外内存量
    -> JavaLangRefAccess.tryHandlePendingReference 非阻塞,将已经被 JVM 垃圾回收的 DirectBuffer 对象的堆外内存释放。
    -> System.gc 触发一次 FullGC,System.gc并不能保证 FullGC 被马上执行,如果设置了-XX:+DisableExplicitGC则这种显式的 GC 会被禁用
    -> Bits.tryReserveMemory 为了等待 FullGC 释放足够的空间,之后还会重试最多 9 次 tryReserveMemory,每次重试会 sleep 1, 2, 4, 8, 16, 32, 64, 128, 256ms,也就是说最多可以等 0.5s
    -> throw new OutOfMemoryError 分配空间失败,抛出 OOM 异常
    -> Unsafe.allocateMemory native 方法,通过 JNI 调用 C++函数分配堆外内存,并返回内存基地址
    -> Unsafe.setMemory(base, size, (byte) 0) 将分配的内存清零
    -> Cleaner.create 创建一个 Cleaner 用于释放堆空间
  2. 释放
    Cleaner.create Cleaner 内部维护了一个 static 的 Cleaner 对象的链表,create 调用创建了一个 Cleaner 对象并将其加入到该链表中
    -> new Cleaner(DirectByteBuffer, new Deallocator(base, size, cap))

Cleaner 实现 GC 回收堆外内存的原理是PhantomReference(虚引用),虚引用必须和引用队列(ReferenceQueue)一起使用,一般用于实现追踪垃圾收集器的回收动作。虚引用不会影响 JVM 是否要 GC 这个对象的判断,当 GC 某个对象时,如果有此对象上还有虚引用,会将虚引用对象插入 ReferenceQueue 队列。
对于 Cleaner 对象,当 GC 时发现它除了虚引用外已不可达(持有它的 DirectByteBuffer 对象在 GC 中被回收了,此时,只有 Cleaner 对象唯一保存了堆外内存的数据),就会把它放进 Reference 类的 pending 静态变量里。
PhantomReference 的父类是 Reference,Reference 类内部的 static 块会启动 ReferenceHandler 线程,线程优先级很高,这个线程是用来处理 JVM 在 GC 过程中交接过来的 reference(pending)。
下面是该线程的大致执行流程:
ReferenceHandler.run() 死循环处理 JVM 提交的 reference,如果是 Clearner 则调用其 clean 方法
-> Cleaner.clean
-> Cleaner.remove(Cleaner) 首先将 Cleaner 从链表中移除,以便 Cleaner 自身可被 GC 回收掉
-> DirectByteBuffer.Deallocator.run()
-> Unsafe.freeMemory 释放分配的堆外内存
-> Bits.unreserveMemory 更新 Bits.totalCapacity

手动释放堆外内存

如前面所述,可以通过编码调用 DirectByteBuffer 的 cleaner 的 clean 方法来释放堆外内存。但需要注意:cleaner 是 private 访问权限,所以,需使用反射来实现。

MappedByteBuffer

MappedByteBuffer 是用来访问大文件的,其实是利用操作系统的 mapedfile 的机制,把一个大文件映射到物理内存,然后用户进程像访问内存一样访问文件,背后操作系统会把磁盘文件映射到内存中来,这是操作系统预估你想存取哪块提前为你准备好的。

本地方法栈

访问本地方式时使用到的栈,为本地方法服务,与虚拟机栈一样,本地方法区域也会抛出 StackOverflowError 和 OutOfMemoryError 异常。

方法执行引擎

用户所编写的程序如何表现正确的行为需要执行引擎的支持,执行引擎执行字节码指令,完成程序的功能。

本地方法接口(JNI)

本地方法接口称为 JNI,是为可移植性准备的。

参考

  1. 《Hotspot 实战》
  2. GC 性能优化
  3. JVM 菜鸟进阶高手之路
  4. The Java® Virtual Machine Specification
  5. The Java® Language Specification
  6. Java 和操作系统交互细节