JVM 调优手记:GC 停顿时间从 500ms 压到 20ms
线上告警:P99 延迟从 80ms 飙升至 2000ms,每隔几分钟就有一次「毛刺」。排查了三天,最终发现罪魁祸首是 GC。这是一篇完整的 JVM 调优实战记录,从问题定位到参数调优,全程复盘。
事故现场#
我们的实时推荐服务部署在 8 台 16C32G 的机器上,JDK 17,堆内存配置为 -Xmx12g -Xms12g,使用 G1 垃圾回收器。日均请求量 2 亿次,单机 QPS 约 3000。
某天凌晨,监控系统突然告警:
告警指标:
- P99 延迟:80ms → 2000ms(持续波动)
- GC 暂停时间:平均 500ms,峰值 3200ms
- Full GC 频率:每 3-5 分钟一次
- CPU 使用率:从 40% 飙升至 85%
用户侧的表现是:推荐列表偶尔加载极慢,页面出现空白等待。
第一步:收集 GC 日志#
开启详细 GC 日志是所有调优工作的起点:
# JDK 17 的 GC 日志参数
-Xlog:gc*:file=/data/logs/gc.log:time,uptime,level,tags:filecount=10,filesize=50m
# 如果是 JDK 8(虽然很老了)
-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/data/logs/gc.log
用 GCEasy 或 gchisto 分析日志后,问题一目了然:
GC 统计摘要(问题期间):
Young GC:
平均耗时: 150ms
频率: 每 30 秒一次
Mixed GC:
平均耗时: 450ms
频率: 每 2 分钟一次
Full GC:
平均耗时: 2800ms
频率: 每 4 分钟一次 ← 这就是元凶!
Full GC 是 G1 回收器的「最后手段」,当 Mixed GC 来不及回收老年代空间,而新对象又需要分配时,就会触发 Full GC。G1 的 Full GC 是单线程的(JDK 10 之前),对于 12GB 的堆来说,一次 Full GC 需要遍历所有对象,耗时自然极长。
第二步:定位内存泄漏#
Full GC 频繁的直接原因是老年代空间不足。但为什么老年代会填满?
用 jmap 导出堆转储:
jmap -dump:live,format=b,file=/tmp/heapdump.hprof <pid>
用 Eclipse MAT(Memory Analyzer Tool)打开分析,发现了可疑的对象堆积:
堆内存概览:
总堆大小: 12GB
已使用: 11.2GB (93%)
Top 对象(按 retained size):
1. byte[] - 3.8GB (34%)
2. HashMap$Node[] - 2.1GB (19%) ← 可疑!
3. RecommendationResult[] - 1.5GB (13%) ← 可疑!
4. String - 0.9GB (8%)
进一步追踪 GC Root 引用链,发现了一个本地缓存的内存泄漏:
// 问题代码
public class RecommendationCache {
// 一个永不过期的本地缓存!
private static final Map<String, List<RecommendationResult>> cache = new HashMap<>();
public void put(String userId, List<RecommendationResult> results) {
cache.put(userId, results); // 只进不出,无限增长
}
public List<RecommendationResult> get(String userId) {
return cache.get(userId);
}
}
这个缓存没有设置过期策略和容量上限。每一次推荐请求的结果都被存入缓存,永远不被清除。随着运行时间增长,缓存中的数据越来越多,最终把老年代撑满。
第三步:修复泄漏 + 重新调优#
修复内存泄漏#
将 HashMap 替换为 Caffeine 缓存,设置合理的过期策略和容量上限:
// 修复后的代码
public class RecommendationCache {
private static final Cache<String, List<RecommendationResult>> cache =
Caffeine.newBuilder()
.maximumSize(100_000) // 最多 10 万条
.expireAfterWrite(5, TimeUnit.MINUTES) // 5 分钟过期
.recordStats() // 启用统计
.build();
public void put(String userId, List<RecommendationResult> results) {
cache.put(userId, results);
}
public List<RecommendationResult> get(String userId) {
return cache.getIfPresent(userId);
}
}
G1 参数调优#
修复泄漏后,重新审视 G1 的参数配置。原来的配置几乎是「裸奔」——除了堆大小,其他全是默认值。
# 调优后的完整 JVM 参数
-server
-Xmx12g
-Xms12g # 堆大小固定,避免动态扩缩的开销
-XX:+UseG1GC # 使用 G1 收集器
# 停顿时间目标(关键参数)
-XX:MaxGCPauseMillis=50 # 目标最大 GC 停顿 50ms(默认 200ms)
# Region 大小
-XX:G1HeapRegionSize=8m # 12GB 堆 / 8MB = 1536 个 Region
# 预留空间
-XX:G1ReservePercent=15 # 预留 15% 空间给 Survivor(默认 10%)
# Mixed GC 触发阈值
-XX:InitiatingHeapOccupancyPercent=35 # 老年代占比 35% 时开始 Mixed GC(默认 45%)
# 并发标记线程
-XX:ConcGCThreads=4 # 并发标记线程数(默认 = CPU/4)
# 大对象阈值
-XX:G1HeapWastePercent=5 # 可回收比例低于 5% 时停止 Mixed GC
# GC 日志
-Xlog:gc*:file=/data/logs/gc.log:time,uptime,level,tags:filecount=10,filesize=50m
关键参数解析#
MaxGCPauseMillis:从 200ms 调到 50ms
G1 会根据这个目标自动调整 Young 区的大小。目标越小,Young 区越小,Young GC 越频繁但单次耗时越短。我们的服务是延迟敏感型,50ms 的目标是合理的。
MaxGCPauseMillis = 200ms(默认):
Young 区较大 → Young GC 频率低但耗时长 → 偶尔触发 Mixed GC/Full GC
MaxGCPauseMillis = 50ms(调优后):
Young 区较小 → Young GC 频率高但耗时短 → Mixed GC 更平滑
InitiatingHeapOccupancyPercent:从 45% 调到 35%
提前触发 Mixed GC,让 G1 有更多时间慢慢回收老年代,而不是等到老年代快满了才手忙脚乱。
IHOP = 45%(默认):
堆使用 45% → 触发并发标记 → 标记完才开始 Mixed GC
如果对象晋升速度快,Mixed GC 来不及回收 → Full GC
IHOP = 35%(调优后):
堆使用 35% → 提前触发并发标记 → 有充裕时间做 Mixed GC
代价:并发标记会占用一些 CPU 资源
调优效果#
调优前 调优后
─────────────────────────────────────────────
Young GC 耗时 150ms 15-25ms
Mixed GC 耗时 450ms 30-50ms
Full GC 频率 每 4 分钟 0 次/天
P99 延迟 2000ms 65ms
CPU 使用率 85% 35%
吞吐量影响 - -3%(可接受)
P99 延迟从 2000ms 降到了 65ms,Full GC 完全消失。代价是 Young GC 频率增加了(从 30 秒一次变成 8 秒一次),但由于单次耗时只有 15-25ms,对用户体验几乎无感。
通用调优方法论#
回顾这次调优,我总结了一套 GC 调优的标准流程:
Step 1: 观察
- 开启 GC 日志
- 用 GCEasy / gchisto 可视化
- 确认 GC 类型分布(Young / Mixed / Full 的比例)
Step 2: 定位
- 如果有 Full GC → 检查是否有内存泄漏
- 如果 Young GC 耗时过长 → 检查 Young 区大小
- 如果 Mixed GC 耗时过长 → 检查老年代碎片化程度
Step 3: 调优
- 先修泄漏,再调参数
- 每次只改一个参数,观察效果
- 记录每次调整的参数和对应的指标变化
Step 4: 验证
- 在压测环境中验证(不要直接上线)
- 观察至少 24 小时,确认无 Full GC
- 对比吞吐量和延迟的变化
几条实战中容易踩的坑#
坑 1:不要盲目增大堆内存
堆越大,单次 GC 的扫描范围越大。12GB 已经是一个需要仔细调优的大小了。如果你的服务只需要 4GB 就能正常运行,就不要给它 16GB。
坑 2:不要在 JDK 17 上用 CMS
CMS 在 JDK 14 已经被移除。如果你在 JDK 17 上还在用 -XX:+UseConcMarkSweepGC,JVM 会直接报错。G1 是 JDK 17 的默认选择,ZGC/Shenandoah 是低延迟场景的进阶选项。
坑 3:不要忽视对象分配速率
GC 问题的根源往往不是回收太慢,而是分配太快。减少不必要的对象创建(比如用对象池、复用数组)比调优 GC 参数更有效。
// 高分配速率的代码
public List<String> process(Request request) {
List<String> result = new ArrayList<>();
for (Item item : request.getItems()) {
// 每次循环都创建新的 StringBuilder
StringBuilder sb = new StringBuilder();
sb.append(item.getName()).append("-").append(item.getId());
result.add(sb.toString());
}
return result;
}
// 优化后的代码
public void process(Request request, List<String> result) {
result.clear(); // 复用 List
StringBuilder sb = new StringBuilder(64); // 预分配容量
for (Item item : request.getItems()) {
sb.setLength(0); // 复用 StringBuilder
sb.append(item.getName()).append("-").append(item.getId());
result.add(sb.toString());
}
}
结语#
GC 调优不是一门精确科学,更像是一门经验工程。没有「最佳参数」,只有在特定业务场景下的「足够好的参数」。
如果你正在面对 GC 问题,记住这个优先级:修泄漏 > 减少分配 > 调 GC 参数。大多数 GC 问题的根因不是 GC 本身,而是糟糕的对象生命周期管理。