Java JVM虚拟机详解


记录 JVM 学习心得,文章内容为摘于《深入理解 Java 虚拟机:JVM 高级特性与最佳实践》,并在原有基础上增添了自身理解,详细内容请参考原作。

一、结构介绍

JVM 的组成结构可以粗略的分为以下五个部分,具体的结构参考下图:

1. 程序计数器

线程私有,用于标记字节码执行的位置,简单而言就是标记多线程切换时每个线程执行的进度。

程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都依赖计数器来完成,各线程之间计数器互不影响,独立存储。

2. 虚拟机栈

线程私有,生命周期同线程,描述 Java 方法执行的线程内存模型,每个方法被执行时 JVM 都会同步创建一个 栈帧 用于存储局部变量表、操作数栈、动态连接、方法出口等信息。

每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。

3. 本地方法栈

线程私有,本地方法栈类似虚拟机栈,但是虚拟机栈存储的 Java 中定义的方法,而本地方法栈存储的是 C++native 方法。

4. Java堆

线程共享,在虚拟机启动时创建,此内存区域的唯一目的就是存放对象实例,Java 里几乎所有的对象实例都在这里分配内存,其可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的。

Java 堆既可以被实现成固定大小的,也可以是可扩展的(通过参数 -Xms-Xmx 设置最大与最小值),不过当前主流的 JVM 都是按照可扩展来实现的,如果在 Java 堆中没有内存完成实例分配,并且堆也无法再扩展时, JVM 将会抛出 OutOfMemoryError 异常。

(1) 直接内存

Java 堆内存相对应的另一概念为 堆外内存,也称为直接内存,其不受 Java 堆内存的限制,通过 -XX:MaxDirectMemorySize 参数限制,默认与 Java 堆最大值一致,虽然其并不属于虚拟机运行时数据区的组成部分,但仍是 Java 中非常重要的部分。

常见的如 Unsafe 类中 allocateMemory() 方法申请的内存即为直接内存,需要注意执行 Minor GC 时并不会回收直接内存的无效对象,只有在触发 Full GC 时才会回收,因此需要格外注意内存释放防止 OOM 问题。

5. 方法区

线程共享,存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据,这一部分空间通常也称为 永久代,其配置信息如下:

  • 通过 -XX:PermSize-XX:MaxPermSize 可设置方法区的初始大小和最大值。
  • 超过方法区最大值将会抛出 java.lang.OutOfMemoryError: PermGen space 异常。

Class 文件中除了有类的版本、字段、方法、接口等描述信息外,在编译期间还会生成各种字面量与符号引用,而这部分内容将在类加载后将存放到方法区的运行时常量池中。

(1) 元空间

JDK 8JVM 移除了方法区中的持久代,取而代之为元空间 (Meta space),其特性与配置如下:

  • 通过 -XX:MetaspaceSize=N-XX:MaxMetaspaceSize=N 设置初始值与最大值,默认无最大限制。
  • 当到达 -XX:MetaspaceSize 所指定的阈值后会开始进行清理该区域。
  • 本地内存耗尽将抛出 java.lang.OutOfMemoryError: Metadata space 异常。

JDK 8 之前方法区中数据存储于 JVM 的堆内存中,因此其内存空间局限于 JVM 堆的内存空间,而元空间改为使用本地内存 (Native memory),即物理内存,意味着只要本地内存足够就不会出现 OOM 错误。

二、对象布局

1. 对象头

Java 的对象头中定义了两个重要字段:运行时数据 (Mark Word) 与类型指针 (Klass),若为数组对象则还会额外包含一个字段存储数组大小。

(1) Mark Word

Mark Word 字段存储了如哈希码 (HashCode)GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等信息,官方称它为 Mark Word

Mark Word 占用大小为 32bit,其中 25bit 用于存储对象哈希码,4bit 用于存储对象分代年龄,2bit 用于存储锁标志位,1bit 固定为 0,这部分的数据在 32 位和 64 位的虚拟机(未开启压缩指针)中分别为 32bit64bit

