JVM 老年代每天 Full GC 上百次:从 CMS 到 G1 再到 ZGC 调优实录

32 个 Java 交易实例,每隔几分钟卡顿一次,接口 P99 从 30ms 飙到 1.8s,CMS 一天上百次 Full GC,单次 STW 1.5s。两周调优:GC 日志定位 + 迁 G1 + MAT 抓出 static HashMap 内存泄漏 + 上 ZGC。Full GC 清零,停顿降到 2ms,P99 稳定 35ms,压测 QPS 翻 2.5 倍。

2024 年我们的核心交易服务:32 个 Java 实例,8C16G,堆内存 8GB,某次大促前压测发现 — 服务每隔几分钟就卡顿一次,接口 P99 从 30ms 飙到 1.8s,排查是 Full GC 在作祟,单次 STW 长达 1.5 秒,一天累计上百次。投了两周做 JVM GC 调优,从 CMS 迁到 G1 再到 ZGC,Full GC 基本消除,GC 停顿从 1.5s 降到 2ms,P99 稳定在 35ms。本文复盘 JVM 内存模型、GC 日志分析、收集器选型、内存泄漏排查的完整实战。

问题背景

服务:Spring Boot 2.7,JDK 11,32 实例
配置:8C16G 容器,-Xmx8g -Xms8g
收集器:CMS(老参数沿用多年)

事故现象:
- 接口 P99:30ms → 周期性飙到 1.8s
- 监控看到锯齿状停顿,每 3-5min 一次
- GC 日志里 Full GC 频繁,单次 STW 1.2-1.5s
- 大促压测 QPS 上不去,一压就抖

初步排查:
# 1. 看 GC 概况(jstat 每 1s 采样)
$ jstat -gcutil 12345 1000
  S0    S1     E      O      M     YGC   YGCT   FGC   FGCT     GCT
  0.00 12.34  68.21  98.76  95.10  8421 210.3   312  468.5    678.8
                     ↑↑↑↑↑                      ↑↑↑   ↑↑↑↑↑
              老年代 98% 几乎打满      Full GC 312 次,累计 468 秒

# 2. CMS 的致命问题:Concurrent Mode Failure
$ grep "concurrent mode failure" gc.log | wc -l
287                          # 287 次 CMS 并发回收失败 → 退化为 STW Full GC

# 3. CMS 还有内存碎片问题
$ grep "promotion failed" gc.log | wc -l
44                           # 碎片导致大对象晋升失败

根因判断:
1. CMS 并发回收跟不上对象晋升速度 → concurrent mode failure
2. CMS 不压缩,长期运行碎片化 → promotion failed
3. 老年代真实占用持续走高 → 疑似内存泄漏
4. 8GB 堆 + CMS 的组合,本身就不适合低延迟场景

修复 1:GC 日志分析与定位

# === 开启详细 GC 日志(JDK 11 统一日志框架)===
java -Xlog:gc*,gc+age=trace,safepoint:file=/logs/gc.log:time,uptime,level,tags:filecount=10,filesize=50m \
     -XX:+HeapDumpOnOutOfMemoryError \
     -XX:HeapDumpPath=/logs/heapdump.hprof \
     -jar app.jar

# JDK 8 写法(对照)
# -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/logs/gc.log
# -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=10 -XX:GCLogFileSize=50m

# === 关键 GC 日志片段解读 ===
# [2024-07-01T10:23:15.123+0800] GC(412) Pause Full (Allocation Failure)
#   8021M->6890M(8192M) 1487.332ms
#   ↑ Full GC,回收前 8021M,回收后 6890M,停顿 1487ms
#   ↑ 回收后老年代还有 6890M → 大量对象回收不掉 = 泄漏信号

# === 用工具分析 GC 日志 ===
# 1. GCViewer / GCeasy.io 上传 gc.log,看吞吐、停顿分布
# 2. 命令行快速统计
$ grep "Pause Full" gc.log | wc -l                    # Full GC 次数
$ grep "Pause Full" gc.log | awk '{print $NF}' | sort -rn | head  # 最长停顿

