我们一个网关服务,平时 P99 延迟 40ms 很稳,但每天凌晨批量任务后总有几次 200ms+ 的卡顿尖刺。G1 的 Mixed GC 停顿在堆大时是元凶。JDK 21 上 ZGC 已经成熟,我决定赌一把把它换上。
切换决策:先看停顿从哪来
先用 JFR 抓了一周 GC 数据。G1 在 16G 堆下,Young GC 停顿约 30ms,但 Mixed GC(回收老年代)偶发到 180ms,且随存活对象增多而抖动。网关对尾延迟敏感——一个 200ms 的 GC 停顿,会直接拖慢那一波所有请求。ZGC 的承诺是"停顿不随堆大小增长",官方说 < 10ms,这正是我们想要的。
切换过程
JDK 21 上开启 ZGC 只需换 GC 参数,业务代码零改:
-XX:+UseZGC
-XX:MaxHeapSize=16g
-XX:SoftMaxHeapSize=12g # 让 ZGC 在空闲时主动把堆缩回来
-XX:+ZGenerational # 分代 ZGC,JDK 21 默认开启
注意 ZGenerational:JDK 21 的分代 ZGC 比非分代吞吐量高约 15%,我们一开始没开,Young 对象回收效率低,换上分代后吞吐回正。
延迟改善实测
| 指标 | G1 | ZGC | 变化 |
|---|---|---|---|
| GC 最大停顿 | 182ms | 8ms | -96% |
| GC 平均停顿 | 31ms | 1.2ms | -96% |
| 请求 P99 延迟 | 212ms | 52ms | -75% |
| 吞吐(QPS) | 基准 100% | 约 94% | -6% |
尾延迟塌方消失,P99 从 212ms 降到 52ms。代价是吞吐掉 6%,CPU 占用涨约 10%(ZGC 并发标记要算力)。对网关而言,尾延迟比吞吐重要,这 6% 换来的是体感的质的提升。
踩坑记录
- 堆不缩:没设
SoftMaxHeapSize时,ZGC 把堆顶到 16G 不下来,K8s limit 边缘游走,差点 OOMKilled。设了软上限后稳定在 12-13G; - 分配速率:ZGC 对"分配风暴"敏感,某次营销活动瞬间新建大量短命对象,Allocation Stall 让个别请求卡了 50ms。加
-XX:ConcGCThreads提并发标记线程数缓解; - 监控盲区:原 Prometheus 的 GC 面板是按 G1 指标做的,切 ZGC 后一堆指标为空。改用 ZGC 专属的
jdk.ZGarbageCollectionJFR 事件导出,重新配了看板。
哪些场景不适合 ZGC
不是所有服务都该换。我们评估的标准:
- 堆 < 4G、停顿本来就不大:G1 够用,换 ZGC 收益小;
- CPU 极度紧张:ZGC 吃并发算力,CPU 余量不足会反噬;
- 批处理离线任务:吞吐优先,G1 或 Parallel 更合适。
监控指标与告警配置
切 ZGC 后监控得重配。我们重点盯三个指标:ZCollectionCount(GC 次数)、Allocation Stall 次数、以及堆的 SoftMax 实际使用。告警这样设:单次 GC 停顿 > 10ms 且持续 5 分钟告警(理论上不该发生,触发即异常),Allocation Stall 计数 > 0 直接电话告警(说明分配速率压垮并发回收)。还配了"堆实际占用持续贴近 limit 的 90%"的预警,提前发现内存泄漏或 SoftMax 失效。JFR 我们开了连续飞行记录(-XX:StartFlightRecording),保留最近 12 小时,出事能回放 GC 全过程,比看聚合指标管用得多。
小结
从 G1 切到 ZGC,核心收益是"停顿与堆大小解耦",把网关尾延迟尖刺从 182ms 压到 8ms,P99 降 75%。代价是 6% 吞吐和略高 CPU,对延迟敏感服务很划算。踩坑集中在堆软上限、分配风暴和监控适配。决策口诀:延迟敏感、堆大、CPU 有余,上 ZGC;吞吐优先、堆小、CPU 紧,留 G1。别盲目追新,用 JFR 数据说话。