Java 内存泄漏排查复盘:从堆飙升到根因定位

内存泄漏是 Java 线上最隐蔽的一类故障。它不像 CPU 飙高那样立刻报警,而是让老年代堆内存缓慢、不可逆地增长,直到某次 Full GC 也收不回空间,最终抛出 OutOfMemoryError: Java heap space,把进程拖垮。本文复盘一次真实的生产排查:从监控告警发现堆曲线异常,到用 jmap/heapdump 保留现场,再用 MAT 顺着对象引用链锁定「谁拽着内存不放」,最后定位到一行被忽视的代码并根治。整个过程不靠猜,全靠现场数据——这和用 Arthas 做线上不停机诊断的思路一脉相承。

一、为什么内存泄漏最容易被忽视

很多人把「内存泄漏」和「内存溢出」混为一谈,其实二者差别很大。内存溢出(OOM)可能是一次流量尖峰把堆打满,问题一过就恢复;而内存泄漏是对象明明已经用完,却因为还有引用被持有,导致 GC 永远回收不掉。它像慢性中毒:前几个小时一切正常,监控曲线只是轻微上扬,没人当回事,直到某天凌晨堆彻底涨满,服务雪崩。

判断要点很简单:GC 之后老年代使用率不回落,反而逐次抬高,就是泄漏的典型信号。关于 Full GC 频次与回收效果的判读,可以对照JVM 内存模型与 GC 调优实战那篇的基础指标。

二、监控先说话:从三条曲线看异常

故障不是从报警那刻才开始的,数据早有预兆。下面这张表是我们在 Grafana 上重点盯的三条曲线,以及它们各自说明什么:

监控指标异常形态它说明什么
老年代(Old)使用率锯齿形上移,每次 GC 谷底越来越高有对象持续堆积、回收不掉,强烈指向泄漏
Full GC 频次从几小时一次,恶化到几分钟一次回收越来越频繁但回收量越来越小
单次 Full GC 后回收量呈下降趋势,后期接近 0堆里存活对象占比逼近 100%,随时 OOM

一个经验法则:只要「老年代谷底逐次抬高」持续三四个 GC 周期,就可以基本确认是泄漏,不必等 OOM 真的发生再动手。越早介入,保留的现场越干净,定位越快。

三、止血第一步:先保住现场,别急着重启

线上内存涨满时,运维和开发的本能反应是「重启大法好」。但这恰恰是最错误的做法:重启会把堆里所有对象清零,现场彻底丢失,问题再也无法在测试环境复现,下次还爆。正确顺序是先观察、再留证、最后才处理。

# 1) 看各内存区实时用量(不用重启,秒级)
jmap -heap <pid>

# 2) 看 GC 回收效率(每 1 秒采样一次,连续 10 次)
jstat -gcutil <pid> 1000 10

# 3) 抓线程栈,排查是否有线程暴涨堆积任务
jstack <pid> > /tmp/jstack.txt

# 4) 关键:保留堆现场(live 只转储存活对象,体积更小)
jmap -dump:live,format=b,file=/tmp/heap.hprof <pid>

# 容器/K8s 环境:先进容器再 dump,或拷出来
kubectl exec <pod> -c <container> -- jmap -dump:live,format=b,file=/tmp/heap.hprof 1
kubectl cp <pod>:/tmp/heap.hprof ./heap.hprof

纪律红线:OOM 前一定要先把 heap.hprof 落盘,再谈重启。这个 dump 文件是后面所有分析的唯一依据。如果进程已经因 OOM 退出,也可以在启动参数里加 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/heap.hprof,让 JVM 在崩溃瞬间自动留证。

四、MAT 分析:找到「占用最大的对象」

拿到 heap.hprof 后,用 Eclipse MAT(Memory Analyzer)打开。新手最容易陷进「逐个看对象」的误区,其实只需三步就能锁定元凶:

1. Dominator Tree:谁占着最多的「保留内存」

Dominator Tree 按 Retained Heap(被该对象支配、一旦回收就能释放的总内存)排序。排在最前面的一两个类,通常就是泄漏的容器。比如看到某个 HashMap 的 Retained Heap 占了堆的 70%,那它下面一定装着不被期望的大对象。

2. Histogram:按类统计实例数与占用

如果你心里已经有怀疑对象(例如某个缓存 Map),直接用 Histogram 按类名过滤,看它的实例数量和 Shallow/Retained Heap 是否异常膨胀。

3. 顺引用链追到 GC Roots

找到大对象后,右键 Path to GC Roots → exclude weak/soft/phantom references。泄漏之所以回收不掉,就是因为有一条强引用链连到了 GC Roots(静态变量、活动线程、JNI 等)。排除掉弱/软引用后,剩下的强引用链就是「凶手」。

-- MAT 的 OQL(对象查询语言)示例:找出某缓存里实例数异常的键
SELECT * FROM com.example.order.OrderCache
WHERE toString(this).length() > 0

