我们组在一个延迟敏感的 Java 服务上,把 G1 GC 换成 ZGC,p99 延迟从 480ms 降到 95ms。但中间踩了 4 个坑:堆外内存暴涨、CPU 占用 +30%、监控数据失真、容器 OOMKilled。本文写实记录数据 + 排查过程,给打算上 ZGC 的同学一份避坑指南。
背景:为什么要换
这个服务是个延迟敏感的网关,QPS 8w,堆 32GB,在 G1 上的表现:
G1 GC,堆 32GB,JDK 17:
Young GC 平均 80ms,p99 220ms
Mixed GC 平均 180ms,p99 480ms
GC 时间占比 3.2%
应用 p99 延迟 480ms(GC 主导)
晚高峰会出现持续 50-200ms 的卡顿
问题:晚高峰用户体感卡顿,接口 p99 上不去。业务要求 p99 < 100ms。G1 调参调到极限也只能压到 280ms。
ZGC vs G1 vs Shenandoah
指标 G1 ZGC Shenandoah
========================================================
STW 时间 50-300ms <1ms <1ms
吞吐量损失 2-5% 5-15% 5-15%
堆大小限制 <= 64GB 推荐 最大 16TB <= 256GB
最小 JDK 8+ JDK 15 GA JDK 12+
内存占用 相同 多 ~5-10% 多 ~5%
CPU 占用 适中 +20-30% +20-30%
适用场景 通用 低延迟 低延迟
切换:启动参数
# 原 G1 配置
-Xms32g -Xmx32g
-XX:+UseG1GC
-XX:MaxGCPauseMillis=100
-XX:G1HeapRegionSize=16m
-XX:InitiatingHeapOccupancyPercent=40
-XX:+ParallelRefProcEnabled
# 换 ZGC
-Xms32g -Xmx32g
-XX:+UseZGC
-XX:+ZGenerational # JDK 21+ 推荐,分代式 ZGC 性能更好
-XX:ConcGCThreads=4 # 并发 GC 线程,一般 = CPU 数 / 8
-XX:ParallelGCThreads=8 # 并行 GC 线程
-XX:SoftMaxHeapSize=28g # 软上限,触发更激进 GC
-XX:+UseLargePages # 大页内存,降低 TLB miss
# JFR 一定要开,排查问题靠它
-XX:+FlightRecorder
-XX:StartFlightRecording=duration=10m,filename=/tmp/jfr.jfr,settings=profile
# GC 日志
-Xlog:gc*=info,gc+heap=debug,safepoint:file=/tmp/gc.log:time,uptime,tags
坑 1:堆外内存暴涨
切 ZGC 第二天,运维告警:容器 RSS 从 35GB 涨到 41GB,被 OOMKilled 一次
原因:ZGC 用了"彩色指针 + 多重映射"技术
每个对象引用在物理内存上要映射 3 次(Mark0 / Mark1 / Remapped)
JDK 18 之前是真的多重映射,RSS 看起来是堆大小的 3 倍
JDK 19+ 用了 multi-mapping,RSS 看起来正常,但实际还是用了那么多虚拟内存
# 看 ZGC 的实际内存:不能只看 RSS
$ pmap -x <pid> | head -20
$ cat /proc/<pid>/smaps | grep -E "Rss|Pss|Swap" | awk ...
# 更准确:看 JFR 的 Native Memory Tracking
-XX:NativeMemoryTracking=detail
$ jcmd <pid> VM.native_memory summary
# 输出:
Total: reserved=42GB, committed=35GB
- Java Heap (reserved=32GB, committed=32GB)
- Class (reserved=1.2GB, committed=200MB)
- Thread (reserved=800MB, committed=80MB)
- Code (reserved=240MB, committed=130MB)
- GC (reserved=2.1GB, committed=1.8GB) ← ZGC 自己的内存
- Compiler (reserved=12MB, committed=4MB)
- Internal (reserved=180MB, committed=180MB)
- Symbol (reserved=24MB, committed=24MB)
修法:容器 memory limit 设成 JVM heap + 50%。比如 -Xmx32g 配 48GB 内存。
坑 2:CPU 占用涨 30%
G1 时段:CPU 平均 35%
切 ZGC 后:CPU 平均 46%(同 QPS)
原因:ZGC 并发标记 + 并发整理,后台一直在跑
ConcGCThreads 默认 = CPU 数 / 4,持续占用
低延迟换 CPU,这是已知 tradeoff
# 调整 ConcGCThreads,平衡 CPU 和 GC 频率
-XX:ConcGCThreads=2 # 减少并发线程,CPU 降但 GC 慢
-XX:ParallelGCThreads=4
# JDK 21 的分代式 ZGC 大幅降低 CPU
# 我们升上 JDK 21 + ZGenerational 后:
G1 CPU 35% p99 480ms
ZGC (JDK 17) CPU 46% p99 95ms
ZGenerational CPU 39% p99 85ms
坑 3:监控指标失真
Prometheus jvm_gc_pause_seconds 指标:
G1: p99 220ms,看着合理
ZGC: p99 0.8ms,看着完美
但应用 p99 还是有偶尔的 50ms 抖动!
GC pause 不再是延迟来源,但有别的东西在卡
# 用 async-profiler 找延迟来源
$ async-profiler -e wall -d 60 -f profile.html <pid>
# 关注非 GC 的 safepoint
# 安全点统计
$ jcmd <pid> VM.safepoint
SafepointCount = 12345
SafepointTime = 8.7s # 总安全点时间
SafepointWaitTime = 1.2s # 等待进入安全点
# 安全点细分(JFR)
SafepointBegin 45ms # GC 安全点(ZGC 下基本为 0)
BiasedLocking 12ms # 偏向锁撤销,JDK 15+ 默认禁用
ClassRedefinition 8ms # JFR / arthas 改字节码
RevokeBias 120ms # ← 这是问题
ThreadDump 5ms # 用户态触发的 dump
# 如果 RevokeBias 占用高:加 -XX:-UseBiasedLocking
坑 4:Allocation Rate 飙升 → 偶发 STW
ZGC 的核心假设:allocation 速度 < concurrent collection 速度
如果代码大量短生命周期对象(比如解析大 JSON),GC 跟不上分配
ZGC 会触发"Allocation Stall" —— 应用线程被迫等 GC 释放内存
这就是少见但真的发生的 STW
# JFR 看 ZGC allocation stall
$ jfr summary /tmp/jfr.jfr | grep -i "stall"
jdk.ZAllocationStall: count=23, totalDuration=1.2s
# GC 日志看 allocation rate
$ grep "Allocation Rate" gc.log | tail
[10.234s] Allocation Rate: 2.5 GB/s
[20.456s] Allocation Rate: 4.8 GB/s ← 突然飙升
[20.456s] Allocation Stall (Main): 35.123ms ← 出现 stall
// 优化:用对象池 / 减少分配
// 错:每个请求 new 一个 buffer
public byte[] processRequest(byte[] input) {
byte[] buffer = new byte[1024 * 1024]; // 每次 1MB,GC 压力大
// ... 处理 ...
return result;
}
// 对:用 ThreadLocal 复用 buffer
private static final ThreadLocal<byte[]> BUFFER = ThreadLocal.withInitial(() -> new byte[1024 * 1024]);
public byte[] processRequest(byte[] input) {
byte[] buffer = BUFFER.get();
Arrays.fill(buffer, (byte) 0);
// ... 处理 ...
return result;
}
// 更好:用 Netty 的 ByteBuf pool
ByteBufAllocator allocator = PooledByteBufAllocator.DEFAULT;
ByteBuf buffer = allocator.buffer(1024 * 1024);
try {
// ... 处理 ...
} finally {
buffer.release();
}
JFR 性能分析常用命令
# 启动时录制
java -XX:StartFlightRecording=duration=10m,filename=app.jfr,settings=profile
# 运行时录制
jcmd <pid> JFR.start name=record1 duration=60s filename=/tmp/r.jfr
# 看录制状态
jcmd <pid> JFR.check
# 分析 jfr 文件
jfr print --events GarbageCollection,JavaErrorThrow,ExecutionSample app.jfr
# GC summary
jfr summary app.jfr
# 火焰图
$ git clone https://github.com/jvm-profiling-tools/async-profiler
$ ./profiler.sh -e cpu -d 60 -f flame.html <pid>
切换后的真实数据
G1 GC ZGC (JDK 17)
QPS 80,000 80,000
p50 latency 12ms 10ms (-17%)
p95 latency 150ms 45ms (-70%)
p99 latency 480ms 95ms (-80%)
p999 latency 1200ms 180ms (-85%)
GC pause p99 220ms 0.8ms (-99%)
Allocation Stall 0 8 / 小时(可忽略)
CPU 占用 35% 46% (+30%)
RSS 内存 35GB 41GB (+17%)
吞吐能力 82,000 QPS 77,000 QPS (-6%)
业务影响:用户卡顿投诉 / 周 从 200 → 5
什么场景值得换 ZGC
- 对 p99 / p999 延迟敏感(API 网关 / 实时推荐 / 金融交易)
- 堆 > 8GB(小堆 ZGC 优势不明显)
- 不在乎多花 20-30% CPU 资源
- 能上 JDK 21,用分代式 ZGC,效果最好
什么场景不要换
- 吞吐量优先(批处理 / 离线计算)→ 用 Parallel GC
- 堆很小(< 4GB)→ G1 / Serial 够用,ZGC 反而开销大
- CPU 是瓶颈,不能再多 30% CPU
- JDK 还没升到 15+
调优流程
- 基线:G1 调到最优,记录 p50 / p99 / GC pause
- 切 ZGC,跑同样负载,对比延迟
- 开 JFR + async-profiler,找出新的延迟源
- 调 ConcGCThreads / SoftMaxHeapSize
- 优化业务代码降低 allocation rate
- 上 JDK 21 + ZGenerational
- 容器内存上调,避免 OOMKilled
- 监控加 ZAllocationStall / SoftMax 利用率
ZGC 真不是银弹,核心是用 CPU + 内存换延迟。如果你的服务延迟敏感且资源富裕,值得切。我们这个网关切换后晚高峰投诉降到几乎为零,业务方满意度大幅上升,代价就是多花 20% CPU,完全可以接受。
—— 别看了 · 2026