【转载】ZGC 收集器简介

2024 年 5 月 12 日 星期日(已编辑)
/
114
摘要
ZGC 收集器是一款基于 Region 内存布局的,(暂时) 不设分代的,使用了读屏障、染色指针和内存多重映射等技术来实现可并发的标记 - 整理算法的,以低延迟为首要目标的一款垃圾收集器。
这篇文章上次修改于 2024 年 5 月 29 日 星期三,可能部分内容已经不适用,如有疑问可询问作者。

【转载】ZGC 收集器简介

ZGC (“Z” 并非什么专业名词的缩写,这款收集器的名字就叫作 Z Garbage Collector) 是一款在 JDK 11中新加入的具有实验性质的低延迟垃圾收集器,是由 Oracle 公司研发的。2018 年 Oracle 创建了 JEP 333 将 ZGC 交给 OpenJDK,推动其进入 OpenJDK 11的发布清单之中。

ZGC 的目标

ZGC和Shenandoah的目标是高度相似的,都希望在尽可能对吞吐量影响不太大的前提下,实现在任意堆内存大小下都可以把垃圾收集的停顿时间限制在十毫秒以内的低延迟。但是 ZGC 和 Shenandoah 的实现思路又是差异显著的,如果说 RedHat 公司开发的 Shen-andoah 像是 Oracle 的 G1 收集器 的实际继承者的话,那 Oracle 公司开发的 ZGC 就更像是 Azul System 公司独步天下的 PGC (Pauseless GC) 和 C4 (Concurrent Continuously Compacting Collector) 收集器的同胞兄弟。

ZGC 的内存布局

与 Shenandoah 和 G1 一样,ZGC 也采用基于 Region 的堆内存布局,但与它们不同的是,ZGC 的 Page 具有动态性——动态创建和销毁,以及动态的区域容量大小。在 x64 硬件平台下,ZGC 的 Page 可以具有如下图所示的大、中、小三类容量:

  • 小型 Page (Small Page):容量固定为 2 MB,用户放置小于 256 KB 的小对象。
  • 中型 Page (Medium Page):容量固定为 32 MB,用于放置大于等于 256 KB 但小于 4 MB 的对象。
  • 大型 Page (Large Page): 容量不固定,可以动态变化,但必须为 2 MB 的整数倍,用于放置 4 MB 或以上的大对象。每个大型 Region 中只会存放一个大对象,这也预示着虽然名字叫做“大型 Page ”,但它的实际容量完全有可能小于中型 Page,最小容量可以低至 4MB。大型 Page 在 ZGC 的实现中是不会被重分配(重分配是 ZGC 的一种处理动作,用于复制对象的收集器阶段,后文将介绍)的,因为复制一个大对象的代价非常高昂。
ZGC的堆内存布局

ZGC的堆内存布局

接下来是 ZGC 的核心问题——并发整理算法的实现。Shenandoah 使用转发指针和读屏障来实现并发整理,ZGC 虽然同样用到了读屏障,但用的却是一条与 Shenandoah 完全不同,更加复杂精巧的解题思路。

ZGC 并发整理算法的实现:染色指针技术

染色指针技术(Colored Pointer)是 ZGC 收集器一个标志性的设计。从前,如果我们要在对象上存储一些额外的、只供收集器或者虚拟机本身使用的数据,通常会在对象头中增加额外的存储字段,如对象的哈希码、分代年龄、锁记录等就是这样存储的。这种记录方式在有对象访问的场景下是很自然流畅的,不会有什么额外负担。

但如果对象存在被移动过的可能性,即不能保证对象访问能够成功呢?又或者有一些根本就不会去访问对象,但又希望得知该对象的某些信息的应用场景呢?能不能从指针或者与对象内存无关的地方得到这些信息,譬如是否能够看出来对象被移动过?这样的要求并非不合理的刁难,先不去说并发移动对象可能带来的可访问性问题,此前我们就遇到过这样的要求——追踪式收集算法的标记阶段就可能存在只跟指针打交道而不必涉及指针所引用的对象的本身的场景。例如对象标记的过程中需要给对象打上三色标记,这些标记本质上就只和对象的引用有关,而与对象本身无关——某个对象只有它的引用关系能决定它存活与否,对象上其他所有的属性都不能影响它的存活判定结果。

HotSpot 虚拟机的几种收集器有不同的标记实现方案,有德把标记直接记录在对象头上(如 Serial 收集器),有德把标记记录在与对象相互独立的数据结构上 (如 G1、Shenandoah 使用了一种相当于堆内存的 1/64 大小的,称为 BitMap 的结构来记录标记信息),而 ZGC 的染色指针是最直接的、最纯粹的,它直接把标记信息记在引用对象的指针上,这时,与其说可达性分析时遍历对象图来标记对象,还不如说是遍历 “引用图” 来标记 “引用” 了。