-- 统计某个类的实例总数与总占用
SELECT count(*) AS instances, sum(shallowSize) AS bytes
FROM java.util.HashMap$Node
WHERE toString(classof(this)) LIKE ".*OrderCache.*"

-- 找出被 ThreadLocal 持有的大对象(泄漏高发区)
SELECT * FROM java.lang.ThreadLocalMap$Entry
WHERE value instanceof com.example.biz.ReportBuffer

顺着 GC Roots 引用链一层层展开,常常会在某个 static Map 或线程的 threadLocals 字段上看到那个不该存在的引用——这一刻,根因就水落石出了。

五、三种最常见的泄漏根因

复盘了十几起内存泄漏后我们发现,根因高度集中在三类代码模式上。下面这张对照表可以直接拿来当 Code Review 的「黑名单」:

根因模式典型代码修复方向
静态集合当缓存不淘汰static Map<K,V> cache = new HashMap<>(),只 put 不 remove换用带上限的 LRUMap / Caffeine,或落地到Redis 这类外部存储
ThreadLocal 用完不 remove线程池复用线程,set 后忘记 remove,对象随线程存活try-finallyremove(),或改用弱引用封装
资源/流未关闭InputStream、数据库连接、缓冲未 close一律 try-with-resources,禁止裸 new

六、定位到代码:一个生产级反例

本次复盘的根因,是「ThreadLocal + 线程池」组合的经典坑。服务用线程池处理请求,每个请求把一个大报表缓冲塞进 ThreadLocal 方便后续方法取用,却只在正常分支里清理,异常分支漏了 remove()。线程被池子复用后,上一个请求的缓冲区就一直挂在那个线程上,请求量越大泄漏越快。

// 反例:异常分支漏掉 remove,线程池复用导致泄漏
private static final ThreadLocal<ReportBuffer> BUF = new ThreadLocal<>();

void handle(Request req) {
    BUF.set(new ReportBuffer(1024 * 1024)); // 每次 1MB 缓冲
    try {
        doBusiness(req);                    // 若这里抛异常...
    } finally {
        BUF.get().writeToDb();             // 且这里也抛异常
    }
    BUF.remove();                          // 永远执行不到
}

修复方式朴素而有效:把 remove() 放进 finally 的第一行,保证无论成败都清理;同时给缓冲加一个全局上限,兜底防住任何遗漏:

// 修复:finally 中先 remove,且用带上限的缓存兜底
void handle(Request req) {
    BUF.set(new ReportBuffer(1024 * 1024));
    try {
        doBusiness(req);
    } finally {
        BUF.remove();                      // 第一优先:无条件清理
        BUF.set(null);
    }
}

// 全局兜底:用 Caffeine 替代裸 static Map,写入即计容量
private static final Cache<String, Report> CACHE =
    Caffeine.newBuilder().maximumSize(10_000).expireAfterWrite(10, TimeUnit.MINUTES).build();

七、根治与验证:压测 + 回归用例

改完代码不等于结束。我们做两件事确认真的修好了:一是针对性压测,用接近生产的并发持续跑几小时,观察老年代曲线是否重新变得平稳;二是补一条回归用例,模拟「长期运行 + 异常路径」让泄漏提前暴露,作为以后 CI 的护栏。如何写出可信赖的测试,可以参考Java 单元测试实战:JUnit 5 与 Mockito 指南

# 压测期间打开 GC 日志,事后分析回收效果
java -Xlog:gc*,gc+heap=debug:file=/data/gc.log:time,uptime,level,tags      -jar order-service.jar

# 用 JMeter / wrk 打持续流量,重点看堆是否不再单调上升
wrk -t8 -c200 -d7200s http://localhost:8080/api/order

# 验证点:jstat 老年代谷底不再逐次抬高,Full GC 后回收量恢复

把「构建产物 + 压测脚本 + 部署」串进流水线,让每次发版都自动跑一遍回归,是避免同类问题回潮的关键。这部分交给GitHub Actions CI/CD 实战里的流水线去兜底最合适。

八、把这次故障沉淀成防线

一次排查的价值,远不止修好那一行代码。我们用 Postmortem 复盘把过程写成了团队资产:告警阈值的设定(老年代谷底连续三次抬高即触发)、Code Review 的黑名单(裸 static 集合、ThreadLocal 无 remove)、以及一条永远生效的护栏——任何本地缓存必须用带容量上限的实现。故障不可怕,可怕的是它第二次以同样的方式发生。

最后送你一份肌肉记忆清单:堆曲线异常别重启 → 先 jmap 留 dump → MAT 看 Dominator Tree 和 GC Roots 引用链 → 定位 static/ThreadLocal/未关流 → 修复后压测 + 补用例。等下次凌晨告警响起,你要做的只是按这套流程走,而不是对着监控干瞪眼。

上一篇 技术人个人知识管理实战:告别收藏夹吃灰
下一篇 Linux 内核参数调优:高并发服务器 sysctl 实战