JVMNotes

第 18 章:ZGC

zjc 于 2026-01-18 发布

这是《JVM 零基础实战指南》的独立章节版。本章从概念、实操和生产排查三个视角展开,代码块保留了原书可直接运行的版本。 ZGC 是 HotSpot 中面向大堆、低延迟场景的垃圾收集器。它的核心承诺是把大部分 GC 工作并发执行,让停顿时间主要取决于根扫描等少量阶段,而不是堆大小、对象数量或整理工作量。

不同 JDK 版本的 ZGC 差异很大。本章以常见 LTS 为主线:JDK 11 引入实验版 ZGC,JDK 15 转正,JDK 17 持续增强,JDK 21 引入分代 ZGC。使用具体版本时,应以该版 JVM 的官方文档和 -XX:+PrintFlagsFinal 输出为准。

18.1 设计目标

ZGC 的目标可以概括为四点:

  1. 停顿时间不随堆大小线性增长;
  2. 支持 TB 级别的大堆;
  3. 吞吐量损失可接受;
  4. 支持并发整理,避免长时间 Stop-The-World 压缩。
传统整理型 GC:
  堆越大、存活对象越多 -> 搬迁和修正引用越久 -> STW 越长

ZGC:
  并发标记
  并发搬迁
  并发修正
  极短 STW 阶段

它不是“零停顿”。仍有短暂的全局同步点,只是这些阶段的工作量被刻意控制得很小。

18.2 着色指针

ZGC 使用对象地址中的若干位记录 GC 状态,这被称为着色指针。

简化示意:

64 位对象地址
+------------------+-------+----------------------+
| 可用作普通地址位 | GC 位 | 实际偏移信息         |
+------------------+-------+----------------------+
                        |
                        +-- Marked0 / Marked1 / Remapped 等

常见视图包括:

视图 含义
Marked0 一轮标记周期使用的活跃视图
Marked1 交替标记周期使用的活跃视图
Remapped 引用已指向搬迁后的新地址

着色指针把元数据放在指针里,而不是额外维护一张巨大的引用表。应用线程读写引用时,JVM 可以根据指针颜色决定是否需要处理。

18.3 读屏障

ZGC 的关键机制是读屏障。它是 JVM 在应用线程读取对象引用时插入的一小段逻辑。

简化流程:

读取引用
  -> 指针颜色是否与当前 GC 阶段匹配?
     -> 匹配:直接访问
     -> 不匹配:
        1. 检查对象是否已被搬迁
        2. 需要时帮助搬迁
        3. 修正引用
        4. 继续业务逻辑

这带来一个重要特性:应用线程在 GC 过程中可以“顺手”完成部分搬迁相关动作。代价是读屏障会增加额外指令,因此 ZGC 的吞吐量通常低于 Parallel,但能换取稳定停顿。

18.4 堆布局

ZGC 将堆划分为多个 Region,也常称为 ZPage。

类型 典型对象大小
small 小对象
medium 中等对象
large 大对象
ZGC 堆
+--------+--------+--------+--------+
| small  | small  | medium | large  |
| Region | Region | Region | Region |
+--------+--------+--------+--------+

Region 大小由 JVM 根据堆大小推导,也可以通过参数控制。堆越大,Region 通常越大。大对象可以独占一个或多个连续 Region。

18.5 GC 周期

非分代 ZGC 的简化周期:

1. 初始标记       极短 STW,扫描根
2. 并发标记       与应用并发执行
3. 再标记         短 STW,处理剩余根和引用
4. 并发预备搬迁   选择待整理 Region
5. 初始搬迁       短 STW,搬迁少量关键对象
6. 并发搬迁       与应用并发搬迁对象
7. 并发修正       应用线程和 GC 修正引用

实际日志中的阶段名称会随 JDK 版本变化。看日志时应重点抓三类信息:

  1. 每个阶段的耗时;
  2. GC 触发原因;
  3. 分配停顿或负载是否过高。

18.6 分代 ZGC

JDK 21 引入分代 ZGC,把对象划分为年轻代和老年代,并假设大多数对象朝生夕死。

分代 ZGC
+----------------------+
| Young Generation     |
| 频繁回收,成本低     |
+----------------------+
| Old Generation       |
| 较少回收,避免长期对象反复扫描 |
+----------------------+

分代带来的收益:

  1. 年轻对象死亡快时,减少无效工作;
  2. 降低分配压力对老年代的影响;
  3. 改善常见 Web、RPC、短生命周期任务场景的吞吐。

同时也引入了写屏障和跨代引用处理的成本。如果对象长期存活比例很高,分代收益会下降。

常用开关:

java -XX:+UseZGC -XX:+ZGenerational -jar app.jar

在较新的 JDK 中,分代 ZGC 可能已经是默认形态;在更早版本中该参数不存在。不要把某个版本的默认值当成永久结论。

18.7 启用与基础参数

启用 ZGC:

java -XX:+UseZGC -Xms16g -Xmx16g -jar app.jar

建议生产环境将 -Xms-Xmx 设置成相同值,减少堆伸缩带来的干扰。

Soft max heap:

java -XX:+UseZGC -Xmx24g -XX:SoftMaxHeapSize=16g -jar app.jar