(2) Klass

Klass 字段存储了指向对象所属类的元数据的指针,也称为类的元数据指针(通过这个指针来确定该对象是哪个类的实例),允许 Java 虚拟机在运行时获取对象的类信息,并进行相应的操作。

HotSpot 虚拟机中的 Java 对象头中 Klass 字段是指对象的元数据信息,用于管理对象的信息,存储指向该对象类的元数据指针。对象的元数据指针大小同载体的机器指针大小,如 32 位虚拟机指针大小即为 32bit64 位虚拟机对应大小为 64bit,可通过指针压缩实现 64bit 对象的指针压缩。

(3) 数组长度

当对象为 Java 数组时,则对象头必须多一块空间存储数组长度,若为普通对象则可省略。

2. 实例数据

实例数据部分是对象真正存储的有效信息,即存储在程序代码里面所定义的各种类型的字段内容,无论是从父类继承下来的,还是在子类中定义的字段都必须记录起来。

3. 对齐填充

这并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。

4. 初始对象

在为对象分配完内存之后,虚拟机必须将分配到的内存空间(但不包括对象头)都初始化为零值,如果使用了 TLAB 的话,这一项工作也可以提前至 TLAB 分配时顺便进行。这步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,使程序能访问到这些字段的数据类型所对应的零值。

Java 虚拟机还要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码(真正调用 hashCode() 方法时才计算)、对象的 GC 分代年龄等信息。

5. 指针压缩

在上面的对象布局中提到了为了在 64 位虚拟机中实现更高效的存储因此引入了指针压缩,通过指针压缩可实现在 64 位虚拟机上同 32 位虚拟机一样仅占用 32bit,从而节省了一半空间。

在开始之前简单介绍一下两类指针类型: 原始指针元数据指针,其作用如下:

  • 原始指针:作用于对象,传统意义上的指针,指向对象在内存中的存储地址。
  • 元数据指针:作用于对象,即对象头的 Klass 字段,标识该对象所属的对象实例等信息。

JDK 6 之后默认开启原始指针的压缩(压缩后大小同 32bit),在 JDK 8 中引入了 UseCompressedClassPointer 用于激活 Java 对象的指针压缩,指针存储空间称为 Compressed class space

指针压缩中涉及的配置参数如下:

  • -XX:+UseCompressedOops:激活原始指针压缩,默认开启。
  • -XX:+UseCompressedClassPointer:激活元数据指针压缩,默认开启。
  • -XX:CompressedClassSpaceSize:指定对象元数据空间大小,默认为 1G,最好不要超过 3G

若程序加载引用对象过多并未能通过 GC 释放可能会抛出:java.lang.OutOfMemoryError: Compressed class space 异常。

三、对象管理

1. 对象创建

JVM 加载至一条 new 指令时将会为对象分配相应内存空间,常见的分配方式有以下两类。

(1) 指针碰撞

假设 Java 堆中内存是绝对规整的,所有被使用过的内存都被放在一边,空闲的内存被放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间方向挪动一段与对象大小相等的距离,这种分配方式称为“指针碰撞 (Bump The Pointer)

(2) 空闲列表

如果 Java 堆中的内存并不是规整的,已被使用的内存和空闲的内存相互交错在一起,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为空闲列表 (Free List)

2. 分配方式

在为 Java 对象分配内存时存在指针碰撞和空闲列表两种方式,而具体的划分实现上存在下述两种方式。

(1) 同步处理

在为对象分配内存空间时采用 CAS 配置,通过失败重试的方式保证更新操作的原子性。

(2) 本地线程分配缓冲

将内存分配的动作按照线程划分在不同的空间之中,即每个线程在 Java 堆中预先分配一小块内存,称为本地线程分配缓冲 (Thread Local Allocation Buffer,TLAB),哪个线程要分配内存,就在哪个线程的本地缓冲区中分配,只有本地缓冲区用完了,分配新的缓存区时才需要同步锁定本地线程分配缓冲。

