01、前言
在 Java 应用程序中,垃圾回收(Garbage Collection,以下简称 GC)是一个不可避免的过程,它负责释放不再使用的内存空间以避免内存泄漏。然而,GC 操作通常会导致短暂的停顿时间(Stop the World,以下简称 STW),这对于对延迟敏感的应用程序来说是一个严重的问题——STW 会导致应用程序暂停响应,从而影响用户体验和系统性能。为了解决这个问题,Java 引入了 Z Garbage Collector(以下简称 ZGC),它是一种低延迟垃圾回收器,旨在减少 GC 引起的停顿时间。ZGC 通过使用并发和分区收集技术,大大减少了 STW 的时间和频率,使得应用程序可以在 GC 期间继续运行,从而提供更加平滑和一致的性能。AutoMQ 基于 ZGC 进行了一系列调优,以获得更低的延迟。在本文中,我们将详细介绍 ZGC 的工作原理,以及如何通过调整和优化 ZGC 的配置来实现更低的延迟,从而提高 Java 应用程序的性能和响应能力。
02、ZGC 特点
在介绍 ZGC 的实现原理之前,我们先来了解一下 ZGC 的特点,以便更好地理解 ZGC 的工作原理:
03、ZGC 工作原理
下面我们将详细介绍 ZGC 的工作原理,以便更好地理解 ZGC 的优势和特点。
注意:以下介绍均基于 JDK 17 版本的 ZGC,部分内容可能与其他版本有所不同,例如,没有涉及到 JDK 21 中引入的分代(Generational)ZGC。
3.1 核心概念
着色指针与多重映射
ZGC 使用了一种称为“着色指针(Colored Pointers,又称染色指针)”的技术,它将对象指针的高位用于存储额外的信息,这些额外的信息可以用于标记对象的状态,进而帮助 ZGC 实现高效的并发垃圾回收。ZGC 中着色指针的结构如下图所示:
如上图所示,着色指针的高位包含了 20 位的元数据,这 20 位元数据用于存储对象的标记信息。目前,ZGC 中使用了其中的 4 位,剩余的 16 位保留用于未来的扩展。这 4 位的作用如下:
Java 应用程序本身不会感知到着色指针,当从堆内存中加载对象时,着色指针的读取由读屏障处理。相较于传统的垃圾回收器将对象存活信息记录在对象头中,ZGC 基于着色指针记录了对象状态,在修改状态时仅为寄存器操作,无需访问内存(对象头的 Mark Word),速度更快。由于着色指针在对象地址的高位存储了额外的信息,因此会有多个虚拟地址映射到同一个对象,此即多重映射(Multi-Mapping)。在 ZGC 中,每个对象的物理地址会映射到三个虚拟地址,分别对应着色指针的三种状态,下图展示了多重映射的实际情况:
值得一提的是,某些监控工具(比如 top)没有处理这种多重映射的场景,这会导致其无法正确识别开启了 ZGC 的 Java 进程占用的内存——监控值会显示为实际值的 3 倍,甚至可能会出现使用 100%+ 物理内存的现象。
读屏障
在上一小节中,我们提到了着色指针的读取由读屏障处理。读屏障(Load barriers)是 JIT 编译器(C2)注入到类文件中的代码段,它会在 JVM 解析类文件时添加到所有从堆中检索对象的地方。下面的 Java 代码示例展示了读屏障会被添加的地方:
Object o = obj.fieldA; // 从堆中读取 Object,会触发读屏障
Object p = o; // 没有从堆中加载,不会触发读屏障
o.doSomething(); // 没有从堆中加载,不会触发读屏障
int i = obj.fieldB // 加载的不是对象,不会触发读屏障
具体的插入方式形如:
Object o = obj.fieldA;
// 触发读屏障
if (o & bad_bit_mask) {
// o 的着色指针的颜色不对,进行修复
slow_path(register_for(o), address_of(obj.fieldA));
}
实际的汇编实现:
mov 0x20(%rax), %rbx // Object o = obj.fieldA;
// %rax 寄存 obj 地址,0x20 为 fieldA 在其中的偏移量,%rbx 用于寄存 Object o 的地址
test %rbx, %r12 // if (o & bad_bit_mask)
// %r12 寄存染色指针当前 bad color 的掩码
// ZGC 不支持压缩对象指针(compressed oops),故可以利用为压缩指针预留的 %r12 寄存器
jnz slow_path // %rbx 中的指针为 bad color,修复颜色——按需修改 0x20(%rax) 与 %rbx
ZGC 中,读屏障注入的代码会检查对象指针的颜色,如果颜色是“坏的”,那么读屏障会尝试修复颜色——更新指针,使它指向对象的新位置,或者迁移对象本身。这种处理方式保证了,在一次 GC 期间,对象迁移等重操作仅会在首次加载对象时发生,之后的加载操作则会直接读取对象的新位置,额外开销仅为一次位运算判断。据官方测试,ZGC 读屏障带来的额外性能开销在 4% 左右。
区域化内存管理
类似于 G1GC,ZGC 会动态地将堆划分为独立的内存区域(Region),但是,ZGC 的区域更加灵活,包括小、中、大三种尺寸,活跃区域的数量会根据存活对象的需求而动态增减。将堆划分为区域可以带来多方面的性能优势,包括:
值得注意的是,所谓的“小区域”、“中区域”和“大区域”并不是指区域的大小,而是指区域的类别和用途。例如,一个大区域可能比一个中等区域还要小。下面将介绍不同区域尺寸及其用途:
压缩与迁移
上一小节中提到,区域化的优势之一是可以利用“大多数同一时间创建的对象也会在同一时间离开作用域”的特点。然而,并非所有对象都是这样,在区域内部必然会产生碎片,导致内存利用率下降。基于内部的启发式算法,ZGC 会将主要由不可访问对象组成的区域中的对象复制到新区域中,以便释放旧区域并释放内存,这就是压缩与迁移(Compaction and Relocation)。ZGC 通过两种迁移方法实现压缩:就地迁移和非就地迁移。
值得说明的是,在执行就地迁移时,ZGC 必须首先压缩指定为对象迁移区域内的对象,这可能会对性能产生负面影响。增加堆大小可以帮助 ZGC 避免使用就地迁移。
3.2 工作流程
值得说明的是,在执行就地迁移时,ZGC 必须首先压缩指定为对象迁移区域内的对象,这可能会对性能产生负面影响。增加堆大小可以帮助 ZGC 避免使用就地迁移。
如上图,ZGC 的工作流程主要包括以下几个步骤:
标记阶段开始的同步点,只会执行一些小的操作,例如设置一些标记位和确定全局颜色。值得说明的是,在 JDK 16 之前,该阶段的耗时和 GC Roots(静态变量与线程栈中的局部变量)的数量成正比。因此在 JEP 376 中引入了一种新的算法,将扫描线程栈的操作转移到并发阶段,从而显著减少了该阶段的耗时。
在这个并发阶段,ZGC 将遍历整个对象图,并标记所有对象(根据 GC 周期不同,设置 Marked0 或 Marked1 标记)。同时,将上一个 GC 周期中尚未被重映射的对象(标记仍为 Marked1 或 Marked0)进行重映射。
标记阶段结束的同步点,会处理一些边界情况。
该阶段会处理弱引用、清理不再使用的对象,并筛选出需要迁移的对象(Relocation Set)。
迁移阶段开始的同步点,通知所有涉及到对象迁移的线程。同样的,在 JDK 16 引入 JEP 376 之后,该阶段的耗时不再与 GC Roots 的数量成正比。
该阶段会并发地迁移对象,压缩堆中的区域,以释放空间。迁移后的对象的新地址会记录到转发表(Forwarding Table)中,用于后续重映射时获取对象的新的地址;该转发表是一个哈希表,使用堆外内存,每个区域分别有一个转发表。可以看到,在一个 GC 周期中,STW 的阶段和并发阶段交替执行,并且绝大多数操作均在并发阶段执行。
示例
为了更好地理解 ZGC 的工作原理,下面通过一个例子来展示 ZGC 工作各阶段执行的操作。
a. 遍历到对象 1、2,发现它们位于区域 0(不在迁移集合中),无需迁移,仅将颜色恢复为 Remapped。b. 遍历到对象 4、5、7,均在迁移集合中,需要迁移。
注意:
注意:
04、使用 ZGC
接下来,我们将介绍如何更好地使用 ZGC,以及一些基本的调优方法。
4.1 配置
正如在本文开头所述,ZGC 的一个设计目标是,尽可能自动调整自身的配置参数,以减少手动配置项。但是我们还是应该了解各个配置的含义以及对 ZGC 的影响,以应对实际生产中的各种需求。
4.2 日志
可以通过设置 -Xlog:gc:gc.log 选项以开启 ZGC 日志。其中 "gc" 意为打印所有 tag 中以 "gc" 开头的日志,"gc.log" 为日志存储路径。下面以 AutoMQ 在实际运行时的一次 GC 为例,按照不同的 log tag,解释 ZGC 日志的含义。
"gc,start","gc,task","gc"
[gc,start ] GC(100) Garbage Collection (Timer)
[gc,task ] GC(100) Using 1 workers
...
[gc ] GC(100) Garbage Collection (Timer) 2240M(36%)->1190M(19%)
"gc,phases"
[gc,phases ] GC(100) Pause Mark Start 0.005ms
[gc,phases ] GC(100) Concurrent Mark 1952.113ms
[gc,phases ] GC(100) Pause Mark End 0.018ms
[gc,phases ] GC(100) Concurrent Mark Free 0.001ms
[gc,phases ] GC(100) Concurrent Process Non-Strong References 79.422ms
[gc,phases ] GC(100) Concurrent Reset Relocation Set 0.066ms
[gc,phases ] GC(100) Concurrent Select Relocation Set 12.019ms
[gc,phases ] GC(100) Pause Relocate Start 0.009ms
[gc,phases ] GC(100) Concurrent Relocate 149.037ms
记录了 ZGC 各个阶段的耗时,其中 "Pause" 与 "Concurrent" 分别标识了 STW 阶段与并发阶段。每次 GC 会存在 3 个 "Pause" 阶段,应主要关注它们的耗时。
[gc,load ] GC(100) Load: 2.74/2.02/1.54
记录了过去 1 分钟、5 分钟、15 分钟的平均负载,即系统的平均活跃进程数。
[gc,mmu ] GC(100) MMU: 2ms/93.9%, 5ms/97.6%, 10ms/98.8%, 20ms/99.4%, 50ms/99.7%, 100ms/99.9%
记录了 GC 期间的最小可用性(Minimum Mutator Utilization)。以本次 GC 为例,在任何连续的 2ms 的时间窗口中,应用至少能使用 93.9% 的 CPU 时间。
"gc,ref"
[gc,ref ] GC(100) Soft: 6918 encountered, 0 discovered, 0 enqueued
[gc,ref ] GC(100) Weak: 8835 encountered, 1183 discovered, 4 enqueued
[gc,ref ] GC(100) Final: 63 encountered, 3 discovered, 0 enqueued
[gc,ref ] GC(100) Phantom: 957 encountered, 882 discovered, 0 enqueued
记录了 GC 期间不同类型的引用对象的处理情况。各字段含义如下:
"gc,reloc"
[gc,reloc ] GC(100) Small Pages: 1013 / 2026M, Empty: 2M, Relocated: 41M, In-Place: 0
[gc,reloc ] GC(100) Medium Pages: 2 / 64M, Empty: 0M, Relocated: 9M, In-Place: 0
[gc,reloc ] GC(100) Large Pages: 3 / 150M, Empty: 0M, Relocated: 0M, In-Place: 0
[gc,reloc ] GC(100) Forwarding Usage: 19M
"gc,heap"
[gc,heap ] GC(100) Min Capacity: 6144M(100%)
[gc,heap ] GC(100) Max Capacity: 6144M(100%)
[gc,heap ] GC(100) Soft Max Capacity: 6144M(100%)
[gc,heap ] GC(100) Mark Start Mark End Relocate Start Relocate End High Low
[gc,heap ] GC(100) Capacity: 6144M (100%) 6144M (100%) 6144M (100%) 6144M (100%) 6144M (100%) 6144M (100%)
[gc,heap ] GC(100) Free: 3904M (64%) 3394M (55%) 3372M (55%) 4954M (81%) 4954M (81%) 3340M (54%)
[gc,heap ] GC(100) Used: 2240M (36%) 2750M (45%) 2772M (45%) 1190M (19%) 2804M (46%) 1190M (19%)
[gc,heap ] GC(100) Live: - 543M (9%) 543M (9%) 543M (9%) - -
[gc,heap ] GC(100) Allocated: - 510M (8%) 534M (9%) 570M (9%) - -
[gc,heap ] GC(100) Garbage: - 1696M (28%) 1694M (28%) 75M (1%) - -
[gc,heap ] GC(100) Reclaimed: - - 2M (0%) 1620M (26%) - -
记录了该 GC 周期中,不同阶段(标记前、标记后、迁移前、迁移后)的各类内存的大小。具体地说:
4.3 版本演进
自 2018 年 ZGC 于 JDK 11 中首次发布以来,在后续的 JDK 版本中,ZGC 也在不断演进。在选择使用 ZGC 前,需要了解 ZGC 的版本演进,以及每个版本的特性和限制,并确认对应版本的 ZGC 可以满足使用需求。
一般来说,JDK 16 及之后的 ZGC 性能已经优化得足够好,足以适配绝大多数场景。
05、AutoMQ 的调优实践
AutoMQ 1 是我们基于云重新设计的云原生流系统,通过将存储分离至对象存储,在保持和 Apache Kafka 100% 兼容的前提下,可以为用户提供高达 10 倍的成本优势以及百倍的弹性优势。在流系统的应用场景中,诸如金融交易、实时推荐等场景都对延迟有非常高的要求。因此在设计 AutoMQ 时候,我们也十分重视延迟指标的优化。在 AutoMQ 的实现中,我们需要尽可能地减少 GC 的停顿时间。而 ZGC 低延迟的特性完美匹配了我们的场景,AutoMQ 通过使用 ZGC,将 STW 时间降低到了 50μs 以下,大大提升了服务的性能,从而为用户提供端到端个位数毫秒的延迟能力。
5.1 案例
下面介绍一些 AutoMQ 在使用 ZGC 时遇到的问题与解决方法。
堆大小选取
使用 ZGC 的第一件事,就是确定堆的大小。有以下几个方面需要考虑:
最终经过充分压测,将 AutoMQ 在经典机型(2 vCPU,16 GiB RAM)上堆大小相关的配置设为:
-Xms6g -Xmx6g -XX:MaxDirectMemorySize=6g -XX:MetaspaceSize=96m
由于 AutoMQ 的缓存 Log Cache 与 Block Cache 都使用了 DirectByteBuffer,故还配置了 6 GB 的堆外内存。
在该配置下,可以做到:
流量激增时延迟抖动
现象
当机器承载流量激增时(从 0 MBps 上升至 80 MBps),会出现数次 “Allocation Stall”(随后自动恢复),导致内存分配阻塞,应用卡顿。
分析
默认配置下,ZGC 会基于内置的自适应算法决定 GC 频率,在该算法下,GC 频率主要由对象分配频率决定。但是,当应用压力突然上升时,该算法可能无法及时感知,导致 GC 不及时,进而导致 Allocation Stall。
解决方法
AutoMQ 将 -XX:ZCollectionInterval 设置为 5s,没有修改 -XX:ZAllocationSpikeTolerance(这是因为,每 5 秒进行一次 GC 时,已经能够承载较大的压力,不会再有压力大幅上升的情况)。进行如上配置后,可以做到:
应用启动后 GC 压力逐渐升高
现象
在应用启动后,随着时间的推移,GC 频率逐渐上升、耗时变长、CPU 占用升高,并最终发生 “Allocation Stall”。
分析
检查 GC 日志,发现每次 GC 时,存活对象的大小逐渐增加,导致可用内存减少,最终导致 Allocation Stall。解决方法检查 Heap Dump,发现某模块存在内存泄露,导致无用对象没有及时释放,最终导致上述问题。修复该问题后,AutoMQ 存活对象的大小维持在 500 MB~600 MB,极端场景下不超过 800 MB。
超大规模集群中 GC 压力高
现象
在超大规模集群压测(90 节点、100,000 分区、6 GiB/s 流量)中,发现 Active Controller CPU 占用达 80%,检查火焰图发现 ZGC 占用了一半以上的 CPU 时间。
分析
检查 GC 日志,发现 GC 耗时偏高(约 5s,主要为标记阶段耗时),且存活对象较多(约 1800 MB)。检查 Heap Dump,发现为元数据相关的对象较多,导致 ZGC 遍历标记较慢,且占用大量 CPU。
解决方法
5.2 调优效果
AutoMQ 经过大量的压测与调优,得益于 ZGC 并发 GC 的优势,实现了极低的延迟。下表对比了 AutoMQ 在 ZGC 和 G1GC 下的表现:
*:测试环境为 2 vCPU,16 GiB RAM。测试负载为 4,800 分区,80 / 80 MBps 生产/消费流量,1,600 Produce/s,1,600 Fetch/s
**:ZGC 的配置参数为 -XX:+UseZGC -XX:ZCollectionInterval=5
***:G1GC 的配置参数为 -XX:+UseG1GC -XX:InitiatingHeapOccupancyPercent=35 -XX:G1HeapRegionSize=16M -XX:MinMetaspaceFreeRatio=50 -XX:MaxMetaspaceFreeRatio=80 -XX:+ExplicitGCInvokesConcurrent
可以看到,AutoMQ 在使用 ZGC 时,由于 STW 时间极短,发送延迟大幅降低;以少量的 CPU 消耗为代价,整体性能大幅提升。
06、总结
在本文中,我们详细介绍了 ZGC 的工作原理和调优方法,以及 AutoMQ 基于 ZGC 调优的实践经验。通过调整和优化 ZGC 的配置,我们成功降低了 AutoMQ 的延迟,提高了系统的性能和响应能力。我们希望这些经验可以帮助更多的 Java 开发者更好地理解和使用 ZGC,从而提升他们的应用程序的性能和稳定性。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。