vlambda博客
学习文章列表

中篇|丝般顺滑!全新垃圾回收器 ZGC 原理与调优

我们在上一篇文章中介绍了 ZGC 的基本概念和阿里的 ZGC 规模化实践,看到阿里业务和云上客户享受到 ZGC 带来的响应时间优化,同时也遭遇到了一些实际问题。为了更好地使用 ZGC,我们需要了解一些 ZGC 的原理,并学会分析 ZGC 日志,对 ZGC 进行调优。
中篇|丝般顺滑!全新垃圾回收器 ZGC 原理与调优

ZGC原理

从宏观的角度看,ZGC 是一种并发( concurrent )的压缩式( compacting ) GC 算法:

  • 并发:在 Java 线程运行的同时,GC 线程在后台默默执行;

  • 压缩式:定期将堆中活跃对象整理到一起,解决内存碎片化问题。

相比于 Java 原有的百毫秒级的暂停的 Parallel GC 和 G1,以及未解决碎片化问题的 CMS ,并发和压缩式的 ZGC 可谓是 Java GC 能力的一次重大飞跃—— GC 线程在整理内存的同时,可以让 Java 线程继续执行。
 ZGC 采用标记-压缩策略来回收 Java 堆:ZGC 首先会并发标记( concurrent mark )堆中的活跃对象,然后并发转移( concurrent relocate )将部分区域的活跃对象整理到一起。这里与早先的 Java GC 不同之处在于,目前 ZGC 是单代垃圾回收器,在标记阶段会遍历堆中的全部对象。
那么问题来了,ZGC 是如何做到并发标记和转移呢?这就要提到ZGC背后的核心技术——读屏障( load barrier )和染色指针( colored pointer )。

ZGC 的读屏障是在指针加载的操作的时候,插入一段针对该指针的处理逻辑:

  • 如果指针指向已经被转移的对象,那么读屏障将修正该指针;

  • 在标记阶段,如果该指针未被标记,那么读屏障将标记该指针;

  • 在转移阶段,如果该指针指向需要转移的区域,那么该指针指向的对象将被转移,然后修正该指针。

读屏障能够确保在 GC 线程与 Java 线程并发运行的情况下,每次指针载入都能访问到正确的对象。

ZGC 的染色指针是将指针高位未使用的 bit 作为指针的颜色,表示指针的状态,使得读屏障在处理指针的时候,能够直接得到该指针的状态,决定采用何种方式来处理指针。生产就绪的 ZGC 支持 2^44=16TB 的寻址空间,实际上使用了 44+4=48 个 bit 作为染色指针的地址,其中高 4 位是指针的颜色。染色指针与读屏障相互配合,将读屏障中的条件判断部分转换为对于指针颜色的判断,如果指针颜色是“错误”的,那么读屏障就会将指针修复为“正确”的。
中篇|丝般顺滑!全新垃圾回收器 ZGC 原理与调优

ZGC日志分析

单次ZGC周期实际执行过程中需要三次短促的暂停,每次暂停之后是若干并发阶段。

[2020-12-23T13:30:57.402+0800] GC(10) Garbage Collection (Allocation Rate)[2020-12-23T13:30:57.408+0800] GC(10) Pause Mark Start 2.918ms[2020-12-23T13:30:58.083+0800] GC(10) Concurrent Mark 674.216ms[2020-12-23T13:30:58.087+0800] GC(10) Pause Mark End 1.336ms[2020-12-23T13:30:58.105+0800] GC(10) Concurrent Process Non-Strong References 18.293ms[2020-12-23T13:30:58.111+0800] GC(10) Concurrent Reset Relocation Set 5.533ms[2020-12-23T13:30:58.111+0800] GC(10) Concurrent Destroy Detached Pages 0.001ms[2020-12-23T13:30:58.121+0800] GC(10) Concurrent Select Relocation Set 10.148ms[2020-12-23T13:30:58.130+0800] GC(10) Concurrent Prepare Relocation Set 9.083ms[2020-12-23T13:30:58.136+0800] GC(10) Pause Relocate Start 2.452ms[2020-12-23T13:30:58.203+0800] GC(10) Concurrent Relocate 66.595ms... (此处忽略一些数据统计信息)[2020-12-23T13:30:58.203+0800] GC(10) Garbage Collection (Allocation Rate) 62020M(76%)->41270M(50%)
上面的 GC 日志展示了一个典型的 ZGC 周期,每一行每个周期中以 Pause 开头的阶段即为暂停阶段,这三个暂停阶段分别为
  • Pause Mark Start(标记开始暂停);

  • Pause Mark End(标记结束暂停);

  • Pause Relocate Start(转移开始暂停)。

可以看到上面的 GC 日志中,ZGC 三个暂停阶段的时间明显低于 10ms 。这三个暂停阶段主要承担 GC Roots 的标记和转移,以及标记线程同步的工作。

这三个暂停阶段后面以 Concurrent 开头的阶段即为并发阶段,其中最核心的两个阶段是

  • Concurrent Mark(并发标记);

  • Concurrent Relocate(并发转移)。

其余的并发阶段主要是并发转移之前的一些预备工作。

ZGC 各个阶段的图示

目前 ZGC 的并发标记会标记整个堆中的所有活跃对象,有别于 G1/CMS/Parallel GC 等分代 GC ,属于单代 GC 。并发标记的过程会顺便修复堆中的错误指针。 为了降低转移对象的负担,ZGC 的并发转移的策略会选择碎片化程度达到某个阈值(ZFragmentationLimit)的区域,类似于 G1 的 Garbage First 策略。

ZGC调优

下面介绍ZGC相关的调优细节,用户应至少完成基本调优部分。

基本调优

