基于ZGC的低延迟调优实践笔记

阿乐
阿乐 管理员 黑卡会员
发布于 2026-09-06 22:04 ·13 浏览 ·0 回复

最近在做网关服务时遇到一批毛刺,Young GC 的 P99 延迟从几十毫秒飙升到了两三百毫秒,排查了半天最终把目光锁在了 ZGC 上。通过这一轮调优实践,我对「低延迟」这件事有了更深的体会,赶紧记录下来分享给大家。

为什么选择 ZGC

先交代一下背景:我们的服务主要是接收上游请求,再转发给下游系统,本质是 IO 密集型的中间链路。堆内存大概 8G,但每次 Young GC 过后总会有一些老年代对象需要处理,而随着流量波动,GC 停顿时间变得不稳定。

JDK 11 之后,ZGC 是最值得考虑的选项之一——它的核心目标就是控制停顿时间在 10ms 以内(现在普遍可以在 1ms 级别),同时支持到 TB 级堆。相比 G1,ZGC 做了一些全新设计,像染色指针、读屏障、多重映射,还有最关键的是把「标记-复制」阶段的耗时打散,避免了一次性全堆扫描带来的长停顿。

简单说,如果你的服务对延迟敏感、堆内存又比较大,完全可以考虑 ZGC;如果堆本来就很小,G1 的停顿也是可控的,反而 ZGC 的染色指针和屏障会有额外的 CPU 开销。

调优第一步:大胆启用,观察现象

我们直接在新版本上启用了 ZGC:

-XX:+UseZGC -Xms8g -Xmx8g -XX:ZCollectionInterval=120

这里建议堆固定,因为 ZGC 对动态扩容的响应不如固定内存那样稳定。`ZCollectionInterval` 可以根据实际情况调整,表示强制 GC 的最大间隔,我们设成 120 秒主要是出于安全兜底。

启动之后观察了几天的 GC 日志,最大的变化是:`GC pause` 基本都稳定在 2-3ms,偶尔蹦到 5ms 以上,但整体远比之前 G1 的几十毫秒好得多。之前那种每隔一段时间就出现的「锯齿状」延迟曲线,一下子平滑了很多。

关键参数:并发线程和堆比例

在调优过程中,我踩了几个小坑,和大家说说:

1. 并发 GC 线程数

ZGC 在标记和转移阶段都是并发执行的,默认的并发线程数取决于 CPU 核心数——`ParallelGCThreads` 大概是核心数的 60%,`ConcGCThreads` 又占其中一半左右。如果机器核数较多但 JVM 只是服务之一,过高的并发线程反而会引起 CPU 争抢。

我们通过以下参数限制了并发线程:

-XX:ConcGCThreads=4

这是在 24 核的容器上验证的结果,留出足够资源给业务线程,GC 阶段本身的停顿依然保持在个位数毫秒。在我司这类典型场景下,并发线程数不需要贪多,否则只会加剧任务切换的抖动。

2. ZCollectionInterval 和 AllocateSpike

如果你对延迟极其敏感,可以把 `ZCollectionInterval` 设短一些,比如 30-60 秒,主动引入 GC,避免流量高峰时因为内存分配压力突然触发 GC。这看起来反直觉——主动 GC 不也会停顿吗?但实际上 ZGC 的「停顿」更多是安全点同步和少数阶段的小停顿,主动触发反而能让回收节奏更均匀、行为更可预期。

另外别忘了 `-XX:AllocateSpikeTolerance=2.0` 这类分配抖动相关参数,默认值在应对突发分配时会有延迟,调高它可以让 ZGC 更早预测到分配潮涌并启动回收,避免实际发生堆耗尽时的同步模式。

3. 透明大页

这个其实是经典的坑。在 Linux 上 THP(透明大页)如果没有关闭,ZGC 这类大量使用指针压缩和内存映射的 GC 在缺页时会遇到超预期的暂停。我们用这样的方式规避:

echo never > /sys/kernel/mm/transparent_hugepage/enabled

关闭后配合 ZGC 自身的多映射处理,毛刺的消失非常显著。这一点在现网环境很容易被忽略,值得重点检查。

看 GC 日志的姿势

ZGC 的日志里其实藏了大量线索。我们建议打开以下参数:

-Xlog:gc*:file=/data/logs/zgc.log:time,uptime,level,tags:filecount=10,filesize=50m

重点关注 `GC(0) Pause Mark Start`、`Pause Mark End` 和 `Pause Relocate Start` 这几个阶段的耗时。正常情况下它们应该都在 1ms 上下;如果某些阶段总是明显偏高,就要去看是否是系统层面的干扰、CPU 抢占或者 NUMA 内存分配问题。

特别是 NUMA,ZGC 对 NUMA 支持很好,如果服务器是多路 CPU,建议开启:

-XX:+UseNUMA

让 ZGC 尽量本地分配,避免跨 NUMA 节点的内存访问代价。这个优化在压测当中能看到 10-15% 的吞吐收益,延迟也可以进一步下降。

遇到的「奇怪」问题

这里再分享一个真实问题:线上环境开启了 ZGC,但过一段时间后 CPU 使用率悄然升高。排查后发现在高并发下,读屏障带来的开销是不能忽略的,尤其是在业务代码里有大量引用访问的场景。这个问题表现在数据上就是:GC 停顿很低,但线程自身执行变慢。解决办法是把 ZGC 的并发线程数进一步降低到 2,同时结合 `-XX:+UseNUMA` 并调整应用层连接池和线程池的大小。

另外一个有趣的点是 ZGC 比较适合「低分配速率」的场景。如果你的服务在创建大量生命周期很短的对象,ZGC 的读屏障成本会让 CPU 明显上升,在这种情况下 ZGC 未必比 G1 更有优势。我们网关场景的主要成本在 IO 上面,所以 ZGC 用起来很顺手,但如果是纯计算型的应用,建议先做压测对比再决定。

小结

ZGC 不是万灵药,但在低延迟、大堆并且 CPU 有一定余量的场景下确实是最优解之一。对我们团队来说,这次调优最终带来的效果是:P99 GC 暂停从 G1 时代的 80ms 左右降到了 3-5ms,整体服务质量提高了不少,而且线上运行非常稳定。

如果各位也有 ZGC 调优的经验或者踩过其他坑,欢迎在评论区留言交流。一起让 JVM 的世界更丝滑一些。

全部回复 0

还没有回复,来抢沙发~