Full GC 排查实战:MAT 定位 Java 内存泄漏

生产环境接口突然变慢、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
列含义异常信号
OOld 区使用率持续 >90% 且掉不下来
FGCFull GC 次数短时间内快速增长
FGCTFull GC 总耗时(秒)单次耗时长、占比高
GCTGC 总耗时接近进程运行时间

若 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% 左右波动
接口 P992s+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 顺藤摸瓜,大多数泄漏都能在半小时内定位。把它变成上线前必查项,比线上救火省心得多。

上一篇 direnv 实战:用 .envrc 实现项目级环境隔离
下一篇 Drizzle ORM 实战:类型安全的 SQL 数据库操作
最新评论
Docker 网络模式详解:bridge/host 生产选型 - FSData
Docker 网络模式详解:bridge/host 生产选型 - FSData
8月20日
[…] 注意最后那条:把端口绑定到 127.0.0.1,再由宿主机上的 Nginx 反代进来,比直接 -p 80:80 暴露安全得多,也方便统一做
Vue3 + TypeScript 工程化:类型安全的前端架构 - FSData
Vue3 + TypeScript 工程化:类型安全的前端架构 - FSData
8月20日
[…] Pinia 的工程化用法,可以参考我们之前的实战文章 《Vue3 组合式 API + Pinia:状态管理从入门到实战》,那里覆盖了 Sto
2026 年最值得装的 10 个 VS Code 插件(AI/前端/后端/效率全覆盖) - FSData
2026 年最值得装的 10 个 VS Code 插件(AI/前端/后端/效率全覆盖) - FSData
8月19日
[…] 状态,点一下就能起停容器,配合我们讲过的 Docker Compose 编排 […]
Redis 缓存设计:穿透、击穿、雪崩与最佳实践 - FSData
Redis 缓存设计:穿透、击穿、雪崩与最佳实践 - FSData
8月19日
[…] 缓存三大坑的本质都是”缓存失效导致请求穿透到数据库”。记住口诀:穿透用布隆、击穿用锁、雪崩用错峰。配合合理的过期策略
GitHub Actions 实战:从零搭建 CI/CD 流水线 - FSData
GitHub Actions 实战:从零搭建 CI/CD 流水线 - FSData
8月19日
[…] Docker 镜像部署。结合我们之前写的 Docker Compose 一键编排实战,可以在 CI […]