JVM调优实战:从内存模型到GC策略
先看一个真实场景。某支付网关服务,8核16G的容器,高峰期每秒钟要处理两千多笔交易。上线三个月后,运维告警频繁:Full GC 每两分钟一次,单次停顿超过 3 秒,接口 P99 从 80ms 飙到 2.4s。
这台机器上跑的是 JDK 11,默认 G1 收集器,堆内存设了 8G。问题出在哪儿?一步步拆。
先定位问题,别急着调参数
拿到告警先做两件事:看 GC 日志,看堆内存分布。
GC 日志是调优的地基。启动参数里加这几行:
-Xlog:gc*:file=/data/logs/gc-%t.log:time,uptime,level,tags:filecount=10,filesize=50m
JDK 11 用统一日志体系,-Xlog 替代了旧的 -XX:+PrintGCDetails。注意 filecount 和 filesize 配合,防止日志把磁盘写满。
跑个压测,观察 GC 日志输出:
[2026-09-02T10:15:33.123+0800] GC(47) Pause Young (Normal) 2048M->512M(8192M) 12.345ms
[2026-09-02T10:15:35.456+0800] GC(48) Pause Young (Normal) 2048M->1024M(8192M) 15.678ms
[2026-09-02T10:16:01.789+0800] GC(49) Pause Full (Allocation Failure) 4096M->2048M(8192M) 3124.567ms
关键点在这:Young GC 每次回收后,存活对象从 512M 涨到 1024M,说明年轻代对象晋升率奇高。Full GC 前堆占用到了 4096M,但 GC 后只降到 2048M,这 2G 是啥?大概率是缓存或线程池相关的长期存活对象。
再用 jmap -histo:live 看存活对象分布:
num #instances #bytes class name
1: 123456 2147483648 [B
2: 45678 182044672 java.util.concurrent.ConcurrentHashMap$Node
3: 23456 89546732 com.paygw.cache.OrderCache
[B 占了 2G,byte 数组。顺着调用链查下去,发现是订单详情缓存,每个缓存条目存了完整的 JSON 报文,平均 50KB 一条,高峰期 4 万条订单全塞在堆里。
内存模型视角:对象到底死在哪
这个问题的根子在于对象生命周期设计。支付订单在交易结束后,详情已经落库,堆里那份 JSON 应该立刻失效。但代码里用了 ConcurrentHashMap 做本地缓存,没有设置过期时间,导致订单对象从年轻代一路晋升到老年代,最后把老年代填满。
从 JVM 内存模型看,对象晋升有两个条件:年龄达到阈值(默认 15 次 Young GC),或者 Young GC 时 To Survivor 区放不下,直接分配进老年代。
这个案例里,订单缓存对象在 Young GC 时存活,被搬进 Survivor,来回几次后晋升老年代。老年代空间被这些"死而不僵"的对象占满,触发 Full GC。
修复方案有两层:
第一层,代码层面。缓存加上过期策略,用 Caffeine 替代裸的 ConcurrentHashMap:
Cache<String, byte[]> orderCache = Caffeine.newBuilder()
.maximumSize(10000)
.expireAfterWrite(Duration.ofMinutes(5))
.recordStats()
.build();
expireAfterWrite 保证订单详情最多在内存里待 5 分钟。recordStats() 可以导出命中率,监控缓存是否有效。
第二层,JVM 参数层面。调整年轻代大小,让短期对象尽量在 Young GC 中被回收:
-Xms8g -Xmx8g -Xmn4g -XX:SurvivorRatio=8
-Xmn4g 把年轻代从默认的 1/3 堆(约 2.7G)提到 4G,SurvivorRatio=8 表示 Eden 和 Survivor 比例为 8:1,Eden 有 3.2G,足够容纳高峰期新建的订单对象。这些对象在 Eden 里分配,Young GC 时直接回收,不会晋升老年代。
这里有个容易踩的坑:-Xmn 和 -XX:NewRatio 冲突。如果同时设置了 -Xmn 和 -XX:NewRatio=2,-Xmn 生效,NewRatio 被忽略。调优时只用一个方式控制年轻代大小,别两个都写。
GC 策略选择:G1 还是 ZGC
参数调完,Full GC 频率降下来了,从每 2 分钟一次降到每小时一次。但 P99 还是偏高,因为 G1 在混合作业(Mixed GC)阶段,需要回收老年代的缓存对象,停顿时间依然有 300-500ms。
这时候考虑换收集器。JDK 11 里 G1 是默认,但 JDK 17 开始 ZGC 已经比较成熟。ZGC 的卖点是停顿时间与堆大小无关,目标控制在 10ms 以内。
切换前先做基准测试。同一台压测机器,同一份压测脚本,对比 G1 和 ZGC:
| 指标 | G1 (默认) | ZGC (实验性) |
|---|---|---|
| P99 延迟 | 430ms | 65ms |
| 吞吐量 | 98.2% | 97.5% |
| Full GC 次数 | 1次/小时 | 0 |
| 平均停顿 | 180ms | 3ms |
ZGC 用读屏障和染色指针,把标记和清理阶段的大部分工作挪到后台线程并发执行,应用线程几乎不停顿。代价是吞吐量略降,因为并发标记需要额外 CPU 开销。
启动参数:
-XX:+UseZGC -Xms8g -Xmx8g -Xlog:gc*:file=/data/logs/gc-%t.log:time,uptime,level,tags:filecount=10,filesize=50m
切换后,P99 从 430ms 降到 65ms。代价是 CPU 使用率从 60% 升到 72%,多出的 12% 是 ZGC 后台线程在跑标记和重映射。这台机器 8 核,72% 还在可控范围。
ZGC 有个坑:它不支持 -XX:+UseCompressedOops 的完整优化,在超大堆(超过 64G)时对象引用会从 4 字节膨胀到 8 字节,内存占用反而更高。16G 堆没有这个问题,但如果未来扩容到 64G 以上,要重新评估。
踩坑记录:调优参数不是越多越好
最后说一个常见误区。很多团队调 JVM 喜欢堆参数,像这样:
-XX:MaxGCPauseMillis=50
-XX:ParallelGCThreads=8
-XX:ConcGCThreads=4
-XX:G1HeapRegionSize=16m
参数越多,问题越多。-XX:MaxGCPauseMillis=50 是软目标,G1 会尝试达到,但并不意味着一定能控制在 50ms 以内。ParallelGCThreads 和 ConcGCThreads 设置不当,会导致 GC 线程和业务线程争抢 CPU。
调优的原则是:一次只改一个变量。先修代码层面的对象生命周期问题,再看年轻代大小,最后才考虑换收集器。顺序反了,很容易把问题掩盖在参数组合里,治标不治本。
这个支付网关案例最终稳定运行在:ZGC + 8G 堆 + Caffeine 缓存,P99 稳定在 70ms 左右,Full GC 彻底消失,CPU 占用 72%。整个调优周期两周,第一周定位问题,第二周验证和灰度。
如果你手上也有类似的高并发服务,建议先看 GC 日志和堆转储,确认对象存活状态,再动手调参数。调完记得跑压测,用数据说话,别靠感觉。
本文关键词:JVM、G1、ZGC、Caffeine