ZGC 与 G1 深度对比——什么业务应该换 ZGC,什么情况下 G1 更好
ZGC 与 G1 深度对比——什么业务应该换 ZGC,什么情况下 G1 更好
适读人群:负责 Java 服务性能调优的开发者或架构师 | 阅读时长:约 15 分钟 | 核心价值:给出 ZGC 和 G1 选型的清晰决策框架,不是简单说"ZGC 更新所以更好"
ZGC 在 Java 15 正式转为 production-ready,然后我们组里就开始讨论要不要迁移。
有同事说:ZGC 停顿时间在 1ms 以内,当然要换。
我当时没有马上表态。我的判断是:GC 选型不是选最新的,是选最合适的。于是花了一周做了一次系统的对比测试。
这篇文章把我的结论和推导过程写下来。
先说结论,避免读完之后还是"不知道该怎么选"
我的立场:
- 对延迟极其敏感(P99 < 50ms)、堆内存 > 8GB:换 ZGC,收益显著
- 追求高吞吐量、对延迟不敏感:G1 更好
- 堆内存 < 4GB:没必要换 ZGC,G1 足够
- 老项目有大量调优过的 G1 参数:先不换,稳定第一
G1 的工作原理简要回顾
G1 把堆分成大小相等的 Region(默认 1-32MB),分为 Eden、Survivor、Old、Humongous 几种类型。
G1 的 GC 过程有 Stop-The-World 阶段:
- Young GC(每次几十毫秒)
- Mixed GC(清理 Young + 部分 Old,几十到几百毫秒)
- Full GC(全堆清理,可能几秒,G1 尽量避免但无法完全消除)
G1 的目标是在满足吞吐量的前提下,控制停顿时间在 MaxGCPauseMillis 以内(默认 200ms)。
G1 的局限: 随着堆增大,停顿时间也会增加。16GB 的堆,一次 Young GC 停顿 100-200ms 是常见的。
ZGC 的核心差异:几乎全并发
ZGC(Z Garbage Collector)的核心设计目标是极低停顿,它把几乎所有 GC 工作都放到并发阶段(和应用线程同时运行),Stop-The-World 阶段只做很少量的工作(标记根对象等)。
G1 GC 过程:
应用运行 → STW停顿(标记+复制) → 应用运行 → STW停顿 → ...
↑
50ms - 500ms 的停顿
ZGC 过程:
应用运行 → STW(初始标记, 约1ms) → 并发标记 → STW(再标记, 约1ms) → 并发迁移 → ...
↑ ↑
~1ms停顿 ~1ms停顿ZGC 实现这个的关键技术是着色指针(Colored Pointers)和读屏障(Load Barrier):
- 对象引用的指针里用几个位存储 GC 状态信息
- 每次读取对象引用时,JVM 检查这几个位,必要时做转发(Forwarding)
这带来了一个代价:每次读对象引用都有轻微的额外开销(读屏障)。
我做的对比测试
测试环境:Java 17,4 核 8GB 的服务,压测工具是 wrk,被测接口是一个典型的 CRUD 接口(数据库查询 + 业务逻辑 + Redis 写入)。
测试配置:
# G1 配置
-XX:+UseG1GC
-Xms4g -Xmx4g
-XX:MaxGCPauseMillis=200
-XX:G1HeapOccupancyPercent=45
# ZGC 配置
-XX:+UseZGC
-Xms4g -Xmx4g
-XX:SoftMaxHeapSize=3g # ZGC 特有参数,软最大堆大小测试结果(压测 10 分钟,并发 500):
| 指标 | G1 | ZGC |
|---|---|---|
| 吞吐量(req/s) | 12,847 | 11,934 |
| P50 延迟 | 38ms | 41ms |
| P99 延迟 | 187ms | 28ms |
| P999 延迟 | 423ms | 35ms |
| 最大停顿 | 358ms | 4ms |
| CPU 使用率 | 71% | 78% |
结果很清晰:
- 吞吐量:G1 高约 7%,ZGC 的读屏障有轻微开销
- P99/P999:ZGC 大幅领先,因为 G1 的大停顿直接拉高了尾延迟
- CPU:ZGC 多用了约 7% CPU(GC 并发线程)
踩坑实录一:ZGC 堆内存用量比 G1 大
上线 ZGC 之后,有个同事反映内存占用比以前多了约 20%。
原因:ZGC 的并发 GC 意味着在 GC 期间,应用可以继续分配内存,GC 要追上这个"allocating and collecting at the same time"的节奏,需要保留更多的缓冲区。
这是 ZGC 的固有特性,堆内存建议预留 20-30% 的额外空间。 如果你的机器内存已经很紧张,ZGC 可能不合适。
配置建议:
# ZGC 推荐:设置 SoftMaxHeapSize,让 ZGC 尽量在这个范围内工作
# 但保留 Xmx 作为上限
-Xms6g -Xmx8g
-XX:SoftMaxHeapSize=6g # ZGC 尽量控制在 6GB 以内,
# 只有确实需要才使用到 8GB踩坑实录二:ZGC 的 GC 线程配置
ZGC 的并发 GC 线程数默认是 CPU 核数的 25%(至少 1 个,最多 8 个)。在 4 核机器上,默认只有 1 个 GC 线程,可能跟不上高分配率。
GC 日志里看到这个就说明 GC 跟不上了:
[gc] GC(12) Allocation Stall: 15msAllocation Stall 是 ZGC 在内存快用完、GC 跟不上时,会暂时暂停分配,等 GC 释放一些内存。这实际上也是一种停顿,虽然比 Full GC 短得多。
解法:增加 GC 线程数:
-XX:ConcGCThreads=4 # 根据 CPU 核数调整踩坑实录三:ZGC 对大对象的处理和 G1 不同
G1 的 Humongous 对象问题(上篇提到的)在 ZGC 里不存在——ZGC 没有 Region 大小限制,大对象不会走特殊路径。
但 ZGC 的大对象回收有另一个问题:大对象迁移成本高。ZGC 在迁移对象时,需要通过读屏障进行指针修正,对象越大,涉及的指针越多,开销越大。
如果你的应用大量创建超过 1MB 的大对象,ZGC 的 GC 线程负载会较高,可能出现 Allocation Stall。
什么业务场景选 ZGC
明确选 ZGC 的场景:
交易系统、实时推荐、游戏服务器——对 P99 延迟极其敏感,任何一次 200ms+ 的停顿都会被用户感知到
大堆服务(16GB 以上)——G1 在大堆下停顿时间随堆增大而增长,ZGC 不受这个限制
Java 15+ 新项目——没有历史包袱,直接用 ZGC
继续用 G1 的场景:
批处理、数据管道——更在意吞吐量,停顿几百毫秒可以接受
小堆服务(4GB 以下)——G1 在小堆下已经足够好,迁移收益有限
Java 11 及以下——ZGC 在 Java 11 有一些限制(不支持类卸载等),不建议在这些版本用
现有系统 G1 已经调优过——稳定性优先,迁移有风险
如何验证迁移效果
如果决定从 G1 迁移到 ZGC,验证步骤:
# 第一步:在测试/预发布环境切换 ZGC,对比相同流量下的延迟分布
# 重点关注 P99、P999、最大停顿时间
# 第二步:监控这些指标
# - Allocation Stall 次数和时间
# - GC 触发频率
# - 内存使用量
# 查看 ZGC 特有的统计
jstat -gc <pid> 1000 # 每秒打印一次 GC 统计GC 选型没有银弹,但延迟敏感型业务用 ZGC 的收益通常很明显,值得投入评估的时间。
深入对比:ZGC 的内部机制为什么能做到低停顿
很多人知道 ZGC 停顿时间短,但不知道它是怎么做到的。理解这个对你调优有帮助。
着色指针(Colored Pointers)
ZGC 在对象引用的指针里,用了几个位来存储 GC 相关的元数据:
64位地址空间中:
bits[0..41] : 实际内存地址(42位,支持 4TB 寻址)
bits[42] : Finalizable 标记
bits[43] : Remapped 标记(对象已迁移完成)
bits[44] : Marked1 标记
bits[45] : Marked0 标记这 4 个标记位用于 GC 状态追踪。代价是每次读取引用时,JVM 需要检查这些位(这就是"读屏障"的开销)。
Load Barrier(读屏障)的工作方式
每次应用线程读取一个对象引用时,JVM 会插入一小段检查代码(读屏障),大概做这几件事:
// 伪代码:读屏障做的事情
Object ref = loadFromMemory(address);
if (ref.bits.isMarked() || ref.bits.needsRelocation()) {
// 对象还在 GC 过程中,做修正
ref = gcRuntime.fixReference(ref);
}
return ref;这段检查在编译后非常轻量(几纳秒),但代价是每次读对象都有一点额外开销,这就是 ZGC 吞吐量略低于 G1 的原因。
并发迁移(Concurrent Relocation)
G1 的对象迁移是 STW 的:应用线程暂停,GC 线程把对象从一个 Region 搬到另一个 Region,更新所有指向旧地址的引用。
ZGC 的迁移是并发的:GC 线程迁移对象时,应用线程不停止。应用线程读到旧地址的引用时,读屏障发现对象已经迁移,自动找到新地址。这样就把"迁移"这个耗时操作也变成了并发操作,停顿时间极短。
这个机制非常精妙,代价是复杂度。ZGC 的代码量比 G1 多很多,而且调试更困难。
ZGC 的几个实际配置建议
我在生产上用 ZGC 调优过的几个参数:
# ZGC 生产配置参考(Java 17,8核16GB 服务器)
java \
-XX:+UseZGC \
-Xms8g -Xmx12g \
-XX:SoftMaxHeapSize=8g \ # ZGC 尽量控制在 8GB,但最多用到 12GB
-XX:ConcGCThreads=4 \ # 并发 GC 线程数,CPU 核数的 50%
-XX:ZAllocationSpikeTolerance=2.0 \ # 分配速率突增的容忍度,默认 2.0
-Xlog:gc*:file=/var/log/zgc.log:time,uptime:filecount=5,filesize=20m \
-jar app.jarSoftMaxHeapSize:这是 ZGC 特有的参数。ZGC 会尽量把堆控制在这个大小,只有在必要时才使用到 Xmx。适合内存资源有限但又不想在正常情况下用满的场景。
ZAllocationSpikeTolerance:这个参数控制 ZGC 对分配速率突增的响应灵敏度。值越大,ZGC 越早开始新一轮 GC 来应对突增(更保守,内存利用率低但更安全);值越小,ZGC 越晚开始 GC(内存利用率高但有 Allocation Stall 风险)。
一个实际的迁移决策案例
我帮一个团队评估过是否从 G1 换 ZGC,他们的情况是:
- 电商交易链路,接口 P99 目标是 100ms
- 堆大小 6GB,GC 日志显示 Young GC 平均 80ms,偶尔有 Full GC 约 1.2s
- Java 11 环境(后来升级到 Java 17 才换的)
评估结论:
- Young GC 80ms 在高峰期确实会影响 P99(P99 里有 30% 是被 GC 拉高的)
- 先升级到 Java 17,同时换 ZGC,一步到位
- 预留额外 2GB 内存(从 6GB 升到 8GB)给 ZGC 的并发缓冲
迁移后结果:P99 从约 120ms 降到约 35ms。这次迁移是值得的。