3. 引用算法

在执行垃圾回收通俗的讲就是判断哪些对象已经没用应该进行资源释放,而判断一个对象是否可用存在两种算法实现:引用计数法可达性分析法

(1) 引用计数法

在对象中添加一个引用计数器,每当有一个地方引用它时计数器值就加一,当引用失效时计数器值就减一,任何时刻计数器为零的对象就是不可能再被使用的。

但是引用计数法存在一个问题即当对象之间相互循环引用时将无法及时回收。

(2) 可达性分析法

通过一系列称为 GC Roots 的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链” (Reference Chain),如果某个对象到 GC Roots 间没有任何引用链相连,或者用图论的话来说就是从 GC Roots 到这个对象不可达时,则证明此对象是不可能再被使用的。

可作为 GC Roots 的对象包括以下几种:

  • 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
  • 在方法区中类静态属性引用的对象,譬如 Java 类的引用类型静态变量。
  • 在方法区中常量引用的对象,譬如字符串常量池 (String Table) 里的引用。
  • 在本地方法栈中 JNI (即通常所说的 Native 方法)引用的对象。
  • JVM 内部的引用,如基本数据类型对应的 Class 对象,一些常驻的异常对象(比如 NullPointExcepitonOutOfMemoryError )等,还有系统类加载器。
  • 所有被同步锁 synchronized 关键字持有的对象。
  • 反映 JVM 内部情况的 JMXBeanJVMTI 中注册的回调、本地代码缓存等。

4. 引用类型

Java 中一个对象的引用情况分为三类:强引用 (Strong Reference)、软引用 (Soft Reference) 和弱应用 (Weak Reference)

(1) 强引用

强引用是日常使用中最常见的引用方式,当通过 new 关键字或基础类型赋值时即创建一个强引用。

如下示例中即创建了两个强引用对象,在执行 GC 垃圾回收时强引用对象资源不会被系统回收。

int i = 1;

Object obj = new Object();
(2) 软引用

通过 SoftReference 泛型类即可创建一个软引用对象,如下示例中将强引用对象 prime 转化为软引用对象。

软引用对象在不存在任何实例或引用时即可被 GC 识别,但其所占内存空间不会被立即回收,只有在 JVM 内存空间不足时才会被真正 GC 回收。

Integer prime = 1;  
SoftReference<Integer> soft = new SoftReference<Integer>(prime);
// 赋空,此时可被 GC 识别但只在 JVM 内存不足时执行 GC
prime = null;
(3) 弱引用

通过 WeakReference 可创建一个弱应用对象,如下示例中将强引用对象 prime 转化为弱引用对象。

当一个对象被声明为弱引用时,在下一次 GC 生命周期即会被系统回收。

Integer prime = 1;  
WeakReference<Integer> soft = new WeakReference<Integer>(prime); 
// 赋空,下一次 GC 即被回收
prime = null;

四、安全点

1. OopMap

迄今为止,所有收集器在根节点枚举 (GC Roots) 这一步骤时都是必须暂停用户线程的,从而防止在执行标记的过程中产生新的引用,而虽然可达性分析算法耗时最长的查找引用链的过程已经可以做到与用户线程一起并发,但根节点枚举始终还是必须在一个能保障一致性的快照中才得以进行。

目前主流 Java 虚拟机使用的都是准确式垃圾收集,所以当用户线程停顿下来之后,其实并不需要一个不漏地检查完所有执行上下文和全局的引用位置,虚拟机应当是有办法直接得到哪些地方存放着对象引用的,在 HotSpot 的解决方案里,是使用一组称为 OopMap 的数据结构来达到这个目的。

一旦类加载动作完成的时候,HotSpot 就会把对象内什么偏移量上是什么类型的数据计算出来,在即时编译过程中也会在特定的位置记录下栈里和寄存器里哪些位置是引用,这样收集器在扫描时就可以直接得知这些信息了,并不需要真正一个不漏地从方法区等 GC Roots 开始查找,从而省去大量时间。

