分代 ZGC 在我们的网关上跑了一年
去年十月,我们把 API 网关从 G1 换成了分代 ZGC,到这个月正好一年。这篇是完整的数据总结,包括参数、遇到的四个问题、以及我现在的看法。
先说结论:换成 ZGC 是我们这一年最值的一次技术决策,但它不是银弹,而且代价被大多数人低估了。
背景:为什么要换
网关是全部流量的入口,堆 16 GB,QPS 峰值 4.2 万。用 G1 时的表现:
| 指标 | G1 时期 |
|---|---|
| Young GC | 1.8 次/秒,单次 30~60ms |
| Mixed GC | 每 3~5 分钟一次,单次 120~400ms |
| Full GC | 平均每周 1.2 次,单次 4~9 秒 |
| P99 | 340ms |
| P999 | 2.8s |
| 因 GC 导致的超时告警 | 月均 23 次 |
真正的痛点不是平均值,是那每周一两次的 Full GC。4 到 9 秒的停顿意味着上游所有超时重试同时打过来,恢复后还要消化堆积的请求,一次 Full GC 的影响能持续一两分钟。每月 23 次告警,值班同学苦不堪言。
我们评估过三个方案:优化代码降低分配速率(做了,降了 30%,但 Full GC 没消失)、缩小堆(内存不够用)、换低延迟收集器。最后选了 ZGC。
配置和版本
我们跑在 JDK 21 上。这里要说清楚版本差异:JDK 21 的分代 ZGC 是 JEP 439 引入的,需要显式开 -XX:+ZGenerational。到 JDK 23(JEP 474)分代模式才成为默认。我们 21 上必须显式指定。
-Xms16g -Xmx16g # ZGC 下强烈建议固定堆
-XX:+UseZGC
-XX:+ZGenerational # JDK 21/22 必需;JDK 23+ 默认开启
-Xlog:gc*:file=/var/log/gc.log:time,uptime,level,tags:filecount=14,filesize=128m
-XX:+HeapDumpOnOutOfMemoryError
-XX:NativeMemoryTracking=summary # 重要,见下文内存开销部分
-Xsoftmx16g # 可选,允许 ZGC 归还内存给 OS
注意 ZGC 相关的调优参数非常少,这是它的设计哲学——几乎不需要调。我们一年下来只动过两个参数(ZAllocationSpikeTolerance 和 ZCollectionInterval),后面会讲。
停顿表现:数据说话
先上一年的完整对比(同流量水平,都是 QPS 3.8 万左右):
| 指标 | G1 | 分代 ZGC | 变化 |
|---|---|---|---|
| 平均 GC 停顿 | 58ms | 0.31ms | -99.5% |
| 最大 GC 停顿 | 9,140ms | 2.4ms | -99.97% |
| P99 停顿 | 410ms | 0.8ms | — |
| P999 停顿 | 3,100ms | 1.6ms | — |
| Full GC 次数 | 62 次/年 | 0 | — |
| 请求 P99 | 340ms | 128ms | -62% |
| 请求 P999 | 2,800ms | 390ms | -86% |
| GC 相关告警 | 276 次/年 | 4 次/年 | -98.6% |
最大停顿 2.4ms 出现在三月份一次流量突增时(QPS 从 3 万冲到 5.6 万),持续时间很短。平峰期的停顿 99% 都在 0.5ms 以下。
P999 从 2.8s 降到 390ms 是最有业务价值的一项。剩下的 390ms 已经和 GC 无关,是下游服务慢和网路抖动。
剩下那 4 次告警,都不是 ZGC 本身的问题,是后面会讲的两个坑。
内存开销:这是最被低估的部分
ZGC 用染色指针和读屏障,代价是额外的内存。这一点宣传材料里很少提,但生产上必须算清楚。
我们用 NMT 抓了一年的数据:
jcmd <pid> VM.native_memory summary scale=MB
Native Memory Tracking:
Total: reserved=24891MB, committed=20134MB
- Java Heap (reserved=16384MB, committed=16384MB)
- Class (reserved=1241MB, committed=286MB)
- Thread (reserved=1103MB, committed=142MB)
- Code (reserved=264MB, committed=91MB)
- GC (reserved=3277MB, committed=3277MB) <- ZGC 自己的开销
- Compiler (reserved=18MB, committed=18MB)
- Other (reserved=2604MB, committed=1936MB) <- 主要是 ZGC 的转发表、标记栈等
堆 16 GB,但整个进程 committed 了 20.1 GB。GC 相关开销约 20%(3.3 GB 显式 + 部分 Other)。这里面最稳定的一项是 ZGC 的转发表(forwarding table),它和堆大小成正比,大约占堆的 3~5%。
对比 G1 时期同样 16 GB 堆,进程 committed 是 17.8 GB。所以 ZGC 多占了约 2.3 GB,实际开销是 14% 左右。
这个代价我们接受了,但如果你在容器里跑,一定要把 memory limit 设成 堆 × 1.25 以上。我们第一版设的 limit 是 18 GB,运行三天被 OOMKilled 了一次:
2024-10-19 03:41:22 Container killed by OOMKiller
memory.usage_in_bytes: 19327352832 (18.0 GiB limit)
RSS: 18.9 GB, Java Heap committed: 16.0 GB
这是第一个坑。后来 limit 提到 22 GB(16 × 1.375),留足余量。
坑二:分配速率突增时的 Allocation Stall
ZGC 不是没有失败模式的。当分配速率超过回收速率时,会发生 Allocation Stall——应用线程被阻塞,等待 GC 腾出空间。
三月份那次流量突增(QPS 5.6 万,是平峰的 1.5 倍),我们抓到了:
[2025-03-11T20:14:33.118+0800] GC(8921) Pause Mark Start 0.412ms
[2025-03-11T20:14:33.891+0800] Allocation Stall (main) 772.441ms
[2025-03-11T20:14:33.891+0800] Allocation Stall (http-nio-8080-exec-142) 771.902ms
[2025-03-11T20:14:34.204+0800] GC(8921) Pause Relocate Start 0.298ms
一次性 40 多个线程 stall 了 772ms。这个值比 G1 的 Full GC 小得多,但也不是可以无视的。
根源是 ZGC 的并发回收跟不上突发的分配。解决办法是调 ZAllocationSpikeTolerance——它控制 ZGC 对分配峰值的预判余量,默认 2.0,意思是按历史分配速率的 2 倍来预留:
-XX:ZAllocationSpikeTolerance=4.0 # 默认 2.0
调大之后 ZGC 会更早启动 GC 周期,代价是 GC 更频繁、CPU 占用略高。我们调到 4.0 之后,同样的流量突增场景下 Allocation Stall 从 772ms 降到 43ms,CPU 从 38% 涨到 41%。
这个参数不要一上来就调大。我们建议的方式是:先看 GC 日志里有没有 Allocation Stall,没有就别动。很多服务一辈子都不会遇到。
坑三:内存归还太慢,被运维投诉
ZGC 默认会把不用的堆内存归还给操作系统,但归还的节奏很保守(默认的 ZUncommitDelay 是 300 秒)。我们的网关有明显的潮汐特征——凌晨 QPS 只有白天的 1/8,堆使用率从 78% 掉到 12%,但进程 RSS 几乎不降。
运维同学在成本会上点名了这个服务:「16 GB 内存,凌晨只用 2 GB,为什么不能缩?」
我们加了两个参数:
-XX:ZCollectionInterval=30 # 强制 GC 周期上限 30 秒(默认不限制)
-XX:ZUncommitDelay=60 # 空闲 60 秒后归还内存(默认 300 秒)
效果:
| 时段 | 调整前 RSS | 调整后 RSS |
|---|---|---|
| 白天高峰 | 20.1 GB | 20.3 GB |
| 凌晨低谷 | 19.4 GB | 7.2 GB |
因为我们在 K8s 上,这个改进让 HPA 的内存指标更准确,整体资源配额降了 18%。
代价是低谷期 GC 更频繁(每 30 秒一次),CPU 多消耗约 0.8%。可以接受。
这里还有个更优雅的方案是 -XX:ZUncommit 配合 -Xsoftmx,但我们没用——Xsoftmx 让堆可以软性伸缩,和固定堆的实践有冲突,我担心引入新的不确定性。
坑四:大对象分配路径
这是最隐蔽的一个。ZGC 对小对象(< 256 KB)、中对象(< 4 MB)、大对象(> 4 MB)有不同的分配路径,大对象分配走的是专门的 page,分配成本远高于小对象。
我们网关有个功能是请求体聚合上报,会把一批请求(最多 512 个)拼成一个大 JSON 再发 Kafka。单个 JSON 平均 2.3 MB,偶尔超过 4 MB。表现是每到整分钟(聚合窗口边界)就有一波延迟尖刺。
用 JFR 抓的分配热点里,ZGC large page allocation 占了大头。我们做的优化很朴素——把聚合上限从 512 降到 128,单个 JSON 控制在 600 KB 以内,落在中对象区间:
// 改前:512 条一批,单批 2.3 MB,部分超过 4 MB 大对象阈值
private static final int BATCH_SIZE = 512;
// 改后:128 条一批,单批约 580 KB,避开大对象分配路径
private static final int BATCH_SIZE = 128;
整分钟的延迟尖刺从 180ms 降到 34ms。发送频率提高了 4 倍但 Kafka 完全没压力。
这个坑的教训是:ZGC 下要特别关注单次分配的大小,不只是总量。我们的做法是给 JFR 加了大对象分配的专门监控,超过 1 MB 的分配会打点。
CPU 开销
ZGC 用读屏障,每次对象读取都有额外指令。这个开销是多少,我们测了:
| 场景 | G1 CPU | ZGC CPU | 增幅 |
|---|---|---|---|
| 平峰(QPS 3.8 万) | 34% | 41% | +7pp |
| 高峰(QPS 5.6 万) | 62% | 73% | +11pp |
| 凌晨(QPS 5000) | 9% | 11% | +2pp |
平均下来多消耗 7~11 个百分点的 CPU,也就是约 20~25% 的相对增幅。这个数字比官方宣称的「不超过 15%」要高,我猜和我们的负载特征有关(网关对象读取非常频繁)。
换算成机器成本:网关 24 台机器,CPU 水位上升后扩到 28 台。多 4 台机器的成本,换来了每月 23 次告警降到 0.3 次,我们认为是划算的。
但这笔账要算清楚。如果你的服务对延迟不敏感(比如批处理任务),用 ZGC 纯属浪费 CPU。ZGC 适合的是「延迟敏感 + 内存大」的场景,小堆(< 4 GB)的服务用 G1 甚至 Parallel GC 更合适。
一年的完整账单
| 项目 | G1 | 分代 ZGC | 评价 |
|---|---|---|---|
| 最大停顿 | 9,140ms | 2.4ms | 极好 |
| P99 请求延迟 | 340ms | 128ms | 好 |
| CPU 占用 | 34% | 41% | 差,多 20% |
| 内存占用(进程) | 17.8 GB | 20.1 GB | 差,多 13% |
| 机器数 | 24 | 28 | 差,多 4 台 |
| GC 调优投入 | 持续 | 近乎为零 | 极好 |
| GC 相关故障 | 62 次/年 | 4 次/年 | 极好 |
关于升级到 JDK 25
JDK 25 上个月发布,是新一任 LTS。我在测试环境跑了一周,关注两个点:
一是紧凑对象头(JEP 519),对象头从 96 位压到 64 位。网关的对象特征是大量短生命周期的小对象(HTTP header map、路由匹配结果),这个特性理论上收益明显。实测堆占用降了 12%,GC 频率降了 9%。
二是分代 ZGC 的持续打磨。JDK 23 之后分代成为默认,ZGenerational 参数已经不需要显式指定了(我们的配置里还留着,JDK 21 上必须留)。JDK 25 里 ZGC 的 NUMA 感知和屏障优化都有改进,我们测下来 CPU 开销比 JDK 21 低了约 3 个百分点。
计划是下个月先在非核心服务上灰度 JDK 25,网关这个服务等到 25.0.2 之后再说。LTS 版本我们团队的规矩是等第二个补丁版。
就写到这。如果哪天你也被《分代 ZGC 生产环境一年的运行总结》里同一个坑绊住,回来翻这篇,能省半小时。