Full GC 后堆只降了 21M:一次 JVM 内存泄漏排查的复盘

服务每隔三四天就有实例变慢、CPU 冲高、重启即愈,典型的频繁 Full GC。GC 日志里 Full GC 后堆只降 21M,基本锁定内存泄漏。dump 堆用 MAT 一查,根子是一个 static HashMap 缓存只进不出。几天排查 JVM 内存:泄漏与容量之分、GC 日志、MAT 分析、缓存治理、ThreadLocal、内存监控。

2024 年我们一个跑了大半年都很稳定的后台服务,开始出现一种很折磨人的症状:每隔三四天,就会有一两个实例响应变得极慢,接口动辄卡顿好几秒,过一阵又自己"好"了,再过几天又犯。监控上看,这台机器的 CPU 会在卡顿时段莫名其妙冲高。一开始我们以为是流量毛刺,后来才意识到——这是典型的 JVM 频繁 Full GC,而 Full GC 的背后,是一处藏得很深的内存泄漏:有个缓存只进不出,堆内存被一点点吃满,GC 拼命回收却收不回多少,只能一遍遍地做代价高昂的 Full GC。投了几天把这次内存问题彻底排查清楚,本文复盘这次实战。

问题背景

业务:后台数据服务,Java 8,堆 4G,常驻运行
事故现象:
- 每隔 3-4 天,个别实例响应变慢,接口卡顿数秒
- 卡顿时段 CPU 冲高,过一阵自行恢复,周期性复发
- 重启实例后立刻恢复正常,几天后又复发

现场排查:
# 1. 看 GC 日志(第一手证据)
$ tail -f gc.log
[Full GC (Ergonomics) 3801M->3780M(4096M), 2.13 secs]
[Full GC (Ergonomics) 3802M->3781M(4096M), 2.20 secs]
# 关键信号:Full GC 后堆几乎没降(3801M->3780M),
#          每次还卡 2 秒 —— 典型的内存泄漏

# 2. 看 GC 频率与停顿
$ jstat -gcutil  1000
  O      FGC    FGCT
 99.2    1240   2680.5    # 老年代 99%,Full GC 1240 次
# 老年代长期满,Full GC 收不动 —— 有对象一直被引用,回收不掉

# 3. dump 堆,分析对象占用
$ jmap -dump:live,format=b,file=heap.hprof 
# 用 MAT 打开,看 Histogram + Dominator Tree

根因:
1. 有个本地缓存(HashMap)只往里 put,从不清理 -> 无限增长
2. 缓存的 key 还带了时间戳,几乎每次都是新 key,永不命中也永不淘汰
3. 对象被这个 Map 强引用着,GC 判定它们"还在用",回收不掉
4. 堆被泄漏对象占满,Full GC 越来越频繁,直至卡死

修复 1:先分清是"内存不够"还是"内存泄漏"

=== 两种情况,表现像,本质完全不同 ===
内存不够(容量问题):
- 业务量确实涨了,4G 堆真的不够用了
- 现象:堆用量整体抬高,但 Full GC 后能明显回落
- 解法:加堆 / 优化对象大小 / 拆分服务

内存泄漏(Bug):
- 有对象本该被回收,却因为还被引用着,回收不掉
- 现象:Full GC 后堆几乎不降,锯齿状曲线的"谷底"
  一次比一次高,最终顶到天花板
- 解法:找到泄漏点,断开那个不该有的引用

=== 怎么快速区分:看 Full GC 后的堆用量 ===
[Full GC 3801M->1200M] -> 回收了 2.6G,健康,可能只是堆偏小
[Full GC 3801M->3780M] -> 只回收 21M,收不动 = 内存泄漏

=== Java 内存泄漏的定义 ===
不是 C 那种"忘了 free",Java 有 GC。
Java 的泄漏是:对象逻辑上已经没用了,
但还有一条引用链把它连着 GC Root,
GC 认为它"可达 = 还在用",于是永远不回收它。
泄漏的根源,永远是【一个不该存在的引用】。

修复 2:GC 日志 —— 排查的第一手证据

=== 一定要开 GC 日志,它是排查的起点 ===
# Java 8 开 GC 日志:
-XX:+PrintGCDetails -XX:+PrintGCDateStamps
-Xloggc:/path/gc.log
-XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=5
-XX:GCLogFileSize=20M

# Java 9+ 用统一日志:
-Xlog:gc*:file=/path/gc.log:time,uptime:filecount=5,filesize=20M