2. 安全点

HotSpot 没有为每条指令都生成 OopMap,如果为每一条指令都生成对应的 OopMap,那将会需要大量的额外存储空间,因此仅在 特定的位置 记录了这些信息,这些位置被称为安全点(Safepoint),通过合理的设置安全点,即可将 GC 的停顿时间控制在一个合理的区间。

因此,用户程序执行时并非在代码指令流的任意位置都能够停顿下来开始垃圾收集,而是强制要求必须执行到达安全点后才能够暂停。

至此,针对于对象的引用链路扫描即有了一个清晰的轮廓,其基本流程如下:

  • 当触发 GC 进程时用户线程需要到达安全点后并挂起。
  • 从安全点处根据之前提到的可作于 GC Roots 的对象寻找根节点(期间停止所有用户线程)。
  • GC Roots 根节点信息配合 OopMap 识别出其相应的引用链(可以与用户线程并发执行)。

3. 设定条件

安全点的选定既不能太少以至于让收集器等待时间过长(等待用户线程到达安全点),也不能太过频繁以至于过分增大运行时的内存负荷。安全点位置的选取基本上是以 “是否具有让程序长时间执行的特征” 为标准进行选定的,因为每条指令执行的时间都非常短暂,程序不太可能因为指令流长度太长这样的原因而长时间执行。

如上图中当触发 GC方式1 中执行器需要等待用户线程达到 安全点B 耗时显然等待时间长于 方式2,等待时间的延长从而增加了 GC 的耗时可能导致程序出现卡顿,同时在等待期间可能会产生新的垃圾对象导致 GC 效率降低,而在 方式3 由于安全点过多虽然能降低等待时间,但可能会增加运行时的内存负荷。

长时间执行 的最明显特征就是指令序列的复用,常见的具有产生安全点的功能的指令如下:

  • 循环跳转;
  • 异常跳转;
  • 执行 native 方法;
  • 执行方法调用(方法的临返回之前放置);

HotSpot 虚拟机为了避免安全点过多带来过重的负担,对循环还有一项优化措施,认为循环次数较少的话,执行时间应该也不会太长,所以使用 int 类型或范围更小的数据类型作为索引值的循环默认是不会被放置安全点的,这种循环被称为可数循环(Counted Loop),在退出循环之后才会放置安全点。相对应地,使用 long 或者范围更大的数据类型作为索引值的循环就被称为不可数循环(Uncounted Loop),将会被放置安全点。

如下示例中的第一个循环即为可数循环(Counted Loop),第二个与第三个为不可数循环(Uncounted Loop),当程序的循环次数过多时或单词循环耗时过久时,可将循环遍历类型设置为 long 或在循环内部通过 Thread.sleep(0) 显式的设置安全点,因为 sleep() 方法调用了 native 方法实现。

需要注意的一点的是设置安全点并不是代表一定会触发 GC,而是当执行 GC 时用户线程可从当前安全点作为 GC Roots 开始扫描对象引用关系。因此,当执行耗时的可数循环(Counted Loop)时若没有手动设置安全点但此刻却触发了 GC 进程,将会导致 GC 停顿时间过久,这显然不是我们愿意看到的结果。

// Counted Loop
for (int i = 0; i < 1000000000; i++) {
    // do something
}

// Uncounted Loop
for (long i = 0; i < 1000000000; i++) {
    // do something
}

// Uncounted Loop
for (int i = 0; i < 1000000000; i++) {
    // do something
    
    if(i % 1000 == 0) {
        try {  
            // sleep() will add Safeponit
            // Because it execute the native method
            Thread.sleep(0);  
        } catch (InterruptedException e) {  
        }
    }
}

4. 执行方案

针对如何在垃圾收集发生时让所有线程都跑到最近的安全点然后停顿下来,有两种方案可供选择:抢先式中断(Preemptive Suspension)和主动式中断(Voluntary Suspension)。

当下较为流行并被广泛使用的为主动式中断,二者相对应的定义如下:

