这是《JVM 零基础实战指南》的独立章节版。本章从概念、实操和生产排查三个视角展开,代码块保留了原书可直接运行的版本。 ZGC 是 HotSpot 中面向大堆、低延迟场景的垃圾收集器。它的核心承诺是把大部分 GC 工作并发执行,让停顿时间主要取决于根扫描等少量阶段,而不是堆大小、对象数量或整理工作量。
不同 JDK 版本的 ZGC 差异很大。本章以常见 LTS 为主线:JDK 11 引入实验版 ZGC,JDK 15 转正,JDK 17 持续增强,JDK 21 引入分代 ZGC。使用具体版本时,应以该版 JVM 的官方文档和 -XX:+PrintFlagsFinal 输出为准。
18.1 设计目标
ZGC 的目标可以概括为四点:
- 停顿时间不随堆大小线性增长;
- 支持 TB 级别的大堆;
- 吞吐量损失可接受;
- 支持并发整理,避免长时间 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 版本变化。看日志时应重点抓三类信息:
- 每个阶段的耗时;
- GC 触发原因;
- 分配停顿或负载是否过高。
18.6 分代 ZGC
JDK 21 引入分代 ZGC,把对象划分为年轻代和老年代,并假设大多数对象朝生夕死。
分代 ZGC
+----------------------+
| Young Generation |
| 频繁回收,成本低 |
+----------------------+
| Old Generation |
| 较少回收,避免长期对象反复扫描 |
+----------------------+
分代带来的收益:
- 年轻对象死亡快时,减少无效工作;
- 降低分配压力对老年代的影响;
- 改善常见 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
健康形态通常是:
- GC 周期稳定推进;
- 并发阶段耗时长但停顿短;
- Live 数据平稳;
- 没有 or 极少 Allocation Stall;
- 停顿时间分布符合目标。
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 时间。
思考题
- 为什么说 ZGC 的停顿时间通常不随堆大小线性增长?
- 着色指针和读屏障分别解决什么问题?
- Allocation Stall 频繁出现时,你会按什么顺序排查?
- 分代 ZGC 为什么能改善常见业务场景?什么场景收益有限?
- 如何设计一个 G1 与 ZGC 的公平对比实验?