线上告警: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

GCEasygchisto 分析日志后,问题一目了然:

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 本身,而是糟糕的对象生命周期管理。