我们有一个核心接口,日调用量上千万,对延迟极其敏感。之前用G1垃圾回收器,P99一直稳定在200ms左右,看起来还行。
直到有一次活动大促,流量翻了三倍,P99直接飙到1.5秒。排查发现,不是代码问题,是G1在内存吃紧时做了一次大GC,STW(Stop The World,停顿)了600ms——用户端直接超时。
那时候我开始认真研究ZGC。JDK 21以后ZGC已经默认支持分代,成熟度比早期版本高了不少。折腾了一个多月换过去,P99从200ms降到了40ms左右,效果立竿见影。但中间踩的坑也不少,写出来给你参考。
先说结论:如果你的服务对延迟敏感、内存有一定余量,ZGC值得换。但ZGC不是银弹,启动参数、内存配置、甚至日志解析方式全都变了,坑比想象中多。
为什么要换:G1在大内存下的停顿问题
G1的设计目标是"可控的停顿时间",通过-XX:MaxGCPauseMillis来约束。但它在两种情况下会破功:
第一种:堆内存不够用的时候。 内存快满了,G1会进入"大GC"模式(Full GC),这时候STW时间不可控,几百毫秒到几秒都有可能。我们大促那次就是这种。
第二种:大对象分配。 超大对象(比如一个几十MB的数组)在G1里分配困难,容易触发连续的垃圾回收,停顿飙升。
ZGC的核心卖点就是:无论堆多大,STW时间都控制在10ms以内,因为它的垃圾回收线程和业务线程是并发的,基本不打断业务。
怎么换:其实就一行配置
ZGC的切换其实很简单,JVM参数改一行:
# 老配置
-XX:+UseG1GC
# 新配置
-XX:+UseZGC
没了,就这一行。启动参数换掉,重启服务,ZGC就开始工作了。
但事情远没有这么简单。下面这几个坑,是我换的过程中真实遇到的。
坑1:ZGC是CPU大户
ZGC的垃圾回收是并发进行的,意思是有专门的GC线程在后台跑,这些线程会占用CPU。
我们服务是8核的,ZGC默认会开一部分核给GC线程用。换过去之后第一周,CPU使用率从45%涨到了60%。如果你CPU本身就吃紧,ZGC可能反而让情况更糟。
解决办法是限制GC线程数:
-XX:ConcGCThreads=2
我把它从默认值调到2,CPU使用率降下来一些,回收效果也没受太大影响。这个参数值得调一调,别用默认值。
坑2:内存一定要留余量
ZGC对内存的占用比G1更激进。原因:ZGC并发回收的时候,需要额外空间放被移动的对象,堆越满,回收压力越大,性能越差。
我们一开始沿用G1时代的内存配置(4G堆),换ZGC后,Full GC反而更频繁了——不是ZGC不行,是内存太小,并发回收根本腾不开手。
调到6G之后,情况才稳定下来。经验:ZGC的堆内存配置,建议比G1时代预留多30%-50%的余量。
坑3:老调优参数不能直接搬
网上很多G1时代的调优文章,参数直接搬到ZGC上,大部分没用,甚至报错:
-XX:MaxGCPauseMillis:对ZGC无效,ZGC的停顿时间由设计保证,不需要这个参数-XX:NewRatio、-XX:SurvivorRatio:这些年轻代参数在分代ZGC下语义不同,别乱设-XX:G1HeapRegionSize:这是G1专属参数,用了直接报错
我的建议是:换ZGC初期,除了-Xmx和-XX:ConcGCThreads,其他参数先别动。 先跑起来看JFR数据,缺什么补什么。ZGC的调优方式和G1完全是两套思路,凭老经验瞎调,只会把系统调坏。
坑4:GC日志格式变了,监控平台得跟着改
这是最容易被忽略的一个。ZGC的GC日志格式和G1完全不同,我们监控平台解析G1日志的规则,换ZGC后直接解析失败。
新格式长这样:
[2026-07-15T10:23:45.123+0800][info][gc] Using ZGC
[2026-07-15T10:23:45.125+0800][info][gc] GC(0) Garbage Collection (Proactive)
[2026-07-15T10:23:45.130+0800][info][gc] GC(0) Pause Mark Start 0.112ms
[2026-07-15T10:23:45.132+0800][info][gc] GC(0) Concurrent Mark 3.421ms
[2026-07-15T10:23:45.135+0800][info][gc] GC(0) Pause Mark End 0.089ms
[2026-07-15T10:23:45.140+0800][info][gc] GC(0) Pause Relocate Start 0.051ms
[2026-07-15T10:23:45.145+0800][info][gc] GC(0) Concurrent Relocate 5.201ms
Pause后面跟的都是STW时间,ZGC的每次STW确实都在个位数毫秒级别。监控平台的解析规则、告警阈值全部要按新格式重写。 别上线前才发现监控瞎了。
坑5:分代ZGC,JDK版本要够新
我们最初是在JDK 17上试ZGC的,那个版本ZGC不支持分代,意味着每次GC都要扫描整个堆,大堆场景效率不高。
JDK 21开始,ZGC支持分代(Generational ZGC),垃圾回收只扫年轻代,效率高了很多。我们当时顺手把JDK也从17升到了21,ZGC的分代模式默认开启。
如果你也在用老JDK,想发挥ZGC的最大威力,建议至少JDK 21+,用分代模式。
换完之后的效果
折腾了一个多月,最终效果:
| 指标 | G1(之前) | ZGC(之后) |
|---|---|---|
| 接口P99 | 200ms | 40ms |
| 接口P999 | 1.5s | 90ms |
| 大促期间最大STW | 600ms | 8ms |
| CPU使用率 | 45% | 60%(调优后55%) |
最明显的是P999:大促期间从1.5秒降到了90ms,用户端基本感知不到卡顿。这是ZGC最值钱的地方——大流量、大堆场景下,停顿依然可控。
代价是CPU占用高了10个点左右,内存多吃了2G。对我们的业务来说,这个交易很划算。
什么场景不适合ZGC
也说点大实话,ZGC不是所有场景都适合:
- CPU资源紧张:ZGC并发回收占CPU,CPU本来就满的,别换
- 堆内存小:4G以下的堆,ZGC的并发回收优势发挥不出来,可能比G1还差
- 对停顿不敏感:你的服务没有低延迟要求,P99 200ms就够用,何必折腾
- 内存本身不够:ZGC需要额外的堆空间余量,内存预算紧的,别硬上
一句话:延迟敏感的在线服务、内存有冗余、CPU有余量——这三个条件都满足,ZGC才值得。
结尾
换ZGC这件事,我最大的感受是:JVM调优不是玄学,但也不是复制粘贴参数。 你得理解每个垃圾回收器的工作原理,知道它在什么条件下表现好、什么条件下拉胯,再结合自己的业务场景做判断。
我们现在的线上服务,ZGC跑了大半年,大促扛了好几轮,P99稳定在40ms上下。这个钱花得值。
如果这篇对你有用,点个关注。下一篇我想写RocketMQ事务消息——分布式事务这件事,我们踩过的坑可能比你想的多。