Back
Featured image of post JVM 学习笔记 09 - 低延迟垃圾收集器 ZGC

JVM 学习笔记 09 - 低延迟垃圾收集器 ZGC

JVM 学习笔记,本文讲解低延迟垃圾收集器 ZGC。

导航页

上篇文章我们讲解了 Shenandoah GC,本文将介绍 ZGC。

ZGC

ZGC 是在 JDK 11 中加入的。它的目标和之前提到的 Shenandoah GC 类似。都是希望在对吞吐降低不大的前提下,尽可能在任意堆大小下,都实现亚毫秒级的低延迟。

ZGC 在原理和实现上都和 Azul 公司闭源的 PGC(Pauseless GC) 和 C4(Concurrent Continuously Compacting Collector)类似。早在 2005 年,Azul VM 上的 PGC 就已经实现了标记、整理阶段都全程和用户线程并发执行。直言不讳的说,ZGC 很大程度上是在抄袭 Azul。

ZGC 基于 Region 布局,不设分代(至少截止 JDK 17),使用加载屏障、染色指针、内存多重映射等技术,实现可并发的标记-整理算法。

内存布局

和 Shenandoah 和 G1 一样,ZGC 也采用基于 Region 的堆内存布局。但不同点在于,ZGC 的 Region(也叫 Page、ZPage)是动态的,可以动态创建、消毁、调整大小。在 x64 硬件下,可用的 Region 大小有:

  • Small Region:固定为 2 MiB,放置小于 256 KiB 的对象。
  • Medium Region:固定为 32 MiB,放置 256 KiB ~ 4 MiB 之间的对象。
  • Large Region:容量不固定,可以为 \(2n\),用于放置 4 MiB 以上的大对象。虽说叫 Large Region,但完全可能比 Medium Region 小。因为每个 Large Region 只能存放一个大对象。ZGC 不会重分配 Large Region 因为复制大对象成本高昂。

染色指针

JDK 13 以后的染色指针布局

染色指针(Colored Pointer)可以将少量的信息直接附加在指针上。理论上,AMD64 架构中支持最多 16 EB(\(2^{64}\))字节内存。然而基于实际需求(用不到这么多)、性能、成本等原因,AMD64 架构最多支持 52 位(4 PiB)地址总线和 48 位(256 TiB)的虚拟地址空间,所以能够支持的最大内存实际为 256 GiB。此外操作系统还具有自己的约束…一开始(JDK 11 中),ZGC 支持最多 4 TiB 内存,而 JDK 13 之后最多支持 16 TiB 内存。

纵使染色指针有大小限制,不支持 32 位平台,不支持压缩指针(-XX:+UseCompressedOops)等约束。但这个 trade off 是非常合理的,收益巨大。在 JEP 333 中,ZGC 研发带头人 Per Liden 描述了以下优势:

  • 带来了 Region 的存活对象被移走后,能够立即释放和重用的优势。不再需要等待整个堆中指向该区域的引用都被修正。同时,也不需要两倍空间才能完成收集,理论上只要还有空闲 Region,就能够完成收集。
  • 减少内存屏障的使用量。设置内存屏障的主要目的,视为了记录对象引用的变动情况。染色指针将这些信息维护在指针中,就可以省去这些操作。
  • 染色指针可能可以拓展,比如开发未利用的 16 位,虽然不能用来存地址,但是可以用于信息记录。

不过要应用染色指针还需要先解决操作系统和 CPU 架构的支持问题。CPU 并不知道哪些部分是标记位,哪些部分是真正的指针,只会一整个都当成地址对待。这个问题在 Solaris/SPARC 平台上比较容易解决,因为硬件层面就支持设置 VM 地址掩码。然而 x86-64 平台上没有类似的方法。ZGC 只能另寻他路。

x86 具有分页管理机制,会进行线性地址到物理地址的空间映射。转换关系可以在硬件、OS 或软件层面实现。ZGC 在 Linux/x86-64 上使用了多重映射(Multi-Mapping)将多个不同的虚拟内存地址映射到一个物理内存地址上。也就是说,ZGC 在虚拟内存中看到的地址空间,比实际的堆空间大。将染色指针中的标识位看作是地址分隔符,只需要经过多重映射转换,就可以使用染色指针正常寻址了。

工作流程

  • 并发标记(Concurrent Mark):和 G1、Shenandoah 类似。也会在前后经历初始标记、最终标记的短暂停顿。为对象图的可达性分析时,ZGC 的标记是在指针上进行的,此时会更新 Marked0、Marked1 标识位。
  • 并发预备重分配(Concurrent Prepare for Relocate):根据条件统计出要清理哪些 Region 并构成重分配集(Relocation Set)。重分配集和回收集还是有区别的,ZGC 不会做收益优先的增量回收,反而会扫描所有 Region,省去 G1 中记忆集的维护成本。因此,重分配集决定的是 Region 是否会释放。但标记过程是针对全堆的。此外类卸载和弱引用处理也是本阶段完成的。
  • 并发重分配(Concurrent Relocate):ZGC 的核心阶段。将重分配集中的存活对象,复制到新 Region 上,并为这些 Region 维护转发表(Forward Table),记录新旧 Region 的改变。得益于染色指针,ZGC 从引用上就能得知,一个对象是否在重分配集中。若用户线程此时访问了,就会被内存屏障截获,修正更新后指向新对象,这种行为被称为指针的「自愈」(Self-Healing)。这样做的好处是,只有第一次访问需要转发,也就是只慢一次。而 Shenandoah 需要每次都转发。另外,一旦重分配集都复制完毕,该 Region 就可以被立即释放,用于新对象分配,哪怕堆中还有很多未更新指针,它们都可以自愈。
  • 并发重映射(Concurrent Remap):重映射要做的就是修正指向重分配集旧对象的所有引用。然而正如前面所说的,这不是一个急切去完成的任务。因此,ZGC 把这个阶段要做的工作,合并到了下一次 GC 的并发标记阶段去完成,就减少了一次遍历对象图的开销。一旦所有指针都修正后,转发表就可以释放了。

ZGC 已经实现了亚毫秒级的停顿时间。然而代价是吞吐量会下降,官方宣称的数字是相比 G1,最多下降 15%。

此外,ZGC 支持 NUMBA-Aware。NUMA(Non-Uniform Memory Access)是一种为吞吐量设计的硬件结构。将本在北桥芯片中的内存控制器集成在 CPU 内核中。在NUM A架构下,ZGC收集器会优先尝试在请求线程当前所处的处理器的本地内存上分配对象,以保证高效内存访问。

JVM 自动内存管理的部分到这里就结束了,下节会讲解 class 类文件结构。

comments powered by Disqus