染色指针是一种直接将少量额外的信息存储在指针上的技术,可是为什么指针本身也可以存储额外信息呢?在 64 位系统中,理论可以访问的内存高达 16 EB (2 的64次幂)字节。实际上,基于需求 (用不到那么多内存)、性能 (地址越宽在做地址转换时需要的页表级数越多) 和成本 (消耗更多晶体管) 的考虑,在AMD 64架构中只支持到 52 位 (4 PB) 地址总线和 48 位 (256 TB) 的虚拟地址空间,所以目前 64 位的硬件实际能够支持的最大内存只有 256 TB。此外,操作系统一侧也会施加自己的约束,64 位的 Linux 则分别支持 47 位 (128 TB)的进程虚拟地址空间和 46 位 (64TB) 的物理地址空间,64 位的 Windows 系统甚至只支持 44 位(16 TB) 的物理地址空间。

尽管 Linux 下 64 位指针的高 18 位不能用来寻址,但剩余的 46 位指针所能支持的 64 位内存在今天仍然能够充分满足大型服务器的需要。鉴于此,ZGC 的染色指针技术继续盯上了这剩下的 46位指针宽度,将其高 4 位提取出来存储四个标志位信息。通过这些标志位,虚拟机可以直接从指针中看到其引用对象的三色标记状态、是否进入了重分配集 (即被移动过)、是否只能通过 finalize() 方法才能被访问到,当然,由于这些标志位进一步压缩了原本就只有 46 位的地址空间,也直接导致了 ZGC 能够管理的内存不可以超过 4 TB (2 的 42 次幂)。

染色指针的优势

尽管染色指针有 4 TB 的内存限制,不能支持 32 位平台,不能支持压缩指针等诸多约束,但它带来的收益也是非常可观的, ZGC 的设计者 Per Liden 陈述了染色指针的三大优势:

  1. 染色指针可以使得一旦某个 Region 的存活对象被移走之后,这个 Region 立即就能够被释放和重用掉,而不必等待整个堆中所有指向该 Region 的引用都被修正后才能清理。

  2. 染色指针可以大幅减少在垃圾收集过程中内存屏障的使用数量,设置内存屏障,尤其是写屏障的目的通常是为了记录对象引用的变动情况,如果将这些信息直接维护在指针中,显然就可以省去一些专门的记录操作。

  3. 染色指针可以作为一种可扩展的存储结构用来记录更多与对象标记、重定位过程相关的数据,以便日后进一步提高性能。

    Note

    现在 Linux 下的 64位 指针还有前 18 位并未使用,它们虽然不能用来寻址,却可以通过其他手段用于信息记录。如果开发了这 18 位,既可以腾出已用的 4 个标志位,将 ZGC 可支持的最大堆内存从 4 TB 扩展到 64 TB,也可以利用其余位置再存储更多的标志,譬如存储一些追踪信息来让垃圾收集器在移动对象时能将低频次使用的对象移动到不常访问的内存区域。

x86-64 上染色指针应用的前提——内存映射技术

Java 虚拟机作为一个普普通通的进程,这样随意重新定义内存中某些指针的其中几位,操作系统是否支持?处理器是否支持?这是很现实的问题,无论中间过程如何,程序代码最终都要转换为机器指令流交付给处理器去执行,处理器可不会管指令流中的指针哪部分存的是标志位,哪部分才是真正的寻址地址,只会把整个指针都视作一个内存地址来对待。这个问题在 Solaris/SPARC 平台上比较容易解决,因为 SPARC 硬件层面本身就支持虚拟地址掩码,设置之后其机器指令就可以忽略掉染色指针中的标志位。但在 x86-64 平台上并没有提供类似的黑科技,ZGC 设计者就只能采取其他的补救措施了,这里面的解决方法呢要涉及虚拟内存映射技术,让我们先来复习一下这个 x86 计算机体系中的经典设计。

虚拟内存映射技术的历史沿革

  1. 远古时代 x86 计算机系统里面,所有进程都是共用同一块物理内存空间的。这样会导致不同进程之间的内存无法相互隔离,当一个进程污染了别的进程内存后,就只能对整个系统进行复位后才能得以恢复。
  2. 从 Intel 80386 处理器开始,提供了 “保护模式” 用于隔离进程。在保护模式下,386 处理器的全部 32 条地址都有效,进程可访问最高也可达 4GB 的内存空间,但此时已不同于之前实模式下的物理内存寻址了,处理会使用分页管理机制把线性地址空间和物理地址分别划分为大小相同的块,这样的内存块被称为“页”(Page)。通过在线性虚拟空间的页与物理地址空间的页之间建立的映射表,分页管理机制会进行线性地址到物理地址空间的映射,完成线性地址到物理地址的转换。

