在金融交易、实时推荐、在线游戏这类对延迟极其敏感的系统里,一次几百毫秒的 GC 停顿就足以触发超时告警、掉单甚至雪崩。ZGC(Z Garbage Collector)是 Oracle 从 JDK 11 引入、JDK 15 转为生产可用的低延迟垃圾回收器,它的设计目标非常激进:无论堆是 8GB 还是 8TB,停顿时间都不超过 10 毫秒。本文从原理讲到生产调优,带你把 Java 应用的 GC 停顿真正压平。
一、为什么需要 ZGC:停顿即 SLA
传统分代回收器的痛点是”停止一切”(Stop-The-World,STW)。以 G1 为例,当堆超过十几 GB,年轻代回收、混合回收、以及最棘手的”大对象 / 并发标记收尾”阶段都会产生几十到几百毫秒的停顿。对 P99 延迟要求 50ms 以内的服务,一次 GC 长停顿就足以击穿 SLA。
更麻烦的是,G1 的停顿时间会随着堆大小增长而放大——对象越多,需要扫描和搬运的成本越高。这意味着业务想靠”加内存”扛量,反而会换来更长的 GC 停顿,陷入两难。《JVM 内存模型与 GC 调优》里我们拆解过堆结构与回收参数,而 ZGC 正是为打破这种正相关而生:它把几乎所有重活都搬到与应用线程并发执行。
二、ZGC 核心原理:着色指针与读屏障
2.1 着色指针(Colored Pointers)
ZGC 最巧妙的设计是把对象的元数据直接编码进指针本身,而不是放在对象头里。在 64 位地址空间中,ZGC 借用了指针的高位几位(第 42~45 位附近)作为”标记位”,分别表示:该对象是否已被标记、是否已被重定位到新地址、是否可最终回收等。实际对象偏移只占其中 42 位,因此单堆最大支持 4TB。
由于这些”颜色位”并不参与真实内存寻址,ZGC 通过多重映射(multi-mapping)让同一段物理内存在不同虚拟地址下呈现不同颜色,应用线程无需感知。这样做的好处是:回收器在搬运对象时,不需要立刻修正所有指向它的旧引用。
2.2 读屏障(Load Barrier)
光有颜色位还不够,还需要”自愈”机制。当应用线程从堆里读取一个对象引用时,ZGC 插入的读屏障(Load Barrier)会顺手检查:这个指针指向的对象是不是已经被搬到了新地址?如果是,读屏障当场把指针”修正”为指向新地址,这个过程对业务代码完全透明。正是读屏障让”对象搬走”和”引用修正”可以并发进行,从而彻底消除长时间的 STW 重定位。
2.3 全并发的标记—重定位
ZGC 的标记、重定位(Relocation)、重映射(Remap)三个阶段全部与业务线程并发执行,仅保留极短暂的 STW 用于扫描线程栈的”根集”。根集通常很小,所以这几段 STW 往往只有亚毫秒级。对比传统回收器需要在 STW 里搬运整个存活集合,这是量级上的差别。
三、版本演进与 JDK 支持
| JDK 版本 | ZGC 状态 | 关键变化 |
| JDK 11 | 实验特性 | 需 -XX:+UnlockExperimentalVMOptions -XX:+UseZGC |
| JDK 15 | 生产可用 | 去掉实验开关,正式可用于生产 |
| JDK 21 | 分代 ZGC | -XX:+ZGenerational 引入年轻代 / 老年代 |
| JDK 23 | 分代成默认 | Generational ZGC 成为 ZGC 默认模式 |
结论很明确:生产环境至少用 JDK 17 以上,推荐 JDK 21+ 直接开分代 ZGC。如果你还在 JDK 8,建议先把升级纳入计划,参考 《Spring Boot 3 升级踩坑实录》 的迁移经验。
四、生产环境启用 ZGC
4.1 基础启动参数(JDK 15+)
# JDK 15+ 生产可用(无需解锁实验特性)
java -XX:+UseZGC \
-Xmx16g -Xms16g \
-jar app.jar
4.2 分代 ZGC(JDK 21+ 推荐)
# 分代模式:年轻代高频小回收 + 老年代低频大回收,吞吐更优
java -XX:+UseZGC -XX:+ZGenerational \
-Xmx16g -Xms16g \
-jar app.jar
在容器环境里,务必让 -Xmx 与容器内存上限对齐,并预留足够空间给堆外内存(线程栈、元空间、Direct Buffer)。与 《Java 虚拟线程》 搭配时更要注意:虚拟线程数量大、栈占用小,但总内存仍受 -Xss 与线程数影响,建议先用压测确定稳定水位再固定 -Xms。
五、关键调优参数
| 参数 | 默认值 | 作用 |
| -XX:SoftMaxHeapSize | 同 -Xmx | 让 GC 在空闲时主动把堆缩到该值之下,省内存 |
| -XX:ZUncommitDelay | 300s | 闲置内存多久后归还操作系统 |
| -XX:ZCollectionInterval | 0 | 两次 GC 最小间隔秒数,0 表示自适应 |
| -XX:ZAllocationSpikeTolerance | 2.0 | 应对突发分配的压力系数,越大越早启动 GC |
| -XX:ZProactive | true | 主动 GC:堆还有余量时提前回收,防抖动 |
一个典型的生产 JVM 参数组合:
java -XX:+UseZGC -XX:+ZGenerational \
-Xmx16g -Xms16g \
-XX:SoftMaxHeapSize=12g \
-XX:ZUncommitDelay=300 \
-Xlog:gc*:file=/var/log/gc-%t.log:time,tid,tags:filecount=5,filesize=100M \
-jar app.jar
六、监控与验证
ZGC 的停顿指标要从 GC 日志里取,关键是 Pause Marks Final 与 Pause Relocate Start 这两段 STW 的耗时。一个健康的 ZGC 日志长这样:
[gc,marking] GC(18) Pause Marks Final 0.482ms
[gc,reloc] GC(18) Pause Relocate Start 0.351ms
[gc] GC(18) Garbage Collection (Concurrent Cycle) 8192M->2048M(16384M)
[gc,load] GC(18) Load: 2.1/Allocation Stall: 0
判断切换是否成功,看两件事:停顿直方图是否整体落到 10ms 内,以及 Allocation Stall 是否为 0(出现则说明回收跟不上分配)。若已接入监控,可把 GC 停顿指标接入 《Prometheus + Grafana 监控告警》,对 Pause 最大值设 10ms 告警线。更细的 GC 日志解读可参考 《JVM 调优实战:从 GC 日志到内存与吞吐优化》。
七、ZGC vs G1 vs Shenandoah
| 维度 | G1 | ZGC | Shenandoah |
| 最大堆 | 数十 GB 仍可控 | TB 级 | TB 级 |
| 典型停顿 | 几十~几百 ms | <10ms | <10ms |
| 并发阶段 | 部分并发 | 全并发 | 全并发 |
| JDK 版本 | 成熟默认 | 15+ 生产 | 12+ 生产 |
| 内存开销 | 低 | 中(着色指针 / 屏障) | 中(转发指针) |
八、常见误区与排障
- 误区一:ZGC 永不出现长停顿。 当日志出现 Allocation Stall,说明回收速度跟不上分配速度,应增大 -Xmx、调高 ZAllocationSpikeTolerance,或优化代码里的短命大对象。
- 误区二:堆越大越好。 ZGC 虽支持大堆,但物理内存不足照样 OOM;建议用 SoftMaxHeapSize 让空闲时回落,配合 ZUncommitDelay 把闲置内存还给系统。
- 误区三:分代一定更快。 多数在线服务分代 ZGC 吞吐更优,但超大对象、高吞吐批处理可实测对比分代开关再定。
- 排障入口: 出现意外长停顿先查 GC 日志的 Allocation Stall 计数,再看是否容器内存 limit 被压满;必要时参考 《Full GC 排查实战:MAT 定位 Java 内存泄漏》 用堆转储确认是不是真泄漏。
九、落地建议
把 ZGC 引入生产,建议分步推进:① 在灰度 / 预发环境用相同流量跑 24 小时,对比切换前后的停顿直方图;② 确认 Allocation Stall 为 0、P99 延迟下降;③ 用 -Xlog 落盘 GC 日志并接入监控;④ 全量前再用压测探明稳定内存水位,固定 -Xms 避免堆反复伸缩。配合容器化部署(《Docker 实战》)与 CI(《GitHub Actions 实战》)可以把这套参数固化进镜像与流水线,做到一键可复现。
结语
ZGC 把”GC 停顿”从架构约束变成了可忽略的背景噪声。它不是银弹——吞吐场景仍要权衡内存与 CPU 开销,但只要你跑在 JDK 17+,尤其是存在低延迟诉求的服务,ZGC 几乎都值得作为默认回收器尝试。先量停顿,再谈优化,用数据而不是直觉决定要不要切。