(Ⅰ) 抢先式中断

抢先式中断 (Preemptive Suspension) 不需要线程的执行代码主动去配合,在垃圾收集发生时,系统首先把所有用户线程全部中断,如果发现有用户线程中断的地方不在安全点上,就恢复这条线程执行,让它一会再重新中断,直到跑到安全点上。

但现在几乎没有虚拟机实现采用抢先式中断来暂停线程响应 GC 事件。

(Ⅱ) 主动式中断

主动式中断 (Voluntary Suspension) 即当垃圾收集需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志位,各个线程执行过程时会不停地主动去轮询这个标志,一旦发现中断标志为真时就自己在最近的安全点上主动中断挂起。

轮询标志的地方和安全点是重合的,另外还要加上所有创建对象和其他需要在 Java 堆上分配内存的地方,这是为了检查是否即将要发生垃圾收集,避免没有足够内存分配新对象。

5. 安全区域

安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾收集都是安全的。

典型的场景便是用户线程处于 Sleep 状态或者 Blocked 状态,这时候线程无法响应虚拟机的中断请求,不能再走到安全的地方去中断挂起自己,虚拟机也显然不可能持续等待线程重新被激活分配处理器时间。

当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入了安全区域,那样当这段时间里虚拟机要发起垃圾收集时就不必去管这些已声明自己在安全区域内的线程了。当线程要离开安全区域时,它要检查虚拟机是否已经完成了根节点枚举(或者垃圾收集过程中其他需要暂停用户线程的阶段),如果完成了,那线程就当作没事发生过,继续执行;否则它就必须一直等待,直到收到可以离开安全区域的信号为止。

五、回收算法

在上一点中介绍了两种方式用于判断对象是否应该进行回收,当经过判断确实需要回收对象时,JVM 中有三种常见的空间回收方式:标记-清除标记-复制标记-整理

下面分别介绍三种的回收方式与其之间的差异。

1. 分代介绍

在开始之前先简单介绍一下垃圾回收中的分代概念,Java堆 执行 GC 操作时经常涉及到两个概念 新生代老年代,二者定义如下:

  • 新生代,当新创建一个对象则归属于新生代,对应垃圾回收中的 Minor GC
  • 老年代,新生代中存活下的对象则逐步晋升到老年代,即在 YGC 后未能及时回收的对象,对应垃圾回收中的 Major GC
(1) 记忆集

假如现在进行一次只局限于新生代区域内的收集(Minor GC),但新生代中的对象是完全有可能被老年代所引用的,为了找出该区域中的存活对象,不得不在固定的 GC Roots 之外,再额外遍历整个老年代中所有对象来确保可达性分析结果的正确性,反过来也是一样。

为此,在新生代上建立一个全局的数据结构(该结构被称为记忆集,Remembered Set),这个结构把老年代划分成若干小块,并标识出老年代的哪一块内存会存在跨代引用。此后当发生 Minor GC 时,只有包含了跨代引用的小块内存里的对象才会被加入到 GC Roots 进行扫描。

2. 标记-清除

首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。

  • 如果 Java 堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过
    程的执行效率都随对象数量增长而降低。
  • 内存空间的碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

3. 标记-复制

将可用内存按容量划分为大小相等的两块,每次只使用其中的一块,当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。

  • 解决了空间碎片化,但会浪费一半的内存空间。
  • 如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销。

当新生代中标识完成后存活的对象大于另一半内存空间,即另一半空间无法存储时,则移至老年代。

(1) Appel式回收

Appel 回收模式是当下 JVM 中较为流行的手段,在 HotSpot 虚拟机的 Serial、ParNew 等新生代收集器均采用了这种策略来设计新生代的内存布局。

AppelJVM 的内存堆 (Heap) 分为新生代与老年代,二者比例为 1:2。其中新生代内存空间又分为一块较大的 Eden 和两块较小的 Survivor 空间:From SpaceTo Space,每次分配内存只使用 Eden 和其中一块 Survivor,默认的 EdenSurvivor 二者比例为 8 : 2