含义:

参数 含义
-Xmx 硬上限,超过会触发分配失败或 OOM
-XX:SoftMaxHeapSize 软目标,ZGC 会尽量把堆控制在其以下

这个组合常用于希望平时用 16g、高峰期允许临时扩展到 24g 的服务。

18.8 触发时机

ZGC 会根据分配速率、堆占用、元数据分配、GC 周期耗时等因素主动触发。常见触发原因包括:

触发原因 方向
Allocation Stall 分配速度超过回收速度,应用等待内存
GC Trigger: Allocation Rate 分配速率高
Warmup JVM 启动初期预热
Proactive 主动回收,避免堆持续增长
Metadata 元空间相关分配压力

Allocation Stall 是重点告警信号。它表示应用线程因为内存暂时不可用而等待,会造成接口延迟抖动。

18.9 观察日志

启用统一日志:

java -XX:+UseZGC \
  -Xlog:gc*,safepoint:file=gc.log:time,uptime,level,tags:filecount=10,filesize=50m \
  -jar app.jar

关注字段:

Garbage Collection
  周期编号与原因
Pause Mark Start
Pause Mark End
Concurrent Mark
Concurrent Relocate
Allocation Stall
Used / Live / Allocated

健康形态通常是:

  1. GC 周期稳定推进;
  2. 并发阶段耗时长但停顿短;
  3. Live 数据平稳;
  4. 没有 or 极少 Allocation Stall;
  5. 停顿时间分布符合目标。

18.10 生产注意事项

第一,不要只看平均停顿。低延迟系统要看 P99、P999、最大值和停顿次数。

第二,ZGC 更耗内存。着色指针、多视图、Region 管理和并发搬迁都需要空间,不能按 Parallel 的经验精确套用。

第三,堆不能压得太满。ZGC 需要留出并发回收的余量。Live 数据长期接近 -Xmx 时,应扩容或减少对象。

第四,CPU 资源要充足。并发 GC 线程会和应用争抢 CPU。容器中要正确暴露 CPU 配额,避免 JVM 误判可用资源。

第五,谨慎使用巨大对象。超大数组或超大缓冲区可能造成 Region 分配压力,甚至让优势消失。

18.11 常见故障与排查

现象 可能原因 排查
Allocation Stall 频繁 堆不足、分配速率过高、CPU 不足 看 GC 日志、CPU、分配速率
吞吐明显下降 读屏障成本、GC 线程过多 对比其他收集器,压测验证
堆占用持续上升 缓存泄漏、Live 数据增长 堆 dump 分析
启动时 CPU 高 预热、类加载、初始 GC 观察 JFR 和启动日志
容器中被 OOM Kill JVM 堆外内存超限 检查总内存和容器 limit

排查模板:

1. 收集 GC 日志、容器 CPU/内存 limit、JFR
2. 确认 Allocation Stall 次数和耗时
3. 查看 Live 数据是否持续增长
4. 查看堆外内存、直接内存、元空间
5. 用堆 dump 确认对象类型
6. 调整堆、软上限、CPU 配额后压测对比

18.12 ZGC 与 G1 的选择

维度 G1 ZGC
目标 延迟与吞吐折中 更低停顿
停顿目标 毫秒级目标 亚毫秒到毫秒级常见
堆规模 中大堆 大堆、超大堆优势更明显
实现复杂度 Region + Remembered Set 着色指针 + 读屏障
吞吐 通常更好 屏障和并发成本更高

如果业务能接受 50ms 到 200ms 的 GC 停顿,G1 往往更省资源。如果 P999 对几十毫秒停顿都敏感,并且机器内存充足,ZGC 值得测试。

最终选择应基于同流量压测,而不是只看收集器名字。

18.13 一个压测观察示例

启动应用:

java -XX:+UseZGC \
  -Xms12g -Xmx12g \
  -Xlog:gc*:file=gc.log:time,uptime,level,tags \
  -jar app.jar

压测后观察:

QPS
P50 / P99 / P999
CPU 使用率
GC 次数
GC 停顿分布
Allocation Stall 次数
Live 数据

对比实验建议:

实验 目的
G1 默认 基线
ZGC + 12g 验证停顿
ZGC + 16g 验证余量
ZGC + 更高 CPU limit 验证并发能力

只有延迟、吞吐、资源成本同时看,才能得到可上线的结论。

本章小结

ZGC 通过着色指针、读屏障、并发标记、并发搬迁和 Region 化布局,把大部分垃圾回收工作移出全局停顿。它适合大堆和低延迟场景,但会消耗更多内存和 CPU。JDK 21 的分代 ZGC 进一步改善了常见短生命周期对象场景的表现。生产使用时,要重点观察停顿分布、Allocation Stall、Live 数据和容器资源,而不是只看平均 GC 时间。

思考题

  1. 为什么说 ZGC 的停顿时间通常不随堆大小线性增长?
  2. 着色指针和读屏障分别解决什么问题?
  3. Allocation Stall 频繁出现时,你会按什么顺序排查?
  4. 分代 ZGC 为什么能改善常见业务场景?什么场景收益有限?
  5. 如何设计一个 G1 与 ZGC 的公平对比实验?