一般来说,ZGC 应当设置堆空间大小( Xmx )和并发 GC 线程数量(ConcGCThreads)。我们建议所有 ZGC 用户应当开启 GC 日志,通常建议开启- Xlog:gc*:gc.log:time ,能够记录较多的 ZGC 细节。

堆空间大小

GC 通常需要开发者指定堆空间大小,具体数值应该大于堆内活跃对象的总大小。冗余空间比例越高,GC 性能通常越好。例如估计对象总大小达到 32GB ,可设置- Xmx40g ,代表开启 40GB 的堆。

ZGC 与传统 GC 的不同之处在于,ZGC 在回收对象的同时,Java 线程也在分配新对象。因此 ZGC 比传统 GC 需要更高比例的冗余空间。

每一轮 ZGC 的过程中分配的对象总大小可以用“分配速度·单轮 ZGC 时间”来估算,因此堆空间的大小应大于“活跃对象的总大小+单次 ZGC 期间分配的对象总大小”。

上述的“分配速度”和“单轮 ZGC 时间”均可以在 GC 日志中找到统计信息。

并发GC线程数量

ZGC 缺省的并发 GC 线程数量是 1/8 的 CPU 核数,例如 16 核的机器,如果没有指定 ConcGCThreads,那么 ZGC 就会开启 2 个并发 GC 线程。

在 GC 日志中,如果频繁出现“ Allocation Stall ”,代表回收跟不上分配的情况,那么可能需要提高 ConcGCThreads 了。当然 ConcGCThreads 不能无限制地增加,因为过多并发的 GC 线程会占据 CPU 资源,甚至影响 Java 线程的正常执行。

注意并发 GC 线程(ConcGCThreads)与并行 GC 线程(ParallelGCThreads)是不同的,前者与 Java 线程可以并发执行,后者是 GC 暂停时的 GC 线程。

进阶调优

Product ready ZGC 的功能同时也支持若干进阶 ZGC 调优选项,可参考 Alibaba Dragonwell 11.0.11.7 的使用说明

https://github.com/alibaba/dragonwell11/wiki/Alibaba-Dragonwell-11-Release-Notes#110117

进阶调优最核心的部分是 GC 触发时机的控制。由于 ZGC 在回收时依然分配对象,于是 ZGC 不能等到堆空间满了以后才触发 GC ,而需要提前一段时间触发 GC ,使得 ZGC 执行的过程中堆空间不会变满,导致 Allocation Stall 或者 OOM。但是如果 ZGC 触发地过于频繁,则 CPU 资源消耗变多,从而降低吞吐率。

Dragonwell11 支持以下 GC 触发时机相关选项:

  • ZAllocationSpikeTolerance:ZGC 通过估算“分配速度·单轮 ZGC 时间”来估算单次 ZGC 期间分配的对象总大小,只要这个总大小小于当前剩余的堆空间,就需要触发 GC 。但是由于 Java 业务的分配速度往往不是稳定的,因此需要为分配速度乘上“毛刺系数” ZAllocationSpikeTolerance ,从而保守地提前触发 GC 。如果 Java 业务分配速度不稳定,偶尔有 Allocation Stall 的发生,那么就应当考虑适当增加 ZAllocationSpikeTolerance。

  • ZCollectionInterval:定时触发 GC,避免 GC 间隔过长。

  • ZProactive:字面意思是“主动触发 GC ”,用于处理分配速率较低的情况。

  • ZHighUsagePercent:堆的水位超过此百分比,则触发 ZGC 。
只要满足以上 GC 触发时机的条件之一,ZGC 就会触发。

SoftMaxHeapSize 选项可以设置 ZGC 堆空间的“软上限”,介于 Xmx 和 Xms 之间。以上的 ZAllocationSpikeTolerance/ZProactive/ZHighUsagePercent 均以 SoftMaxHeapSize 的值作为 ZGC 堆空间的“软上限”,当分配速度过快时可以扩展到至多 Xmx 的堆空间,当分配速度较慢时可以将堆空间收缩到 Xms 。SoftMaxHeapSize 通常需要打开 - XX:+ZUncommit 。

除此之外还有一些有用的进阶调优功能:

  • ZFragmentationLimit:控制 ZGC 的对象的碎片化程度,ZFragmentationLimit 越低,ZGC 回收越彻底;

  • ZMarkStackSpaceLimit:调节 ZGC 标记栈空间大小;

  • ZUnloadClassesFrequency:控制 ZGC 类卸载的频率;

  • ZRelocationReservePercent:控制 ZGC 的预留分配空间,降低 OOM 风险;

  • ZStatisticsInterval:控制 ZGC 日志中统计信息的输出频率,原先 10 秒一次输出可能会影响 GC 详细信息的解读。

(上面的 ZHighUsagePercent/ZUnloadClassesFrequency/ZRelocationReservePercent 是 Dragonwell11 的特有选项。若切换到其他版本的 OpenJDK 时,请避免使用这些选项。)

在后面的篇章中,读者将看到我们的 Alibaba Dragonwell11 通过对 ZGC 进行生产就绪改造,从而解决生产实践中的一些问题。

欢迎扫码进入钉钉群获得更多 Dragonwell 的支持
关于作者
唐浩,2019 年加入阿里云编程语言与编译器团队,目前从事 JVM 内存管理优化方向的工作。
现 DragonWell 已加入 龙蜥社区 (OpenAnolis )Java 语言与虚拟机 SIG,同时龙蜥操作系统(Anolis OS )8 版本支持 DragonWell 云原生 Java ,欢迎大家加入社区 SIG,参与社区共建。
SIG 地址:
https://openanolis.cn/sig/java/doc/216166872482840581
戳“阅读原文”直达龙蜥社区官网哦~