=== 从 GC 日志看什么 ===
1. Full GC 的频率:几分钟一次 vs 几天一次,差别巨大
2. Full GC 后的回收效果:堆降了多少 -> 区分泄漏/容量
3. 单次 GC 停顿时长:Full GC 停顿几秒,业务就卡几秒
4. Young GC 是否过于频繁:可能是新生代太小或对象创建太快

=== jstat 实时看 GC 概况 ===
$ jstat -gcutil  1000 10
 S0   S1   E    O    M    YGC  YGCT  FGC  FGCT
 0   12.3 45.6 99.1 96.2 8821 102.3 1240 2680.5
# E=Eden O=老年代 M=元空间
# O 长期 99% + FGC 次数飞涨 = 老年代有回收不掉的对象

=== 关键判断 ===
"Full GC 频繁 + 每次回收效果差 + 老年代居高不下"
这三个一起出现,基本就能锁定:内存泄漏。
下一步就是 dump 堆,看到底是什么对象在泄漏。

修复 3:dump 堆内存 —— 找出泄漏的对象

# === 第 1 步:把堆 dump 下来 ===
# 主动 dump(只导出存活对象,文件更小)
jmap -dump:live,format=b,file=heap.hprof 

# 更推荐:配置 OOM 时自动 dump,抓现场最准
# -XX:+HeapDumpOnOutOfMemoryError
# -XX:HeapDumpPath=/path/dumps/

# === 第 2 步:用 MAT(Eclipse Memory Analyzer)分析 ===
# 打开 heap.hprof,重点看三个视图:

# 1. Histogram(直方图):按类统计对象数量和占用
#    一眼看出"哪个类的实例多到不正常"
#    比如 HashMap$Node 有几千万个 -> 某个 Map 爆了

# 2. Dominator Tree(支配树):按"谁占着最多内存"排序
#    能定位到具体哪个对象实例占了堆的一大半

# 3. Path to GC Roots:对泄漏对象,看它的引用链
#    这是【最关键的一步】—— 它告诉你:
#    这个本该被回收的对象,到底是被谁引用着的。
#    顺着引用链就能找到那个"不该存在的引用"。

# === 第 3 步:对比两次 dump,找"一直在涨"的对象 ===
# 间隔一段时间 dump 两次,对比哪些类的实例数显著增长,
# 持续增长的那个,就是泄漏的嫌疑对象。
=== 我们这次 MAT 看到的 ===
Histogram:  java.util.HashMap$Node  实例 4800 万个,占 2.9G
Dominator Tree:  一个 static 的 HashMap 占了堆的 73%
Path to GC Roots:
  这个 HashMap 是个 static 字段 -> 被类对象强引用
  -> 类对象常驻 -> Map 永不回收
  -> Map 里的 4800 万个 Entry 也跟着永不回收

结论:罪魁就是那个 static HashMap 缓存。

修复 4:最常见的泄漏 —— 缓存只进不出

// === 我们踩的坑:一个"缓存"只 put 不清理 ===
public class PriceCache {
    // static Map 生命周期 = 整个 JVM,里面的对象永不回收
    private static final Map CACHE
        = new HashMap<>();

    public static PriceInfo get(String skuId, long timestamp) {
        // 坑 1:key 里拼了 timestamp,几乎每次都是【新 key】
        String key = skuId + "_" + timestamp;
        PriceInfo info = CACHE.get(key);   // 永远命中不了
        if (info == null) {
            info = loadFromDb(skuId);
            CACHE.put(key, info);          // 坑 2:只 put,从不删
        }
        return info;
        // 结果:CACHE 无限膨胀,每个请求都往里塞新 entry
    }
}

// === 修复 1:用有容量上限、能自动淘汰的缓存 ===
// 别用裸 HashMap 当缓存。用专业缓存库,设最大容量 + 过期:
private static final Cache CACHE =
    Caffeine.newBuilder()
        .maximumSize(10_000)                     // 容量上限,超了自动淘汰
        .expireAfterWrite(Duration.ofMinutes(10)) // 写后 10 分钟过期
        .build();

public static PriceInfo get(String skuId) {
    // 修复 2:key 只用 skuId,不要拼时间戳这种高基数的东西
    return CACHE.get(skuId, PriceCache::loadFromDb);
}

// === 本地缓存的铁律 ===
// 1. 必须有容量上限(maximumSize),不能无界增长
// 2. 必须有淘汰策略(LRU / 过期),旧数据要能被清出去
// 3. key 不能是高基数、近乎唯一的值(如带时间戳、UUID),
//    否则缓存命中率为 0,还无限膨胀
// 4. 数据量大、要多实例共享时,用 Redis,别用本地 Map

