G1 换 ZGC 实战:p99 从 480ms 降到 95ms,踩了 4 个坑

延迟敏感网关把 G1 换 ZGC,p99 从 480ms 降到 95ms。但中间踩坑:堆外内存暴涨被 OOMKilled、CPU +30%、监控数据失真、Allocation Stall。本文写实记录 4 个坑 + JFR 调试 + JDK 21 分代式 ZGC 数据 + 选型建议,附完整启动参数。

我们组在一个延迟敏感的 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

  1. 对 p99 / p999 延迟敏感(API 网关 / 实时推荐 / 金融交易)
  2. 堆 > 8GB(小堆 ZGC 优势不明显)
  3. 不在乎多花 20-30% CPU 资源
  4. 能上 JDK 21,用分代式 ZGC,效果最好

什么场景不要换

  1. 吞吐量优先(批处理 / 离线计算)→ 用 Parallel GC
  2. 堆很小(< 4GB)→ G1 / Serial 够用,ZGC 反而开销大
  3. CPU 是瓶颈,不能再多 30% CPU
  4. JDK 还没升到 15+

调优流程

  1. 基线:G1 调到最优,记录 p50 / p99 / GC pause
  2. 切 ZGC,跑同样负载,对比延迟
  3. 开 JFR + async-profiler,找出新的延迟源
  4. 调 ConcGCThreads / SoftMaxHeapSize
  5. 优化业务代码降低 allocation rate
  6. 上 JDK 21 + ZGenerational
  7. 容器内存上调,避免 OOMKilled
  8. 监控加 ZAllocationStall / SoftMax 利用率

ZGC 真不是银弹,核心是用 CPU + 内存换延迟。如果你的服务延迟敏感且资源富裕,值得切。我们这个网关切换后晚高峰投诉降到几乎为零,业务方满意度大幅上升,代价就是多花 20% CPU,完全可以接受。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

Kafka 顺序消费的 7 层防线:订单状态错乱事故复盘

2026-5-19 11:28:17

技术教程

gRPC 全链路 deadline 传播实战:从下游被卡死到 5 分钟定位

2026-5-19 11:32:26

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索