# === jstat 实时观察晋升速率 ===
$ jstat -gc 12345 1000 10
# 关注 EU(eden 用量)增长速度 = 对象分配速率
# 关注 OU(old 用量)增长速度 = 晋升速率,这个高就是问题源头

# === jmap 看对象分布(找占内存大户)===
$ jmap -histo:live 12345 | head -20
 num     #instances         #bytes  class name
   1:      18234567     1458765360  [B                  (byte 数组)
   2:       8923451      428325648  java.lang.String
   3:       6234123      299237904  com.xxx.OrderCacheEntry   ← 可疑

修复 2:堆内存与分代调优

# === 容器环境的内存感知(JDK 11 默认已支持)===
# 错误:容器 16G,-Xmx8g 写死,容器调整后不会变
# 正确:用比例,JVM 自动按容器 limit 算
-XX:+UseContainerSupport                  # 默认开启,识别 cgroup limit
-XX:InitialRAMPercentage=60.0
-XX:MaxRAMPercentage=60.0                 # 堆占容器内存 60%(留空间给元空间/线程栈/直接内存)
-XX:MinRAMPercentage=60.0

# === 堆内分代比例 ===
# CMS 时代的调优(对照):
-Xmx8g -Xms8g                             # 堆固定,避免动态扩缩
-Xmn3g                                    # 新生代 3G(交易服务对象朝生夕死多)
-XX:SurvivorRatio=8                        # eden:survivor = 8:1:1
-XX:MaxTenuringThreshold=6                 # 对象熬过 6 次 YGC 才晋升老年代
-XX:+UseCMSInitiatingOccupancyOnly
-XX:CMSInitiatingOccupancyFraction=70      # 老年代到 70% 就启动 CMS(留余量防失败)

# === 元空间(MetaSpace)别忘了限制 ===
-XX:MetaspaceSize=256m
-XX:MaxMetaspaceSize=256m                  # 不限制会一直涨,吃满容器内存

# === 直接内存(Netty/NIO 用)===
-XX:MaxDirectMemorySize=1g                 # 显式限制,否则默认等于 -Xmx,容易 OOM

# 调优后先验证:concurrent mode failure 是否减少
# 但根本问题是 CMS 本身 → 下一步换 G1

修复 3:从 CMS 迁移到 G1

# === G1:面向大堆 + 可预测停顿的收集器(JDK 9+ 默认)===
java -Xmx8g -Xms8g \
  -XX:+UseG1GC \
  -XX:MaxGCPauseMillis=100 \               # 目标停顿 100ms,G1 自动调整以逼近
  -XX:G1HeapRegionSize=8m \                # region 大小,8G 堆配 8m(2048 个 region)
  -XX:InitiatingHeapOccupancyPercent=45 \  # 堆占用 45% 启动并发标记(IHOP)
  -XX:G1NewSizePercent=30 \                # 新生代下限 30%
  -XX:G1MaxNewSizePercent=60 \             # 新生代上限 60%
  -XX:G1ReservePercent=15 \                # 预留 15% 防晋升失败(to-space exhausted)
  -XX:+ParallelRefProcEnabled \            # 并行处理引用,缩短 Remark 阶段
  -XX:+UseStringDeduplication \            # 字符串去重,省堆内存
  -jar app.jar

# === G1 不要做的事 ===
# 1. 不要同时设 -Xmn / -XX:NewRatio,会让 MaxGCPauseMillis 失效
# 2. 不要把 MaxGCPauseMillis 设太小(如 10ms),G1 会把新生代压得很小
#    → YGC 过于频繁,反而吞吐崩了。50-200ms 是合理区间
# 3. G1HeapRegionSize 别太小,大对象(> region 一半)会进 humongous 区

# === 观察 G1 的关键指标 ===
$ jstat -gc 12345 1000
# G1 日志里:
# [GC pause (G1 Evacuation Pause) (young)]      → YGC,正常
# [GC pause (G1 Evacuation Pause) (mixed)]      → 混合回收老年代,正常
# [Full GC (Allocation Failure)]                → 出现就是失败信号!G1 不该 Full GC

# === G1 出现 Full GC 的常见原因 ===
# 1. to-space exhausted:回收时没有空 region 放存活对象 → 调大 G1ReservePercent
# 2. humongous 对象太多:大对象碰撞 → 调大 G1HeapRegionSize
# 3. 并发标记跟不上:IHOP 太高 → 调低 InitiatingHeapOccupancyPercent
$ grep "Full GC" gc-g1.log | grep -oP '\(.*?\)' | sort | uniq -c

修复 4:内存泄漏排查(MAT)

# 换了 G1 停顿下来了,但老年代占用还是缓慢爬升 → 确实有泄漏

# === 抓堆转储 ===
# 方式 1:OOM 时自动抓(已配 -XX:+HeapDumpOnOutOfMemoryError)
# 方式 2:运行时手动抓(注意:抓 dump 会 STW,生产挑低峰)
$ jmap -dump:live,format=b,file=/logs/heap.hprof 12345
# 方式 3:jcmd(更现代)
$ jcmd 12345 GC.heap_dump /logs/heap.hprof

# === 用 Eclipse MAT 分析 heap.hprof ===
# 1. Leak Suspects 报告:MAT 自动找出可疑泄漏点
#    "One instance of ConcurrentHashMap occupies 4.2 GB (61%)"
# 2. Dominator Tree:按"支配"关系看谁占内存最多
# 3. 看 GC Roots 引用链:这个对象为什么回收不掉

# === 本次定位结果 ===
# 可疑大户 com.xxx.OrderCacheEntry 有 620w 个实例
# 引用链:
#   GC Root (static)
#    └─ OrderLocalCache.CACHE  (static final HashMap)
#        └─ 620w 个 OrderCacheEntry   ← 一个永不清理的静态 Map!

# 根因:有人图方便用 static HashMap 做本地缓存,只 put 不 remove
# 订单一直累积,GC Root 强引用,永远回收不掉 = 教科书级内存泄漏
// === 修复:换成有界 + 自动过期的缓存 ===
// 错误:无界 static HashMap,只增不减
// private static final Map<Long, OrderCacheEntry> CACHE = new HashMap<>();

// 正确:Caffeine,容量上限 + 写后过期
private static final Cache<Long, OrderCacheEntry> CACHE = Caffeine.newBuilder()
    .maximumSize(100_000)                       // 硬上限,超了按 LRU 淘汰
    .expireAfterWrite(Duration.ofMinutes(10))   // 写后 10min 过期
    .recordStats()                              // 暴露命中率指标
    .build();

// 自查清单:Java 里常见的内存泄漏来源
// 1. static 集合只 put 不 remove(最常见)
// 2. ThreadLocal 用完不 remove,线程池场景线程复用 → 泄漏
// 3. 监听器/回调注册了不注销
// 4. 自定义 ClassLoader 持有引用(热部署场景)
// 5. 连接/流/资源未 close

// ThreadLocal 正确用法:try-finally 兜底 remove
private static final ThreadLocal<Context> CTX = new ThreadLocal<>();
public void handle() {
    try {
        CTX.set(new Context());
        doWork();
    } finally {
        CTX.remove();                           // 线程池里必须 remove
    }
}

修复 5:ZGC 低延迟方案

# G1 调优后 Full GC 没了,但 Mixed GC 仍有 80-120ms 停顿
# 交易服务想要 < 10ms 的极致延迟 → 上 ZGC

# === ZGC:JDK 15+ 正式可用,停顿与堆大小无关,目标 < 1ms ===
java -Xmx8g -Xms8g \
  -XX:+UseZGC \
  -XX:+ZGenerational \                     # JDK 21+ 分代 ZGC,吞吐更好,强烈建议开
  -XX:SoftMaxHeapSize=7g \                 # 软上限,ZGC 尽量不超过它
  -XX:+UseLargePages \                     # 大页内存,减少 TLB miss
  -XX:ConcGCThreads=2 \                    # 并发 GC 线程数(按 CPU 调)
  -Xlog:gc*:file=/logs/gc-zgc.log \
  -jar app.jar

# === ZGC 的取舍 ===
# 优点:停顿稳定 < 1ms,堆再大也不涨(8G / 一两百 G 都一样)
# 代价:
#  1. 吞吐比 G1 略低(并发做的事多,占 CPU)→ 分代 ZGC 已大幅改善
#  2. 内存占用略高(染色指针 + 多重映射)
#  3. 需要 JDK 升级:ZGC 建议 JDK 17+,分代 ZGC 要 JDK 21+

# === 收集器选型决策 ===
# 堆 < 4G,吞吐优先(批处理)   → Parallel GC
# 堆 4-16G,要平衡             → G1(JDK 9+ 默认,90% 场景够用)
# 要极致低延迟,堆大           → ZGC / Shenandoah
# 我们最终:交易服务 ZGC,后台批处理服务 G1

# === 验证停顿 ===
$ grep -oP 'Pause \w+ .*?(\d+\.\d+)ms' gc-zgc.log | awk '{print $NF}' | sort -rn | head
2.1ms
1.8ms
1.5ms                                      # 全部个位数 ms,达标

修复 6:监控告警

# Micrometer + Prometheus,Spring Boot Actuator 暴露 JVM 指标
# management.endpoints.web.exposure.include=prometheus

groups:
- name: jvm-gc
  rules:
  # 1. Full GC 发生(G1/ZGC 下出现就是异常)
  - alert: JvmFullGcHappening
    expr: increase(jvm_gc_pause_seconds_count{action="end of major GC"}[5m]) > 0
    annotations:
      summary: "{{ $labels.instance }} 发生 Full GC,检查内存泄漏或参数"

  # 2. GC 停顿时间 P99
  - alert: JvmGcPauseHigh
    expr: |
      histogram_quantile(0.99,
        rate(jvm_gc_pause_seconds_bucket[5m])) > 0.2
    for: 5m
    annotations:
      summary: "{{ $labels.instance }} GC 停顿 P99 > 200ms"

  # 3. 老年代使用率(回收后仍高 = 泄漏信号)
  - alert: JvmOldGenHigh
    expr: |
      jvm_memory_used_bytes{area="heap",id=~".*Old.*|.*Tenured.*"}
      / jvm_memory_max_bytes{area="heap",id=~".*Old.*|.*Tenured.*"} > 0.8
    for: 10m
    annotations:
      summary: "{{ $labels.instance }} 老年代 > 80%,疑似泄漏"

  # 4. GC 吞吐(GC 耗时占比,> 5% 说明 GC 太重)
  - alert: JvmGcOverhead
    expr: |
      sum(rate(jvm_gc_pause_seconds_sum[5m])) by (instance) > 0.05
    for: 10m
    annotations:
      summary: "{{ $labels.instance }} GC 耗时占比 > 5%"

  # 5. 堆内存使用率
  - alert: JvmHeapHigh
    expr: |
      sum(jvm_memory_used_bytes{area="heap"}) by (instance)
      / sum(jvm_memory_max_bytes{area="heap"}) by (instance) > 0.9
    for: 5m
    annotations:
      summary: "{{ $labels.instance }} 堆使用率 > 90%"

  # 6. 元空间使用率
  - alert: JvmMetaspaceHigh
    expr: |
      jvm_memory_used_bytes{id="Metaspace"}
      / jvm_memory_max_bytes{id="Metaspace"} > 0.85
    annotations:
      summary: "{{ $labels.instance }} 元空间 > 85%,检查类加载泄漏"

  # 7. 线程数(线程泄漏)
  - alert: JvmThreadHigh
    expr: jvm_threads_live_threads > 1000
    annotations:
      summary: "{{ $labels.instance }} 线程数 > 1000,检查线程池泄漏"

优化效果

指标                    优化前(CMS)   G1            ZGC
=============================================================
GC 停顿 P99             1487ms         95ms          2ms
Full GC 次数/天          312            0             0
接口 P99                1.8s           120ms         35ms
接口 P999               3.2s           280ms         48ms
GC 耗时占比              9.8%           2.1%          1.4%
吞吐(单实例 QPS)       1800           4200          4600
老年代回收后占用         6.8G(泄漏)    回收干净      回收干净

成本与稳定性:
- 修掉 static HashMap 泄漏,服务不再需要每周重启"续命"
- 大促压测 QPS 从 5.7w 提升到 14w,无停顿抖动
- 32 实例 GC 停顿对齐消除,雪崩式超时消失
- 收集器分层:交易服务 ZGC,批处理服务 G1,各取所需

排查耗时:
- GC 日志分析定位 CMS 问题:1 天
- G1 调参 + 压测验证:3 天
- MAT 抓泄漏:半天(Leak Suspects 直接点出来)
- ZGC 迁移 + JDK 升级验证:1 周

避坑清单

  1. 必开 GC 日志(-Xlog:gc*),没日志的 GC 问题等于盲人摸象
  2. 容器里用 -XX:MaxRAMPercentage 而非 -Xmx 写死,适配 cgroup limit
  3. CMS 已废弃(JDK 14 移除),concurrent mode failure 是它的固有缺陷
  4. JDK 9+ 默认 G1,90% 场景直接用 G1,别再沿用 CMS 老参数
  5. G1 下别同时设 -Xmn,会让 MaxGCPauseMillis 失效
  6. G1 出现 Full GC 就是异常,查 to-space exhausted / humongous
  7. 老年代回收后仍居高不下 = 内存泄漏信号,立刻抓 heap dump
  8. MAT 的 Leak Suspects + Dominator Tree 是定位泄漏的利器
  9. static 集合、ThreadLocal 不 remove 是泄漏头号来源
  10. 要极致低延迟上 ZGC(JDK 21+ 用分代 ZGC),停顿与堆大小无关

总结

JVM GC 调优最容易被误解为"调参数",但这次实战让我们认清:调优的 90% 是诊断,只有 10% 是改参数。最大的认知改变是,GC 频繁、停顿长往往不是 GC 本身的问题,而是内存泄漏的表象 —— 一个永不清理的 static HashMap 让老年代被 620 万个订单对象占满,GC 拼命回收却什么也收不掉,于是被迫一遍遍 Full GC,真正该修的是那行代码,不是 GC 参数。最被低估的工具是 GC 日志,很多团队线上服务根本没开 GC 日志,出了问题只能靠重启续命,而一行 -Xlog:gc* 几乎零成本,concurrent mode failure、promotion failed、回收后老年代占用,所有线索都写在里面。最容易踩的坑是抱着 CMS 不放,CMS 在 JDK 14 已被移除,它的并发回收跟不上、不压缩导致碎片,这些是设计缺陷不是调参能救的,JDK 9 之后 G1 就是默认且足够好的选择。最后,收集器没有银弹:G1 适合 90% 的平衡场景,ZGC 用极致低延迟换一点吞吐和内存,我们最终按服务特性分层 —— 交易链路上 ZGC 把停顿压进个位数毫秒,后台批处理留在 G1 保吞吐,选型的本质是想清楚自己要什么。

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

Redis 内存从 30G 暴涨到 90G:大 key 热 key 与过期策略治理实录

2026-5-20 10:57:37

技术教程

一个共用线程池拖垮全站:线程池隔离与参数调优实录

2026-5-20 12:09:08

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