JVM 低延迟垃圾收集器 Shenandoah 和 ZGC
本文部分摘自《深入理解 Java 虚拟机第三版》
概述
衡量垃圾收集器的三项指标分别是:内存占用、吞吐量和延迟。这三者共同构成一个“不可能三角”,即一款优秀的收集器最多可以同时达成其中两项
随着硬件性能的提升,对内存占用和吞吐量也有所助益,但对延迟却并非如此。比如内存扩大了,对延迟反而会带来负面效果,因为回收 1TB 的堆内存毫无疑问会比回收 1GB 的堆内存耗费更多时间。因此,延迟成为了垃圾收集器最重视的性能指标
在 CMS 和 G1 之前的全部收集器,其工作的所有步骤都会产生 Stop The World。CMS 和 G1 分别使用增量更新和原始快照技术,实现了标记阶段的并发,但对标记后的清理仍未得到妥善解决。CMS 使用标记 - 清除算法,虽然可与用户线程并发执行,但会产生空间碎片,一旦碎片淤积过多就必然会 Stop The World。G1 虽然可以按更小粒度进行回收,但会出现短暂的停顿
本文要介绍的两款收集器:Shenandoah 和 ZGC,几乎整个工作过程都是并发的,只有初始标记、最终标记阶段有短暂的停顿,并且停顿时间基本固定,与堆的容量、对象数量无关。这两款目前仍处于实验状态的收集器,被官方命名为低延迟垃圾收集器
Shenandoah 收集器
Shenandoah 作为一款第一个不由 Oracle 开发的 HotSpot 收集器,被官方明确拒绝在 OracleJDK12 中支持 Shenandoah 收集器,因此 Shenandoah 收集器只在 OpenJDK 才会包含。Shenandoah 收集器能实现在任何堆内存大小下都把垃圾停顿时间限制在十毫秒以内,这意味着相比 CMS 和 G1,Shenandoah 不仅要进行并发的垃圾标记,还要并发低进行对象清理后的整理
Shenandoah 和 G1 有相似的堆内存布局,在初始标记、并发标记等许多阶段的处理思路都高度一致,甚至直接共享一部分代码。不同的是,虽然 Shenandoah 也是基于 Region 的堆内存布局,回收策略也和 G1 一致,但在管理堆内存方面,它与 G1 至少有三个明显的不同:
- 支持并发的整理算法,G1 的回收阶段可以多线程并行,但不能与用户线程并发
- Shenandoah 默认不使用分代收集
- Shenandoah 摒弃了在 G1 中需耗费大量资源去维护的记忆集,改用连接矩阵的全局数据结构来记录跨 Region 的引用关系
SHenandoah 收集器的工作过程大致可分为以下九个阶段:
-
初始标记
首先标记与 GC Roots 直接关联的对象,需要 Stop The World
-
并发标记
遍历对象图,标记出全部可达对象,这个阶段与用户线程一起并发执行
-
最终标记
处理剩余的 SATB 扫描,并统计出回收价值最高的 Region,并构成一组回收集,该阶段会有短暂停顿
-
并发清理
这个阶段用于清理那些整个区域内连一个存活对象都没有找到的 Region
-
并发回收
把回收集里面的存活对象先复制一份到其他未被使用的 Region,并发执行的困难在于移动对象的同时,用户线程可能会对移动对象进行读写访问,移动对象是一次性行为,但移动之后整个内存中所有指向对象的引用还是旧对象的地址,还难在一瞬间全部改变过来。Shenandoah 将会通过读屏障和被称为 Brooks Pointers 的转发指针来解决
-
初始引用更新
并发回收复制对象结束后,还需把堆中所有指向旧对象的引用修正到复制后的新对象,这个操作称为引用更新。引用更新的初始化阶段实际上并没有做什么具体处理,只是为了建立一个线程集合点,确保所有并发回收阶段中进行的收集器线程都已经完成分配给它们的对象移动任务,会有短暂的停顿
-
并发引用更新
真正开始引用更新操作,与并发标记不同,它不再需要沿着对象图来搜索,只需按照内存物理地址的顺序,线性地搜索出引用类型,把旧值改为新值
-
最终引用更新
修正 GC Roots 中的引用,这个阶段是 Shenandoah 的最后一次停顿
-
并发清理
经过并发回收和引用更新后,整个回收集中所有的 Region 已无存活对象,最后一次并发清理回收这些 Region 的内存空间,供新对象分配使用
了解了 Shenandoah 收集器的工作过程,再来看一下 Shenandoah 用于支持并发整理的核心概念 —— 转发指针(Brooks Pointer)。此前,要做类似的并发操作,通常要在被移动对象原有的内存上设置保护指针,一旦用户程序访问到归属于旧对象的内存空间就会产生自陷中断,进入预设好的异常处理器,再由其中的代码逻辑把访问转发到复制后的新对象。这种方式虽然能实现对象移动和用户线程并发,但如果没有操作系统层面的直接支持,将导致用户态频繁切换到核心态,代价巨大
转发指针是在原有对象布局结构的最前面统一增加一个新的引用字段,在正常情况下,该引用指向对象自己。当对象拥有一份新的副本时,只需修改一处指针的值,即旧对象上转发指针的引用位置,使其指向新对象,便可将所有对该对象的访问转发到新的副本上。这样只要旧对象的内存仍然存在,虚拟机内存中所有通过旧地址访问的代码仍可继续使用,都会被转发到新对象继续工作
ZGC 收集器
ZGC 全称 Z Garbage Collector,是一款在 JDK11 新加入的具有实验性质的低延迟垃圾收集器,由 Oracle 公司研发。ZGC 与 Shenandoah 的目标高度相似,都希望在对吞吐量影响不大的前提下,实现任意堆内存大小下垃圾收集停顿时间限制在十毫秒以内,但两者的实现思路又有显著差异。ZGC 是一款基于 Region 内存布局的,不设分代的,使用读屏障、染色指针和内存多重映射等技术来实现可并发的标记 - 整理算法的,以低延迟为首要目标的一款垃圾收集器
首先从 ZGC 的内存布局说起,ZGC 的 Region 具有动态性,即动态创建和销毁,以及动态的区域容量大小。然后是 ZGC 的并发整理算法的实现,ZGC 采用的是染色指针技术(Colored Pointer)。从前,如果我们要在对象上存储一些额外信息,通常会在对象头中增加额外的存储字段,如哈希码、分代年龄、锁记录等。这种方式在有对象访问的场景下是很自然流程的,不会有问题,但如果对象存在被移动过的可能性,即不能保证能成功访问对象呢?又或者有一些根本就不会访问对象,但又希望得知对象的某些信息的场景呢?能不能从指针或者与对象内存无关的地方获取这些信息呢?
染色指针是一种直接将少量额外信息存储在指针上的技术,ZGC 甚至直接把标记阶段的标记信息记录在引用对象的指针上,因此,与其说可达性分析是遍历对象图来标记对象,不如说是遍历引用图来标记引用。使用染色指针有三大优势:
- 染色指针可以使得某一 Region 的存活对象被移走之后,该 Region 能立即被释放和重用,而不必等待整个堆中所有指向该 Region 的引用都被修正才能清理
- 染色指针可以直接记录对象引用的变动信息,减少内存屏障(尤其是写屏障)的使用
- 染色指针可以作为一种可扩展的存储结构,用来记录更多与对象标记、重定位相关的数据
ZGC 的运行过程大致可划分为以下四个大的阶段,都是可以并发执行的,仅是两个阶段中间会存在短暂的停顿小阶段:
-
并发标记(Concurrent Mark)
遍历对象图做可达性分析,前后也要经历类似 G1、Shenandoah 的初始标记、最终标记的短暂停顿。与 G1、Shenandoah 不同的是,ZGC 的标记是在指针上而非对象,标记阶段会更新染色指针中的 Marked 0、Marked 1 标志位
-
并发预备重分配(Concurrent Prepare for Relocate)
根据特定的查询条件统计出本次收集过程要清理哪些 Region,将这些 Region 组成重分配集(Relocation Set)。ZGC 划分 Region 的目的并非像 G1 是为了做收益优先的增量回收,ZGC 每次回收都会扫描所有 Region,用范围更大的扫描成本换取维护记忆集的成本。ZGC 的标记过程是针对全堆的,ZGC 的重分配集只是决定里面的存活对象会被重新复制到其他的 Region 中,里面的 Region 会被释放,而不能说回收行为就只针对这个集合里面的 Region
-
并发重分配(Concurrent Relocate)
这个过程要把重分配集中的存活对象复制到新的 Region 上,并为重分配集中的每个 Region 维护一个转发表,记录从旧对象到新对象的转发关系。如果用户线程此时并发访问位于重分配集中的对象,这次访问将会被预置的内存屏障所截获,并根据 Region 上的转发表记录将访问转发到新复制的对象上,同时更新该引用的值,使其指向新对象,这种行为称为指针的自愈(Self-Healing)能力
-
并发重映射(Concurrent Remap)
修正整个堆中指向重分配集中旧对象的所有引用,不过这并不是一项迫切完成的任务,因为即使是旧引用,它也是可以自愈的。因此,ZGC 把并发重映射阶段要做的工作,合并到下一次垃圾收集循环中的并发标记阶段去完成,反正都是要遍历所有对象图,这样还可以节省一次遍历对象图的开销。一旦所有指针被修正之后,原来记录新旧对象关系的转发表就可以释放掉了