Appel 式回收执行步骤如下:

  • EdenFrom Space 中仍然存活的对象一次性复制到 To Space 空间上。
  • 清理掉 Eden 和及 From Space 空间,若存活的对象 To Space 无法存放则移至老年代。
  • 每经历过一次复制到 To Space 空间相对应的对象年龄即增长一次,增长至 15 时即移至老年代。
  • 通过参数 MaxTenuringThreshold 设置垃圾的最大年龄,默认为 15,取值为 0~15
  • 当老年代中的空间不足以存放新生代中晋升的对象时即触发 Full GC,应降低 Full GC 发生频次。

4. 标记-整理

标记-整理其标记过程仍然与 标记-清除 算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。

但是在老年代这种每次回收都有大量对象存活区域,移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作,而且这种对象移动操作必须全程暂停用户应用程序才能进行。

六、经典收集器

1. Serial

Serial 收集器是最基础、历史最悠久的收集器,基于 标记清除 模式实现,在垃圾收集过程中需要停止所有用户工程进程。

Serial OldSerial 收集器的老年代版本,基于 标记整理 模式,它同样是一个单线程收集器,主要供客户端模式下的 HotSpot 虚拟机使用。

2. ParNew

ParNew 收集器实质上是 Serial 收集器的多线程并行版本。

3. Parallel

Parallel Scavenge 收集器也是一款新生代收集器,它基于 标记复制 模式实现的收集器,也是能够并行收集的多线程收集器。其提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间的 -XX:MaxGCPauseMillis 参数以及直接设置吞吐量大小的 -XX:GCTimeRatio 参数。

Parallel OldParallel Scavenge 收集器的老年代版本,基于 标记整理 模式实现,支持多线程并发收集。

4. 应用场景

Java 9 开始 JDK 默认使用的垃圾收集器取决于所选择的 JVM 启动模式,有两种启动模式可供选择:

服务器模式不同 JDK 版本默认垃圾收集器如下:

  • Server 模式,这是 JDK 默认的启动模式,适用于长时间运行的服务器应用程序。
  • Java 8 及之前的版本,默认使用 Parallel (基于标记复制) 垃圾收集器。
  • Java 9 及之后默认使用的垃圾收集器是 G1 垃圾收集器。

客户端模式不同 JDK 版本默认垃圾收集器如下:

  • Client 模式,这是面向桌面应用程序和短期运行的应用程序的启动模式。
  • Java 8 及之前的版本,默认使用 Serial (基于标记清除)垃圾收集器。
  • Java 9 及之后的版本,默认使用 Serial/Serial Old 垃圾收集器组合。

5. 模式查看

那如何查看当前 JVM 的启动方式呢?方法其实很简单直接通过 java -version 命令即可。

可以看到上图的最后一行中有 64-Bit Server VM 字样,即代表当前 JVM 默认的启动模式为服务器模式,简单的讲即当前通过 java -jar 命令运行的程序其 JVM 模式为服务器模式。

而针对正在运行中的程序,则可以通过 jcmd 命令进行查看,命令格式如下:

jcmd <pid> VM.version

执行后可以看到返回的信息和上述 java -version 结果类似,同样为 Server VM

如果你不想以默认的模式启动,则可以在执行运行命令时通过 -client-server 指定模式。

# 以客户端模式启动
java -client -jar test.jar

# 以服务器模式启动
java -server -jar test.jar

七、CMS收集器

CMS(Concurrent Mark Sweep) 是一款优秀的收集器,它最主要的优点在名字上已经体现出来:并发收集、低停顿,一些官方公开文档里面也称之为“并发低停顿收集器”(Concurrent Low Pause Collector)。垃圾收集的目标范围为整个新生代(Minor GC),或整个老年代(Major GC),亦或整个 Java 堆(Full GC)。

1. 工作流程

CMS 收集器的执行流程共分为四步,具体工程流程如下:

