Administrator
发布于 2025-10-11 / 5797 阅读
62

分代 ZGC 生产环境一年的运行总结

分代 ZGC 在我们的网关上跑了一年

去年十月,我们把 API 网关从 G1 换成了分代 ZGC,到这个月正好一年。这篇是完整的数据总结,包括参数、遇到的四个问题、以及我现在的看法。

先说结论:换成 ZGC 是我们这一年最值的一次技术决策,但它不是银弹,而且代价被大多数人低估了。

背景:为什么要换

网关是全部流量的入口,堆 16 GB,QPS 峰值 4.2 万。用 G1 时的表现:

指标G1 时期
Young GC1.8 次/秒,单次 30~60ms
Mixed GC每 3~5 分钟一次,单次 120~400ms
Full GC平均每周 1.2 次,单次 4~9 秒
P99340ms
P9992.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 相关的调优参数非常少,这是它的设计哲学——几乎不需要调。我们一年下来只动过两个参数(ZAllocationSpikeToleranceZCollectionInterval),后面会讲。

停顿表现:数据说话

先上一年的完整对比(同流量水平,都是 QPS 3.8 万左右):

指标G1分代 ZGC变化
平均 GC 停顿58ms0.31ms-99.5%
最大 GC 停顿9,140ms2.4ms-99.97%
P99 停顿410ms0.8ms
P999 停顿3,100ms1.6ms
Full GC 次数62 次/年0
请求 P99340ms128ms-62%
请求 P9992,800ms390ms-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 GB20.3 GB
凌晨低谷19.4 GB7.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 CPUZGC 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,140ms2.4ms极好
P99 请求延迟340ms128ms
CPU 占用34%41%差,多 20%
内存占用(进程)17.8 GB20.1 GB差,多 13%
机器数2428差,多 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 生产环境一年的运行总结》里同一个坑绊住,回来翻这篇,能省半小时。

参考