不同层次的虚拟内存从物理内存的转换关系可以在硬件层面、操作系统层面或者软件进程层面实现,如何完成地址转换,是一对一、多对一还是一对多的映射,也可以根据实际需要来设计。

内存映射在 ZGC 上的应用

Linux/x86-64 平台上的 ZGC 使用了多重映射 (Multi - Mapping) 将多个不同的虚拟内存地址映射到同一个物理内存地址上,这是一种多对一映射,意味着 ZGC 在虚拟内存中看到的地址空间要比实际的堆内存容量来得更大。把染色指针中的标志位看作是地址的分段符,那只要将这些不同的地址段都映射到同一个物理内存空间,经过多重映射转换后,就可以使用染色指针正常进行寻址了,效果如图所示:

多重映射下的寻址

多重映射下的寻址

在某些场景下,多重映射技术确实可能会带来一些复制大对象时会更容易这样的额外好处,可从根源上讲,ZGC 的多重映射只是它采用染色指针技术的伴生产物,并不是专门为了实现其他某种特性需求而去做的。

ZGC 是如何工作的?

ZGC 的运作过程大致可划分为以下四个大的阶段。全部四个阶段都是可以并发执行的,仅是两个阶段中间会存在短暂的停顿小阶段,这些小阶段,譬如初始化 GC Root 直接关联对象的 Mark Start,与之前 G1 和 Shenandoah 的 Initial Mark 并没有什么差异。是什么?ZGC 的运作过程具体如图所示:

ZGC运作过程

ZGC运作过程
  • 并发标记( Concurrent Mark): 与 G1、Shenandoah 一样,并发标记是遍历对象图做可达性分析的阶段,前后也要经过类似于 G1、Shenandoah 的初始标记、最终标记 (尽管 ZGC 中的名字不叫这些) 的短暂停顿,而且这些停顿阶段所做的事情在目标上也是相类似的。与 G1、Shenandoah 不同的是,ZGC 的标记是在指针上而不是在对象上进行的,标记阶段会更新染色指针中的 Marked 0、Marked 1 标志位。

  • 并发预备重分配(Concurrent Prepare for Relocate): 这个阶段需要根据特定的查询条件统计得出本次收集过程要清理哪些 Region,将这些 Region 组成重分配集 (Relocation Set)。重分配集与 G1 收集器的回收集 (Collection Set) 还是有区别的,ZGC 划分 Region 的目的并非为了像 G1 那样做收益优先的增量回收。相反,ZGC 每次回收都会扫描所有的 Region,用范围更大的扫描成本换取省去 G1 中记忆集的维护成本。因此,ZGC 的重分配集只是决定了里面的存活对象会被重新复制到其他的 Region 中,里面的 Region 会被释放,而并不能说回收行为就只是针对这个集合里面的 Region 进行,因为标记过程是针对全堆的。此外,在 JDK 12 的 ZGC 中开始支持的类卸载以及弱引用的处理,也是在这个阶段完成的。

  • 并发重分配(Concurrent Relocate):重分配是 ZGC 执行过程中的核心阶段,这个过程要把重分配集中的存活对象复制到新的 Region 上,并为重分配集中的每个 Region 维护一个转发表 (Forward Table),记录从旧对象到新对象的转向关系。得益于染色指针的支持,ZGC 收集器能仅从引用上旧明确的纸一个对象是否处于重分配集之中,如果用户线程此时并发访问了位于重分配集中的对象,此次访问将会被预置的内存屏障所截获,然后立即根据 Region 上的转发表记录将访问转发到新复制的对象上,并同时修正更新该引用的值,使其直接指向新对象,ZGC 将这种行为称为指针的 “自愈” (Self Healing) 能力。

    Note

    这样做的好处是:只有第一次访问旧对象会陷入转发,也就是只慢一次,对比 Shenandoah 的 Brooks 转发指针,那是每次对象都必须付出的固定开销(简单地说就是每次都慢),因此 ZGC 对用户程序的运行时负载要比 Shenandoah 来得更低一些。

    还有一个直接的好处是由于染色指针的存在,一旦重分配集中某个 Region 的存活对象都复制完毕后,这个 Region 就可以立即释放用户新对象的分配(但是转发表还得留着不能释放掉),哪怕堆中还有很多指向这个对象的未更新指针也没有关系,这些旧指针一旦被使用,它们都是可以自愈的。

  • 并发重映射(Concurrent Remap): 重映射所做的就是修正整个堆中指向重分配集中旧对象的所有引用,这一点从目标角度看是与 Shenandoah 并发引用更新阶段一样的,但是 ZGC 的并发重映射并不是必须要 “迫切” 去完成的任务,因为前面说过,即使是旧引用,它也是可以自愈的,最多只是第一次使用时多一次转发和修正操作。重映射清理这些旧引用的主要目的是为了不变慢 (还有清理结束后可以释放转发表这样的附带收益),所以说并不是很“迫切”。因此,ZGC 很巧妙地把并发重映射阶段所要做的工作,合并到了下一次垃圾收集循环中的并发标记阶段里去完成,反正他们都是要便利所有对象的,这样合并就节省了一次遍历对象图的开销。一旦所有指针都被修正之后,原来记录新旧对象关系的转发表就可以释放掉了。