修复 5:其他常见的泄漏场景

// === 1. ThreadLocal 用完不 remove(线程池场景必踩)===
private static final ThreadLocal CTX =
    new ThreadLocal<>();
public void handle(Request req) {
    CTX.set(buildContext(req));
    try {
        process();
    } finally {
        CTX.remove();   // 必须!线程池线程不销毁,
                        // 不 remove,ThreadLocal 的值就一直挂在线程上
    }
}
// 线程池里线程被复用,ThreadLocal 不清理 ->
// 旧 context 一直被线程引用着 -> 泄漏,还可能串数据。

// === 2. 监听器 / 回调注册了不注销 ===
// register 了 listener,对象销毁时忘了 unregister,
// 发布者一直持有 listener 引用 -> listener 及其关联对象泄漏。

// === 3. 没关闭的资源:流、连接、线程池 ===
// InputStream/Connection 不 close -> 关联的 native 内存/缓冲区泄漏
// 线程池 shutdown 没调 -> 线程一直在,引用链上的对象回收不掉
// 一律 try-with-resources;线程池在容器关闭时 shutdown。

// === 4. 静态集合"只增不减" ===
// static List/Map/Set 往里加元素却没有对应的移除逻辑,
// 它们的生命周期和 JVM 一样长 -> 加进去的对象永远活着。

// === 5. 字符串 / 大对象被意外长期持有 ===
// 比如把一个大 List 的某个元素塞进了一个长生命周期对象,
// 导致整个大 List 都被这一个引用拖着,无法回收(尤其老式 substring)。

// === 共同规律 ===
// 所有 Java 内存泄漏,本质都是同一句话:
// 一个【长生命周期的对象】,持有了一个【本该短命的对象】的引用,
// 让短命对象跟着它一起"长生不老"。
// 排查时,永远去找那条"不该存在的引用链"。

修复 6:内存监控告警

# JVM 内存与 GC 监控(Micrometer / jvm_exporter 指标)
groups:
- name: jvm-memory
  rules:
  # 1. 老年代使用率持续过高(泄漏的最早信号)
  - alert: JvmOldGenHigh
    expr: |
      jvm_memory_used_bytes{area="heap",id=~".*Old.*|.*Tenured.*"}
      / jvm_memory_max_bytes{area="heap",id=~".*Old.*|.*Tenured.*"}
      > 0.85
    for: 10m
    annotations:
      summary: "JVM 老年代使用率 > 85%,排查内存泄漏或堆容量不足"

  # 2. Full GC 过于频繁
  - alert: JvmFullGcFrequent
    expr: increase(jvm_gc_pause_seconds_count{action=~".*Major.*|.*Full.*"}[10m]) > 5
    for: 5m
    annotations:
      summary: "10 分钟内 Full GC 超过 5 次,排查内存泄漏"

  # 3. GC 停顿时间过长(直接影响接口 RT)
  - alert: JvmGcPauseLong
    expr: jvm_gc_pause_seconds_max > 1
    for: 5m
    annotations:
      summary: "单次 GC 停顿 > 1s,业务会明显卡顿"

  # 4. 堆内存整体接近上限
  - alert: JvmHeapNearFull
    expr: |
      sum(jvm_memory_used_bytes{area="heap"})
      / sum(jvm_memory_max_bytes{area="heap"}) > 0.9
    for: 5m
    annotations:
      summary: "JVM 堆使用率 > 90%,接近 OOM"

优化效果

指标                      治理前              治理后
=============================================================
Full GC 频率              泄漏期每分钟数次     几小时 1 次
Full GC 后堆用量          3780M(几乎不降)     1100M(回收充分)
单次 Full GC 停顿         2.1s                 0.2s
老年代使用率              长期 99%             稳定 40-55%
本地缓存                  裸 HashMap 无界      Caffeine 容量上限+过期
缓存 key                  拼时间戳,命中率 0   只用 skuId,命中率 95%+
实例周期性变慢            3-4 天复发           不再复发
接口 P99                  卡顿时 3-5s          稳定 < 200ms
内存可观测                无                   老年代/GC 频率/停顿监控

治理过程:
- 开 GC 日志 + jstat 确认是泄漏:0.5 天
- jmap dump + MAT 定位泄漏对象:1 天
- 修复缓存只进不出 + 换 Caffeine:1 天
- 排查其余 ThreadLocal/监听器泄漏点:1.5 天
- 监控接入 + 长期观察验证:1 天

