Advanced-Java.03a1.ZGC回收器详解

ZGC(The Z Garbage Collector)是 JDK 11中推出的一款低延迟垃圾回收器,它的设计目标包括:

  • 停顿时间不超过10ms(低延迟,最大特点);
  • 停顿时间不会随着堆的大小,或者活跃对象的大小而增加;
  • 支持8MB~4TB 级别的堆(未来支持16TB)

STW 对吞吐量的影响

部分上游业务要求风控服务65ms 内返回结果,并且可用性要达到99.99%。但因为 GC 停顿,我们未能达到上述可用性目标。当时使用的是 CMS 垃圾回收器,单次 Young GC 40ms,一分钟10次,接口平均响应时间30ms。

受影响的请求分2部分:1是 GC 持续时间内的(30ms),2是 GC 发生前30ms 内到来的请求(40ms),所以一次 YGC 受影响的请求持续时间是70ms,一分钟10次 YGC,那么就是一分钟内,有700ms 的请求会受影响(这个比例按照持续时间计算,是70ms / 60000ms = 1.12%),这段时间内的请求,其耗时会增加0~40ms 不等,最差就是多等40ms,也就是 GC 发生前30ms ~ GC 刚开始的时候到来的请求(30ms),这些请求的耗时会多出整整40ms;

+40ms     | +0~40ms  |      -无影响-      |
req | STW | req | req |
--------► ---------► --------► --------►
| 30ms | 40ms | 30ms | 30ms |

G1停顿时间瓶颈

G1的混合回收过程可以分为标记阶段、清理阶段和复制阶段。

../_images/Advanced-Java.03a1.ZGC回收器-2023-06-06-3.png

初始标记(STW), 并发标记, 再标记(STW), 清理(STW), 初始转移(STW), 并发转移

标记阶段停顿分析

  • 初始标记阶段:初始标记阶段是指从GC Roots出发标记全部直接子节点的过程,该阶段是STW的。由于GC Roots数量不多,通常该阶段耗时非常短。
  • 并发标记阶段:并发标记阶段是指从GC Roots开始对堆中对象进行可达性分析,找出存活对象。该阶段是并发的,即应用线程和GC线程可以同时活动。并发标记耗时相对长很多,但因为不是STW,所以我们不太关心该阶段耗时的长短。
  • 再标记阶段:重新标记那些在并发标记阶段发生变化的对象。该阶段是STW的。

清理阶段停顿分析

  • 清理阶段清点出有存活对象的分区和没有存活对象的分区,该阶段不会清理垃圾对象,也不会执行存活对象的复制。该阶段是STW的。

复制阶段停顿分析

  • 复制算法中的转移阶段需要分配新内存和复制对象的成员变量。转移阶段是STW的,其中内存分配通常耗时非常短,但对象成员变量的复制耗时有可能较长,这是因为复制耗时与存活对象数量与对象复杂度成正比。对象越复杂,复制耗时越长。

四个 STW 过程中,初始标记因为只标记 GC Roots,耗时较短。再标记因为对象数少,耗时也较短。清理阶段因为内存分区数量少,耗时也较短。转移阶段要处理所有存活的对象,耗时会较长。因此,G1停顿时间的瓶颈主要是标记-复制中的转移阶段 STW。为什么转移阶段不能和标记阶段一样并发执行呢?主要是 G1未能解决转移过程中准确定位对象地址的问题。

G1的 Young GC 和 CMS 的 Young GC,其标记-复制全过程 STW,这里不再详细阐述。

ZGC 细节

动态 Region

ZCG 不再有 CMS、G1的分代概念,同时采用基于 Region 的堆内存布局(与 Shenandoah 和 G1一样),但与它们不同的是, ZGC 的 Region 具有动态性 :

  • 动态创建
  • 动态销毁
  • 以及动态的区域容量大小

ZGC 的 Region 可以具有大、中、小三类容量:

  • Small Region:2MB,主要用于放置小于 256 KB 的小对象。
  • Medium Region:32MB,主要用于放置大于等于 256 KB 小于 4 MB 的对象。
  • Large Region:N * 2MB,这个类型的 Region 是可以动态变化的,不过必须是 2MB 的整数倍,最小支持 4 MB。每个 Large Region 只放置一个大对象,并且是不会被重分配的。