(Ⅰ) 初始标记

CMS initial mark,需要停止所有用户线程,仅标记 GC Roots 能直接关联到的对象,整个过程耗时很短。

(Ⅱ) 并发标记

CMS concurrent mark,不需要停顿用户线程,从 GC Roots 的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长,但是可以与垃圾收集线程一起并发运行。

(Ⅲ) 重新标记

CMS remark,需要停止所有用户线程,修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录。

(Ⅳ) 并发清除

CMS concurrent sweep,不需要停顿用户线程,清理删除掉标记阶段判断的已经死亡的对象。

2. 缺点不足

CMS 默认启动的回收线程数是 (处理器核心数量+3)/4 ,如果处理器核心数在四个或以上,并发回收时垃圾收集线程只占用不超过 25% 的处理器运算资源,并且会随着处理器核心数量的增加而下降。但是当处理器核心数量不足四个时,CMS 对用户程序的影响就可能变得很大。

CMS 回收器的缺点主要包含下述几点

  • CMS 在最后一步执行并发清除是基于标记清除的回收模式,因此会造成内存空间碎片化,后续若需要存入大对象时则会再次触发 GC 造成性能损耗。
  • 在并发阶段它虽然不会导致用户线程停顿,但却会因为占用了一部分线程(或者说处理器的计算能力)而导致应用程序变慢,降低总吞吐量。
  • 在并发标记与并发清除的过程中用户线程仍在运行中,若在此期间的新生成的对象在本轮 GC 中无法被回收,只能等待下一次 GC 回收。
  • 在垃圾收集阶段用户线程仍在运行状态,则需要预留足够内存空间提供给用户线程使用,因此 CMS 收集器不能像其他收集器那样等待到老年代几乎完全被填满了再进行收集,必须预留一部分空间供并发收集时的程序运作使用,通过 XX:CMSInitiatingOccu-pancyFraction 参数设置 GC 触发阈值(百分比)。

八、G1收集器

G1(Garbage First) 收集器面向堆内存任何部分来组成回收集(Collection Set)进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大,这就是 G1 收集器的 Mixed GC 模式。

虽然使用这种分代方式提高了效率但由于更多的分代 G1 至少要耗费大约相当于 Java堆 容量 10%20% 的额外内存来维持收集器工作。

1. Region

G1 收集器将连续的 Java 堆划分为多个大小相等的独立区域(Region),每一个 Region 都可以根据需要,扮演新生代的 Eden 空间、 Survivor 空间,或者老年代空间。

收集器能够对扮演不同角色的 Region 采用不同的策略去处理,这样无论是新创建的对象还是已经存活了一段时间、熬过多次收集的旧对象都能获取很好的收集效果。

2. 记录集

由于 G1 收集器的回收集不再之前的完成的 Java堆 而是单独的 Region,因此就会出现划分的不同 Region 之间可能存在相互引用的状况,为此 G1 中引入了记录集的结构。

每个 Region 都维护有自己的记忆集,这些记忆集会记录下别的 Region 指向自己的指针(记录每个 Region 间的相互引用),并标记这些指针分别在哪些卡页的范围之内。

G1 的记忆集在存储结构的本质上是一种哈希表,Key 是别的 Region 的起始地址,Value 是一个集合,里面存储的元素是卡表(“我指向谁”与“谁指向我”)的索引号。

3. Humongous

Region 中还有一类特殊的 Humongous 区域专门用来存储大对象,G1 收集器认为只要大小超过了一个 Region 容量一半的对象即可判定为大对象。

每个 Region 的大小可以通过参数 -XX:G1HeapRegionSize 设定,取值范围为 1MB~32MB,对于那些超过了整个 Region 容量的超级大对象,将会被存放在 N 个连续的 Humongous Region 之中, G1 的大多数行为都把 Humongous Region 作为老年代的一部分来进行看待。

4. 工作流程

CMS 收集器类, G1 收集器的工作同样分为下列四步:

(Ⅰ) 初始标记

