生产环境接口突然变慢、CPU 飙高,top 一看竟是 JVM 在疯狂 Full GC。Full GC 会触发 Stop-The-World,所有业务线程暂停,单次停顿几百毫秒到几秒,积累起来接口 P99 直接崩盘。很多人第一反应是”堆不够大,加内存”,但加内存往往只是把崩溃推迟几小时,因为真正的病灶是对象本该被回收却始终被引用拽着,堆越大单次回收越慢,反而雪上加霜。本文用 jstat 抓取 GC 现场,再用 MAT 打开堆转储,一步步定位 Java 内存泄漏根因,并给出可直接照做的修复清单。
一、先确认:到底是 Full GC 还是别的
不要一上来就 dump 堆。先用 jstat 看 GC 频率与耗时,确认是 Old 区回收不掉导致的 Full GC,而不是普通 Young GC 抖动或业务自身慢。拿到 PID 后持续采样:
jstat -gcutil $(pgrep -f 'your-app.jar') 1000 10
# 再配合 GC 日志,拿到带时间戳的停顿记录
java -Xlog:gc*,gc+heap=debug:file=/var/log/app/gc.log:time,uptime,level,tags -jar your-app.jar
| 列 | 含义 | 异常信号 |
|---|---|---|
| O | Old 区使用率 | 持续 >90% 且掉不下来 |
| FGC | Full GC 次数 | 短时间内快速增长 |
| FGCT | Full GC 总耗时(秒) | 单次耗时长、占比高 |
| GCT | GC 总耗时 | 接近进程运行时间 |
若 O 区居高不下 + FGC 飙升,基本可以锁定是内存泄漏(对象回收不掉),而不是单纯的吞吐压力。这里要区分两种”老年代增长”:正常的缓存预热或批量任务会让 Old 区短暂升高,但随着请求结束、引用释放,它会自然回落;而泄漏的特征是只涨不跌——每次 GC 后 Old 区水位都比上一轮更高,FGC 间隔越来越短,直到频繁 Full GC 把吞吐吃光。看到”锯齿形上升”的 O 区曲线,就可以判定泄漏。此时再去抓堆,才有的放矢。
二、抓取堆现场:两种姿势
2.1 主动 jmap 抓取
# live 只 dump 存活对象,文件更小、更快
jmap -dump:live,format=b,file=/tmp/heap_$(date +%s).hprof <pid>
注意 jmap -live 本身会触发一次 Full GC 来回收死对象,对在线服务有秒级 STW;大堆(几十 GB)慎用,必要时配合 jstack 同时抓线程,看是不是线程堆积间接拖垮了内存。
2.2 OOM 自动转储(推荐常驻)
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/log/app/heap.hprof
-XX:OnOutOfMemoryError="kill -9 %p"
| 方式 | 对业务影响 | 适用场景 |
|---|---|---|
| jmap 主动 dump | 触发一次 Full GC,STW 几百毫秒~秒级 | 在线诊断、问题可复现 |
| OOM 自动 dump | 几乎无感知,仅在崩溃时落盘 | 生产常驻、兜底取证 |
三、用 MAT 打开堆转储,三步走
MAT(Memory Analyzer Tool)是分析 hprof 的标配。打开后会自动给出 Leak Suspects 报告,但更可靠的是手动三步,避免被误报带偏:
- Leak Suspects Report:一键列出可疑对象与占用概览。
- Dominator Tree:支配树,看哪些对象”霸占”了最多保留堆(Retained Heap)。
- Path to GC Roots → exclude weak/soft/phantom:只看强引用链,定位谁在死死拽住大对象。
也可以直接用 OQL 捞大对象,按保留大小排序:
SELECT o, o.@retainedHeapSize
FROM java.util.HashMap o
ORDER BY o.@retainedHeapSize DESC
| 视图 | 用途 |
|---|---|
| Leak Suspects | 快速看嫌疑对象 |
| Dominator Tree | 看谁占着最多保留堆 |
| Histogram | 按类名聚合实例数与占用,关注自己包名下的类 |
| Path to GC Roots | 顺强引用链找到”元凶” |
四、案例一:静态缓存只增不减
某接口用静态 Map 做本地缓存,computeIfAbsent 只塞不删,流量一高 Old 区很快打满,FGC 每分钟几十次:
private static final Map<String, Object> CACHE = new ConcurrentHashMap<>();
void load(String key){
// 只增不减,key 无限膨胀 → 内存泄漏
CACHE.computeIfAbsent(key, k -> expensiveQuery(k));
}
MAT 里 Dominator Tree 会显示这个 ConcurrentHashMap 保留了近 1.2GB,Path to GC Roots 一路指到静态字段。这种”静态集合只塞不删”是生产最常见的泄漏形态:本地缓存、会话表、去重 Set,只要用 static 修饰又没淘汰策略,流量一涨就爆。修复方法是换成有上限、有 TTL 的缓存:
// 用 Caffeine 兜底容量与过期,杜绝无限增长
LoadingCache<String, Object> CACHE = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofMinutes(30))
.build(key -> expensiveQuery(key));
五、案例二:ThreadLocal 在线程池里泄漏
另一个高频雷区是 ThreadLocal 在线程池里忘了 remove。线程被池子复用,第一次写入的大对象会一直挂在那个线程上,请求越多泄漏越多:
private static final ThreadLocal<byte[]> BUF =
ThreadLocal.withInitial(() -> new byte[1024 * 1024]);
void handle(Request req){
BUF.get(); // 每次写入 1MB,线程池复用时永不释放
}
正确写法必须放在 try/finally 里清理,让线程归还池子前释放引用:
void handle(Request req){
try {
BUF.get();
// ... 业务处理
} finally {
BUF.remove(); // 必须清理,否则线程池线程长期持有
}
}
六、修复后验证
复测时同时看 jstat 与 GC 日志,对比修复前后的关键指标:
jstat -gcutil <pid> 1000
# 观察 FGC 是否停止增长,O 区是否回落到正常水位
| 指标 | 修复前 | 修复后 |
|---|---|---|
| FGC 频率 | 每分钟 30+ 次 | 0(仅老年代真正满才触发) |
| 单次停顿 | 300~800ms | <50ms |
| O 区水位 | 持续 95%+ | 稳定在 40% 左右波动 |
| 接口 P99 | 2s+ | 80ms |
复测后 FGC 归零、O 区稳定回落、接口 P99 恢复正常,说明泄漏已根治。上线前用 JMH 微基准 压一压,能在早期暴露 Old 区增长趋势,而不是等线上告警。
七、生产环境内存泄漏检查清单
| 项 | 动作 |
|---|---|
| 兜底取证 | 常驻开启 HeapDumpOnOutOfMemoryError + 固定 dump 路径 |
| 缓存 | 一律用 Caffeine/Guava 带上限与过期,禁止裸 Map 当缓存 |
| ThreadLocal | 必须 try/finally remove,尤其在线程池环境 |
| 监听器/回调 | 用完及时 remove,避免静态集合持有外部对象 |
| 上线前 | 压测观察 Old 区,参考 Spring Boot 3 升级、Java 21 虚拟线程 |
| 回归 | CI 用 GitHub Actions 跑内存回归,结合 Spring Security 鉴权链路 |
八、常见误区
- 误区一:看到 Full GC 就调大堆。堆越大,单次停顿越久,泄漏只是被推迟。
- 误区二:只看 Young GC。Old 区缓慢增长才是泄漏的典型特征。
- 误区三:换 GC 器(ZGC/G1)能根治。GC 器只管回收,管不了你攥着不放的引用。
- 误区四:靠重启续命。重启只是清空现场,问题必然复现。
九、MAT 之外:命令行快速初判
在来不及下载 MAT、或堆文件太大(几十 GB)打不开时,可以先在命令行做初步判断。jmap -histo:live 能按类统计实例数与占用,一眼看出是哪个类异常膨胀:
jmap -histo:live <pid> | head -30
# 关注排在前几位的、自己包名下的类,而不是 java.lang.String 这类正常大户
如果想看”对象是在哪里分配的”,可以用 async-profiler 的 alloc 模式采样,直接定位到分配热点代码行。另有一个常被忽略的技巧:把 hprof 丢到 Eclipse MAT 的无头模式(ParseHeapDump.sh)跑,能自动产出 Leak Suspects 报告再下载查看,省去本地拉大文件的麻烦。无论用哪种工具,思路都是一致的——先按”谁占着最多内存”排序,再顺引用链找到那个不该被长期持有的根。
十、把它变成工程习惯
单次排查能救一次火,但真正降本的是把内存安全变成工程默认项:新服务模板就自带 HeapDumpOnOutOfMemoryError;缓存组件统一封装,禁止业务直接 new 一个裸 Map 当缓存;Code Review 把”ThreadLocal 是否 remove””集合是否上限”列为必查点;压测脚本把 Old 区水位作为通过门槛。当这些成为肌肉记忆,Full GC 引发的线上事故会从”每月一次”降到”几乎为零”。
Full GC 频繁往往是”症状”,内存泄漏才是”病根”。拿到堆转储、用 MAT 顺藤摸瓜,大多数泄漏都能在半小时内定位。把它变成上线前必查项,比线上救火省心得多。