../_images/Advanced-Java.03a1.ZGC-Regions.png

ZGC阶段

ZGC 的垃圾回收周期分为如下几个阶段:

../_images/Advanced-Java.03a1.ZGC-Staged.png

  • 初始标记:初始标记阶段是指从 GC Roots 出发标记全部直接子节点的过程,GC Roots 数量不多,通常该阶段耗时非常短,需要STW
  • 并发标记/对象重定位:从 GC Roots 开始对堆中对象进行可达性分析,并发标记耗时相对长很多,但该阶段是并发的,无需 STW
  • 再标记:重新标记那些在并发标记阶段发生变化的对象。再标记因为对象数少,耗时较短,需要 STW
  • 并发转移准备:
  • 初始转移:只处理 GC Roots,耗时较短,需要 STW
  • 并发转移:GC 线程和应用线程一起工作;

ZGC 只有三个 STW 阶段:初始标记再标记初始转移

初始标记和初始转移分别都只需要扫描所有 GC Roots,其处理时间和 GC Roots 的数量成正比,一般情况耗时非常短;再标记阶段 STW 时间很短,最多1ms,超过1ms 则再次进入并发标记阶段。即 ZGC 几乎所有暂停都只依赖于 GC Roots 集合大小,停顿时间不会随着堆的大小或者活跃对象的大小而增加。

ZGC 转移阶段的优化:

  • G1 和 ZCG 都基于 标记-复制算法,所以都会有转移阶段,即把存活对象转移到新的 Region 中;
  • G1的 转移阶段完全 STW 的,且停顿时间随存活对象的大小增加而增加;
  • ZGC 把转移阶段分为了初始/并发两个阶段,并发转移虽然耗时较长但不需要 STW;

ZGC 低延迟的实现

ZGC 在标记、转移和重定位阶段几乎都是并发的,这是 ZGC 实现停顿时间小于10ms 目标的最关键原因。

ZGC 是如何做到并发标记和转移呢?这就要提到 ZGC 背后的核心技术 —— 读屏障( load barrier )和染色指针( colored pointer )

并发转移 中“并发”意味着GC线程在转移对象的过程中,应用线程也在不停地访问对象。假设对象发生转移,但对象地址未及时更新,那么应用线程可能访问到旧地址,从而造成错误。而在ZGC中,应用线程访问对象将触发“读屏障”,如果发现对象被移动了,那么“读屏障”会把读出来的指针更新到对象的新地址上,这样应用线程始终访问的都是对象的新地址。那么,JVM是如何判断对象被移动过呢?就是利用对象引用的地址,即着色指针。下面介绍着色指针和读屏障技术细节。

➤ ZGC 通过 指针着色读屏障 来实现的并发标记和并发转移:

(1)指针着色:
ZGC 仅支持64位系统,对象指针占用空间=64bit,其中真正作为地址的是0-41位,42-45存储指针着色,高18位不使用(42+4+18);

../_images/Advanced-Java.03a1.ZGC回收器-2023-06-06-1.png

其中表示染色状态的4位:

  • finalizable 位:该对象只能通过终结器(finalizer)访问
  • remap 位:引用是最新的,并指向对象的当前位置
  • marked0 和 marked1 位:标记可达对象

对比过去的 GC,对象的存活信息是放在对象头的 Mark Word 中,而 ZGC 将对象存活信息存储在对象指针的4bits 中,少了一次读内存的操作。

传统的垃圾回收器需要进行一次内存访问,并将对象存活信息放在对象头中;而在 ZGC 中,只需要设置对象指针的第42~45位即可,并且因为是寄存器访问,所以速度比访问内存更快。

(2)读屏障:

即在“读操作”之后加入一小段代码,这段代码会修改指针的着色,这里的读操作指仅“从堆中读取对象引用”,无论GC 标记线程应用线程 对这些引用的读取,都会被加入读屏障。

读屏障示例:

Object o = obj.FieldA // 从堆中读取引用,需要加入屏障
<Load barrier>

Object p = o // 无需加入屏障,因为不是从堆中读取引用
o.dosomething() // 无需加入屏障,因为不是从堆中读取引用
int i = obj.FieldB //无需加入屏障,因为不是对象引用