需要停顿线程,但耗时很短,仅仅只是标记一下 GC Roots 能直接关联到的对象。

(Ⅱ) 并发标记

GC Root 开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。

(Ⅲ) 最终标记

对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的 SATB 记录。

(Ⅳ) 筛选回收

负责更新 Region 的统计数据,对各个 Region 的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个 Region 构成回收集,然后把决定回收的那一部分 Region 的存活对象复制到空的 Region 中,再清理掉整个旧 Region 的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的。

九、ZGC收集器

ZGC 收集器是一款在 JDK 11 中新加入的具有实验性质的低延迟垃圾收集器,是由 Oracle 公司研发的,目的是在尽可能对吞吐量影响不太大的前提下实现在任意堆内存大小下都可以把垃圾收集的停顿时间限制在十毫秒以内的低延迟。

1. Region

ZGC 收集器中对 Region 实现了更细的划分,分为小、中、大型三种。

  • 小型:容量固定为 2MB,用于放置小于 256KB 的小对象。
  • 中型:容量固定为 32MB,用于放置大于等于 256KB 但小于 4MB 的对象。
  • 大型:容量不固定可以动态变化,但必须为 2MB 的整数倍,用于放置 4MB 或以上的大对象,每个大型 Region 中只会存放一个大对象。

2. 染色指针

染色指针是 ZGC 收集器的核心,在 64 位虚拟机中有 18 位是无法寻址的,ZGC 的染色指针技术将剩下的 46 位指针宽度中其高 4 位用于存储四个标志信息。通过这些标志位,虚拟机可以直接从指针中看到其引用对象的三色标记状态、是否进入了重分配集(即被移动 Remapped)、是否只能通过 finalize() 方法才能被访问到。

ZGC 能够管理的内存不可以超过 4TB(2 的 42次幂)。

3. 工作流程

(Ⅰ) 并发标记

并发标记是遍历对象图做可达性分析的阶段,ZGC 的标记是在指针上而不是在对象上进行的,标记阶段会更新染色指针中的 Marked 0Marked 1 标志位。

(Ⅱ) 并发预备重分配

这个阶段需要根据特定的查询条件统计得出本次收集过程要清理哪些 Region ,将这些 Region 组成重分配集(Relocation Set)。

ZGC 每次回收都会扫描所有的 Region,用范围更大的扫描成本换取省去 G1 中记忆集的维护成本。因此 ZGC 的重分配集只是决定了里面的存活对象会被重新复制到其他的 Region 中,里面的 Region 会被释放,而并不能说回收行为就只是针对这个集合里面的 Region 进行,因为标记过程是针对全堆的。

(Ⅲ) 并发重分配

这个过程要把重分配集中的存活对象复制到新的 Region 上,并为重分配集中的每个 Region 维护一个转发表(Forward Table),记录从旧对象到新对象的转向关系。

得益于染色指针的支持,ZGC 收集器能仅从引用上就明确得知一个对象是否处于重分配集之中,如果用户线程此时并发访问了位于重分配集中的对象,这次访问将会被预置的内存屏障所截获,然后立即根据 Region 上的转发表记录将访问转发到新复制的对象上,并同时修正更新该引用的值,使其直接指向新对象,ZGC 将这种行为称为指针的自愈(Self-Healing)能力。

(Ⅳ) 并发重映射

重映射所做的就是修正整个堆中指向重分配集中旧对象的所有引用。

但是 ZGC 的并发重映射并不是一个必须要迫切去完成的任务,因为前面说过,即使是旧引用,它也是可以自愈的,最多只是第一次使用时多一次转发和修正操作。重映射清理这些旧引用的主要目的是为了不变慢(还有清理结束后可以释放转发表这样的附带收益),所以说这并不是很迫切。


参考链接

  1. Guide to WeakHashMap in Java
  2. 《深入理解 Java 虚拟机:JVM 高级特性与最佳实践》

文章作者: 烽火戏诸诸诸侯
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 烽火戏诸诸诸侯 !
  目录