避坑清单

  1. 实例周期性变慢、CPU 冲高、重启后恢复,典型是频繁 Full GC
  2. 看 Full GC 后堆降不降:降很多是容量不足,几乎不降是内存泄漏
  3. Java 内存泄漏不是忘了 free,是对象还被引用着、GC 判定它可达而不回收
  4. 一定要开 GC 日志,它是排查的第一手证据,jstat 看 GC 概况
  5. jmap dump 堆 + MAT 分析,Path to GC Roots 能找出不该存在的引用链
  6. 最常见的泄漏是缓存只进不出,本地缓存必须有容量上限和淘汰策略
  7. 缓存 key 不能用带时间戳/UUID 的高基数值,否则命中率为 0 还无限膨胀
  8. 线程池场景 ThreadLocal 必须在 finally 里 remove,否则值挂在复用线程上
  9. 监听器注册了要注销,流/连接/线程池用完要关,静态集合要有移除逻辑
  10. 泄漏本质是长生命周期对象持有了短命对象,监控老年代使用率最早预警

总结

这次 JVM 内存泄漏的排查,最值得记下来的是一套可以反复套用的方法论。事故的表象很有迷惑性:服务每隔三四天就有实例变慢、CPU 冲高,然后又自己恢复,重启则立刻见效——这种"周期性发作、重启即愈"的特征,几乎就是内存泄漏写在脸上的签名,因为重启把堆清空了,泄漏的对象被一次性抹掉,于是又能稳定运行几天,直到泄漏物再次把堆填满。排查的第一步永远是 GC 日志,它是最不会骗人的第一手证据。而 GC 日志里最关键的一个信号,是 Full GC 之后堆内存到底降了多少:如果一次 Full GC 把堆从将近 4G 收回到了 1G 多,那说明 GC 是有效的,问题可能只是堆配小了,属于容量问题;但如果像我们这样,Full GC 折腾了两秒,堆却只从 3801M 降到 3780M,只挤出来可怜的 21M,那就说明堆里绝大多数对象 GC 根本动不了——它们明明逻辑上已经没用了,却因为还被某条引用链连着,被 GC 判定为"仍然可达、仍在使用",于是怎么也回收不掉。这就是 Java 内存泄漏的本质,它和 C 语言里"忘记 free"的泄漏是两码事:Java 有垃圾回收,它的泄漏从来不是"没人回收",而是"有一个不该存在的引用,死死拦着不让回收"。一旦确认是泄漏,下一步就是把堆 dump 下来,用 MAT 这样的工具去分析,而 MAT 里最有价值的功能,是对可疑对象去看它的"Path to GC Roots",也就是把那条让对象无法被回收的引用链完整地揪出来——这条链的源头,就是那个"不该存在的引用"。我们这次顺着链一查,源头是一个 static 修饰的 HashMap,它被当作本地缓存用,但写法上犯了两个致命错误:一是它只往里 put、从来没有任何清理逻辑,是个彻头彻尾的无界容器;二是它的 key 里拼接了时间戳,导致几乎每一个请求进来都是一个全新的 key,缓存永远命中不了,于是每个请求都老老实实地往这个 Map 里又塞一条新记录。一个永不淘汰、又持续被高速写入的 static Map,它的结局是注定的——把整个堆吃光。修复它本身不难,换成 Caffeine 这种成熟的缓存库,设上容量上限和过期时间,再把 key 里那个高基数的时间戳去掉就行。但这次复盘真正沉淀下来的,是对"长生命周期对象"的一种警觉。回过头看 Java 里几乎所有的内存泄漏——无界的静态集合、线程池里没有 remove 的 ThreadLocal、注册了却没注销的监听器、忘了关闭的资源——它们可以用同一句话概括:一个生命周期很长的对象,不小心抓住了一个本该很快死去的对象的引用,把后者也拖成了"长生不老"。所以从此以后,每当我写下 static、写下往一个长期存在的容器里 add 元素、写下 ThreadLocal.set,我都会多停留一秒,问自己一个问题:我放进去的这些东西,将来由谁、在什么时候、用什么逻辑,把它们清理出去?如果这个问题我答不上来,那这里大概率就埋着下一个内存泄漏。

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

服务假死数据库却很闲:一次连接池耗尽排查的复盘

2026-5-20 13:41:27

技术教程

Executors 埋的雷:一次线程池配置踩坑的复盘

2026-5-20 13:46:40

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