接下来详细介绍 ZGC 一次垃圾回收周期中地址视图的切换过程(这里的几种视图可以理解为几种不同的指针着色标记):

  • 初始化:ZGC 初始化之后,整个内存空间的地址视图被设置为Remapped。程序正常运行,在内存中分配对象,满足一定条件后垃圾回收启动,此时进入标记阶段。
  • 并发标记阶段:第一次进入标记阶段时视图为 M0,如果对象被 GC 标记线程或者应用线程访问过,那么就将对象的地址视图从 Remapped 调整为 M0。所以,在标记阶段结束之后,对象的地址要么是 M0视图,要么是 Remapped。*如果对象的地址是 M0视图,那么说明对象是活跃的;如果对象的地址是 Remapped 视图,说明对象是不活跃的。
  • 并发转移阶段:标记结束后就进入转移阶段,此时地址视图再次被设置为 Remapped。如果对象被 GC 转移线程或者应用线程访问过,那么就将对象的地址视图从 M0调整为 Remapped

在标记阶段存在两个地址视图 M0和 M1,上面的过程显示只用了一个地址视图。
之所以设计成两个,是为了区别前一次标记和当前标记。第一次进入并发标记,视图调整为 M0,第二次进入并发标记阶段后,地址视图调整为 M1;

如下图所示,有3个对象1、2、3,初始阶段都是 “Remapped”..

../_images/Advanced-Java.03a1.ZGC回收器-2023-06-06-2.png

ZGC 线上效果

美团的案例:

(1)可显著降低 TP999 和 TP99耗时,ZGC 在低延迟(TP999 < 200ms)场景中收益较大:

  • TP999:下降12~142ms,下降幅度18%~74%。
  • TP99:下降5~28ms,下降幅度10%~47%。

但超低延迟(TP999 < 20ms)和高延迟(TP999 > 200ms)服务收益不大,原因是这些服务的响应时间瓶颈不是 GC,而是外部依赖的性能。

TP(Top Percentile)是一项衡量系统延迟的指标:TP999表示99.9%请求都能被响应的最小耗时;TP99表示99%请求都能被响应的最小耗时。

(2)低延迟的代价是吞吐下降

对吞吐量优先的场景,ZGC 可能并不适合。例如,Zeus 某离线集群原先使用 CMS,升级 ZGC 后,系统吞吐量明显降低。究其原因有二:

  • 第一,ZGC 是单代垃圾回收器,而 CMS 是分代垃圾回收器。单代垃圾回收器每次处理的对象更多,更耗费 CPU 资源;
  • 第二,ZGC 使用读屏障,读屏障操作需耗费额外的计算资源。

吞吐量: 系统的生命周期内,应用程序所花费的时间和系统总运行时间的比值。系统总运行时间 = 应用程序耗时 + 总 GC 耗时。
for example:如果系统运行了 100 分钟,全部 GC 耗时 1 分钟,则系统吞吐量=99% 注意区别系统设计中的“Thoughput”

ZGC 调优实践

  • -Xms -Xmx:堆的最大内存和最小内存,这里都设置为10G,程序的堆内存将保持10G 不变。

  • -XX:+UnlockExperimentalVMOptions -XX:+UseZGC:启用 ZGC 的配置。

  • -XX:ConcGCThreads:并发回收垃圾的线程。默认是总核数的12.5%,8核 CPU 默认是1。调大后 GC 变快,但会占用程序运行时的 CPU 资源,吞吐会受到影响。

  • -XX:ParallelGCThreads:STW 阶段使用线程数,默认是总核数的60%。

  • -XX:ZCollectionInterval:ZGC 发生的最小时间间隔,单位秒。

  • -XX:ZAllocationSpikeTolerance:ZGC 触发自适应算法的修正系数,默认2,数值越大,越早的触发 ZGC。

  • -XX:+UnlockDiagnosticVMOptions -XX:-ZProactive:是否启用主动回收,默认开启,这里的配置表示关闭。

  • -Xlog:设置 GC 日志中的内容、格式、位置以及每个日志的大小。

ZGC 日志分析

https://tech.meituan.com/2020/08/06/new-zgc-practice-in-meituan.html

Reference