分代 or 不分代?

相比 G1、Shenandoah 等先进的垃圾收集器,ZGC 在实现细节上做了一些不同的权衡选择,譬如 G1 需要通过写屏障来维护记忆集,才能处理跨代指针,得以实现 Region 的增量回收。记忆集要占用大量的内存空间,写屏障也对正常程序运行造成额外负担,这些都是权衡选择的代价。

ZGC 就完全没有使用记忆集,它甚至连分代都没有,连像 CMS 中那样只记录新生代和老年代间引用的卡表也不需要,因而完全没有用到写屏障,所以给用户变成带来的运行负担也要小得多。这是优势。

ZGC 的这种选择也限制了它能承受的对象分配速率不会太高。这是劣势。

Note

可以想象一下场景来理解 ZGC 这个劣势:ZGC 准备要对一个很大堆做一次完整的并发收集,假设其全过程要持续十分钟以上,在这段时间里面,由于应用的对象分配速率很高,将创造大量的新对象,这些新对象很难进入当次收集的标记范围,通常就只能全部当作存活对象来看待——尽管其中绝大部份对象都是朝生夕灭的,这就产生了大量的浮动垃圾。如果这种高速分配持续维持的话,每一次完整的并发收集周期都会很长,回收到的内存空间持续小于期间并发产生的浮动垃圾所占用的空间,堆中剩余可腾挪的空间就越来越小了。目前唯一的办法就是尽可能增加堆容量大小,获得更多喘息的时间。但是若要从根本上提升 ZGC 能够应对的对象分配速率,还是需要引入分代收集。让新生对象都在一个专门的区域中创建,然后专门针对这个区域进行更频繁、更快的收集。(继续引入分代收集的必要性。Java 21 已经引入分代 ZGC, JEP 439,Johnny 注

ZGC 的性能

ZGC 的性能表现相当亮眼,从官方给出的测试结果来看,用“令人震惊的、革命性的 ZGC“ 来形容都不为过。

吞吐量

在 ZGC 的 ”弱项“ 吞吐量方面,以低延迟为首要目标的 ZGC 已经达到了以高吞吐量为目标的 Parallel Scavenge 的 99%,直接超越了 G1。

ZGC的吞吐量测试

ZGC的吞吐量测试

停顿时间

而在 ZGC 的强项停顿时间测试上,它就毫不留情地与 Parallel Scavenge、G1 拉开了两个数量级的差距。不论是 95% 停顿、99% 停顿、99.9% 停顿,抑或是最大停顿时间,ZGC 均能毫不费劲地控制在了 10 毫秒以内,以至于把它和另外两款停顿数百近千毫秒的收集器放到一起对比,就几乎显示不了 ZGC 的柱状条。

ZGC的停顿时间测试

ZGC的停顿时间测试

尾声

ZGC 原本是 Oracle 作为一项商业特性来设计和实现的,只不过在它横空出世的 JDK 11 时期,正好适逢 Oracle 调整许可证授权,把所有商业特性都开源给了 OpenJDK,所以用户对其商业特性并没有明显的感知。ZGC 有着令所有开发人员趋之若鹜的优秀性能,让以前大多数人只是听说,但从未用过的 “Azul 式的垃圾收集器” 一下子飞入寻常百姓家。相信在它完全成熟后,将会成为服务端、大内存、低延迟应用的首选收集器的有力竞争者。

本文节选自 周志明《深入理解Java虚拟机:JVM高级特性与最佳实践(第三版)》,近些年来,ZGC 的发展日臻成熟,有很多内容需要更新或补充,然此文深入浅出,娓娓道来,仍不失为了解 ZGC 之必读文章。

使用社交账号登录

  • Loading...
  • Loading...
  • Loading...
  • Loading...
  • Loading...