拓冰建站拓冰建站
首页 / 资讯中心 / 正文

JVM内存泄漏排查实战:用MAT从堆转储到GC Roots定位根因

做后端服务久了一定碰到过这种场景接口响应一天比一天慢监控曲线跟爬坡似的往上走日志里堆满了 Full GC 告警最后在某个凌晨服务直接 OOM 躺平。这时候把堆转储文件导出来接上 MATMemory Analyzer Tool一查往往能发现是某个集合类一直在膨胀或者某个全局缓存没设上限。这篇文章就来完整复盘我用 MAT 工具链排查内存泄漏的全套流程从堆转储怎么导出、MAT 怎么配置到 Leak Suspects 报告怎么读、Histogram 和 Dominator Tree 怎么交叉验证最后一路追到 GC Roots 锁定根因。适合被线上 OOM 搞到头大的后端开发也适合想系统掌握 JVM 内存分析方法论的性能工程师和运维同学。保证每一步都来自真实踩坑不是照着文档念。1. 先分清内存泄漏、内存溢出与增长过快1.1 三类内存问题的本质区别很多人一看到 OOM 就说“内存泄漏”其实这俩不能直接画等号。内存溢出OutOfMemoryError只是一个结果它可能是泄漏导致的也可能只是对象分配速率太快、或者一次性加载了太多数据。如果定位方向错了后面所有操作都会被带偏。我习惯把内存问题分成三类内存泄漏对象本来应该被回收但因为存在强引用链GC 一直回收不掉。典型特征是内存占用随运行时间持续上升就算 GC 也无法回落到初始水位。内存溢出堆大小被撑爆。可能是泄漏累积的结果也可能是某次请求一次性分配了几 GB 大对象比如把一个超大文件读进内存。内存增长过快没有泄漏但业务流量上来后对象堆积速度超出 GC 回收能力。典型特征是内存曲线整体上移但每次 GC 后能明显回落。区分它们有个很直观的手段连着做几次 Full GC比如通过 jmap 触发或者用 jcmd 执行System.gc()然后看存活对象大小。如果 GC 后内存能回落到正常水平那大概率不是泄漏而是分配速率问题如果 GC 后老年代占用依然居高不下那泄漏嫌疑就很大。千万别一上来就 dump先把问题定性后面才能精准下手。1.2 现场保留如何正确导出堆转储文件确认疑似泄漏后第一件事不是拿 MAT 分析而是把现场保留下来。堆转储文件Heap Dump就是分析内存泄漏最核心的证据拿不到一份干净的 dump后面全是白干。导堆转储有几种方式适用场景完全不同导出方式命令示例适用场景注意事项OOM 自动导出JVM 参数-XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/data/dump/生产环境首选必须提前配置OOM 发生时自动生成现场最真实jmap 手动导出jmap -dump:live,formatb,fileheap.hprof 12345内存还在涨但没 OOMlive会先触发 Full GC生产高峰谨慎jcmd 导出jcmd 12345 GC.heap_dump /data/dump/heap.hprof较新 JDK 推荐新版 JDK 里 jmap 逐渐被边缘化jcmd 是趋势MAT 内联导出MAT 直接连远程 JMX 导出小堆、测试环境生产环境不建议性能开销大这里有个我踩过的坑jmap -dump:live的live参数会强制触发一次 Full GC。如果你在业务高峰期执行应用会明显卡顿严重的直接导致超时雪崩。所以这个命令最好在低峰期用或者干脆用不带live的 dump把存活和待回收对象一起导出来MAT 里再手动过滤。另外生产环境的 JVM 参数里一定要提前加上-XX:HeapDumpOnOutOfMemoryError。不配的话OOM 一发生现场被回收得一干二净排查只能靠猜。我见过很多服务连这个参数都没开OOM 后只剩一张监控截图完全无从下手。导出来的 hprof 文件通常很大4GB 堆可能生成 4~6GB 的 dump 文件传输和存储都不方便。建议导完立刻 gzip 压缩一般能压到原来的 1/5 到 1/10MAT 本身也支持直接打开.hprof.gz压缩格式不用解压。2. MAT 工具链的安装与基础配置2.1 下载独立版本还是 Eclipse 插件版MAT 的全称是 Eclipse Memory Analyzer Tool虽然它头顶着 Eclipse 的名号但我强烈建议下载独立 RCP 版本不要用 Eclipse 插件版。独立版本是一个打包好的完整程序解压就能用启动快、不依赖 Eclipse 环境。插件版要装到 Eclipse 里每次启动还要带着整个 IDE加载大堆文件时又慢又容易卡死我试过一次就再也没碰过。下载的时候注意看版本和运行环境。MAT 从 1.15 版本之后基本要求 64 位 JDK 和 64 位操作系统如果你的堆转储文件来自 64 位 JVM而 MAT 跑在 32 位系统上那基本废了内存上限顶到 2GB 根本不够用。还有一个小提醒MAT 分析的是 JVM 堆转储文件一般以.hprof结尾也有人叫它 Heap Dump 文件。搜索引擎里经常有人把 MATLAB 的.mat数据文件和这个搞混两个完全不同的东西这个工具链只针对 JVM 内存分析场景。2.2 调大解析内存别让工具先挂掉安装完 MAT 第一件事不是打开 hprof而是改配置文件。MAT 默认的堆内存只有 1024MB这个大小打开稍微大一点的 dump 文件还没等分析界面出来就报Java heap space直接崩溃。配置文件在安装目录下的MemoryAnalyzer.ini里面长这样-startup ../eclipse/plugins/org.eclipse.equinox.launcher_1.6.400.v20210924-0641.jar --launcher.library ../eclipse/plugins/org.eclipse.equinox.launcher.win32.win32.x86_64_1.2.500.v20210924-0641.jar -vmargs -Xmx1024m把最后那行-Xmx1024m改大比如-Xmx4096m或者-Xmx8192m。具体设多少取决于你的 dump 文件大小和本机内存。我的经验是4GB 的堆转储MAT 自己至少要有 3~4GB 可用堆8GB 的堆转储MAT 的-Xmx最好给到 6~8GB。原则就是宁可多给不要委屈它因为解析索引、构建支配树这几个阶段都极其吃内存。如果你有多台机器可选优先选内存大的那台来做分析。曾经在一个 8GB 内存的笔记本上开 6GB 的 hprof整个系统卡成幻灯片换到 32GB 内存的台式机几分钟就出结果了体验天差地别。2.3 打开 hprof 前需要理解的几个核心概念在用 MAT 之前有两个概念必须弄清楚否则后面看报表完全抓瞎浅堆和保留堆。浅堆Shallow Heap是对象自身占用的内存大小不包含它引用的其他对象。比如一个HashMap实例本身可能只有几百字节浅堆那栏显示几百。保留堆Retained Heap是指如果这个对象被回收了随之一起被回收的所有对象所占的内存总和。这俩的关系可以这么理解浅堆是行李箱本身的重量保留堆是行李箱加上里面所有行李的总重量。排查内存泄漏时我们真正关心的是保留堆。因为泄漏的本质是一大堆本该死掉的对象被一个活着的对象“牵制”住分析时找的就是那些“牵制力”最大的对象。保留堆越大说明它拽住的对象越多释放它能回收的内存也越多。打开 hprof 文件时MAT 会弹出一个窗口问你要不要生成 Leak Suspects Report同时还有几个选项可以勾选。如果你只需要快速看嫌疑报告保持默认直接 Finish 就行。如果是超大文件可以考虑先不勾选“Calculate precise retained sizes”计算精确保留大小那样会快不少代价是部分视图的保留堆数值是估算的但对定位大问题影响不大。3. 第一张牌Leak Suspects 自动报告怎么读3.1 自动报告在哪看MAT 最友好的地方就是它打开堆转储文件后会自动生成一份 Leak Suspects 报告也就是“泄漏嫌疑报告”。这份报告相当于工具先帮你做了一轮粗筛把所有对象按保留堆排了个序然后把最可疑的候选者摆在你面前。如果你在打开文件时跳过了自动生成也可以手动生成点击菜单栏的Reports - Leak SuspectsMAT 会重新跑一遍分析流程。时间长短取决于堆大小快则几秒慢则几分钟。报告打开后你会看到左边是一个 Problem Suspects 列表列出了几个疑似泄漏点右边是每个嫌疑的详细说明一般会用一大段文字描述“某个类加载器加载的 X 类持有了大量 Y 对象占据了堆的百分之多少”。同时还有一张饼状图按线程或类库展示内存分布。3.2 教你拆解一份真实报告我之前排查过的一个实际案例一个查询服务运行两天后 OOMLeak Suspects 报告第一条这样写的org.hibernate.internal.SessionImpl的实例数量达到数万个共占用约 2.1GB 内存占据了整个堆的 68%并且报告提示这些实例被ThreadLocal引用。这句话信息量很大拆开来看SessionImpl是 Hibernate 的会话对象正常情况下用完就该关闭。实例数量达到数万个说明有很多会话没被释放。提示被ThreadLocal引用这是关键线索——ThreadLocal 是线程私有变量如果线程是长期存活的比如 Tomcat 的工作线程那么 ThreadLocal 里存的 Session 就永远无法被回收。顺着这个线索我打开线程视图一看果然每个 Tomcat 工作线程的 ThreadLocal 里都挂着一个未关闭的 Session。最后定位到业务代码里一个事务方法在异常路径上没有正确回滚和关闭会话导致 Session 对象被 ThreadLocal 一直攥着。修复代码后内存曲线很快就平了。3.3 为什么不能全信这份报告Leak Suspects 报告虽然好用但它有一个致命局限它只是按“保留堆大小”找出最大的几个对象然后给出一些自动推断。这很容易掩盖两种真实情况。第一种最大的对象不是泄漏点而是一个正常的全局缓存。我遇到过一个大 Map 占了堆的 40%报告把它标为头号嫌疑。但仔细看业务逻辑这个 Map 本来就是设计来常驻内存的配置缓存真正的问题是另一个小对象不断往这个 Map 里塞新 key导致它无限膨胀。报告只看到 Map 很大看不到背后的业务逻辑这时候就要靠 Histogram 和支配树去追根溯源。第二种多个小泄漏点叠加单看任何一个都不明显。Leak Suspects 会把最显眼的那个点标为 Head但次要嫌疑可能才是真正需要一起处理的。所以我通常把 Leak Suspects 当作第一张牌、一个入口而不是最终结论。真正的关键证据还得靠下面两张牌交叉验证。4. 第二张牌Histogram 大对象清单逐层过滤4.1 按保留大小排序找出大头Histogram 是 MAT 里最朴素也最常用的视图它的本质是一张按类统计的对象清单列出每个类的实例数量、浅堆总和、保留堆总和。打开方式很简单点击工具栏的Histogram图标或者从Window - Open Perspective - Other - Histogram进入。打开之后直接把列表按Retained Heap列降序排序。这时候你看到的就是整个堆里“哪个类的对象加起来占内存最多”。大部分情况下Top 10 就能覆盖 80% 以上的堆内存。记得先把分组方式切一下。Histogram 默认按类分组但有些时候按包名对比更直观右键选择Group by - Package可以快速看出内存主要集中在哪个业务包里。如果线上用的框架类一眼就看到是 Hibernate 的 Session 还是 Netty 的 ByteBuf定位速度能快不少。4.2 从常见大对象反推业务场景Histogram 列出来的对象名很多一眼就能看出对应的业务场景。我总结了一些常见的大对象类型和它们对应的典型问题常见大对象典型问题场景char[]大量字符串被缓存、SQL 拼接、日志内容积压byte[]IO 缓冲区、网络传输数据、图片/文件内容加载HashMap$Node[]哈希表类集合容量过大、Map 无限增长ArrayList/Object[]列表类数据集合持续追加、分页查询一次加载太多java.lang.ThreadLocal相关ThreadLocal 值未清理SessionImpl/EntityManagerORM 会话未关闭举个例子有一次我拿到一个 dumpHistogram 里char[]占了近 60% 的堆。点进char[]看引用发现大部分被一个叫Logger的并发队列引用。原来业务代码里把一条可能非常长的 SQL 拼到日志里还用了异步日志框架结果高并发下这个日志事件队列不断积压最终把堆吃满。这个场景不看对象引用链几乎不可能定位到。4.3 用 Merge Shortest Paths 快速收窄范围Histogram 只能告诉你“哪个类占内存多”但还没有告诉你“谁把它引用了”。这时候需要用一个利器右键点击某个类选择Merge Shortest Paths to GC Roots - exclude all phantom/weak/soft etc. references。这句话有点长它的意思翻译过来就是帮我找一条从 GC Root 到这个类对象的引用链路但把弱引用、软引用、虚引用都排除掉只看强引用链。为什么要排除因为弱引用和软引用本来就不阻碍 GC理论上可以被随时回收排查强引用时代它们只会干扰视线。执行之后MAT 会以目录树的形式展示出谁是真正的“持有者”。我习惯一层一层展开直到看到业务类为止。如果最后一层停在某个熟悉的 service 类或 controller 类那基本就破案了——去翻对应代码里有没有把对象存进静态变量、ThreadLocal 或者集合里。5. 第三张牌Dominator Tree 与 GC Roots 引用链追凶5.1 支配树在泄漏分析中到底有什么用Dominator Tree支配树是 MAT 里最有“技术含量”的一个视图很多新人一看名字就怕其实理解起来没那么复杂。支配树做的是这么一件事把对象的引用关系重新组织成一棵树在这棵树里如果一个节点 A 支配节点 B就表示 B 对象只要还活着A 对象就不可能被回收。用大白话说支配树帮我们找到了每个对象的“生死开关”。A 就是 B 的支配者——B 能不能活A 说了算。实际分析中我通常直接在 Dominator Tree 视图下按Retained Heap排序看排在前面的是谁。排在最前面的那些对象往往就是整个堆里最大的“内存聚合点”。如果这个聚合点背后有业务类在支配嫌疑一下子就锁定了。5.2 Path to GC Roots一条命令锁定强引用Dominator Tree 找到嫌疑对象后接下来就是整个排查流程中最关键的一步看这个对象到底是怎么被 GC Roots 引用的。右键选择嫌疑对象点击Path to GC Roots - exclude all phantom/weak/soft etc. referencesMAT 会画出一条从 GC Root 到该对象的最短强引用链。一条典型的泄漏引用链长这样Thread └─ ThreadLocalMap$Entry └─ ThreadLocalMap$Entry.value └─ MyBusinessService └─ sessionFactory └─ SessionImpl └─ ...看到Thread - ThreadLocalMap这条路径第一个念头就应该是有没有工人线程里的 ThreadLocal 被塞了不该长期存活的对象。然后去查对应代码基本一查一个准。我遇到过最典型的案例就是一个线程池里的自定义ThreadLocal没有在任务结束时remove()。线程池线程是常驻的永远不销毁所以每个线程的 ThreadLocalMap 里挂着的对象永远参加不了 GC。一次任务挂一个千次任务挂一千个内存不爆才怪。解决方式也很简单finally 块里remove()一下问题就没了。5.3 从线程视角看引用链的完整拼图有时候引用链从 Thread 开始但你看不到具体是哪个线程。这时候可以切到Threads视图MAT 会把所有存活线程列出来展示每个线程的栈信息和局部变量。对比一下业务日志里的线程名很容易看出到底是哪个线程池的哪类任务在“养”这些对象。这里还有个实用技巧在 Threads 视图里线程下挂的对象树也按保留堆排序。如果一个线程的保留堆明显比其他线程大几个数量级那这个线程大概率就是泄漏的源头。我处理过一个连接池泄漏问题就是靠这个视图发现一个空闲线程还拽着 500MB 的连接对象顺着栈一查原来是连接返还逻辑有分支判断漏了。6. 组合拳jmap/jstat/MAT 的完整排查链路6.1 先看 JVM 状态再决定要不要 dumpMAT 确实是内存泄漏排查的主角但它不应该是一上来就用的工具。真正完整的一条排查链路是jstat 看趋势jmap/jcmd 导堆然后才是 MAT 分析。在导 dump 之前先用 jstat 观察一下 GC 情况jstat -gcutil 12345 1000 20这个命令每秒输出一次 GC 统计共输出 20 次。重点看FGCFull GC 次数和FGCTFull GC 耗时。如果 Full GC 频繁触发且老年代占比一直没有明显下降那基本确认存在泄漏或内存膨胀可以放心导 dump。接着用jcmd导堆jcmd 12345 GC.heap_dump /data/dump/heap.hprof导完后再结合 MAT 的 Leak Suspects、Histogram、Dominator Tree 三张牌依次分析。这套组合拳的好处是每一步都有前面的数据支撑不会盲目动手。比如只看 jstat 就能判断出堆内存是不是“GC 后能回落”这决定了是走泄漏排查路线还是走容量规划路线。6.2 MAT 与前端内存泄漏排查的对应关系工具链这套方法论不止服务端能用前端内存泄漏排查的思路也是同一个套路。前端开发者可以打开 Chrome DevTools 的 Memory 面板录制 Heap Snapshot然后对比快照里的 Retained Size 来定位泄漏点。操作方式和 MAT 的 Histogram 几乎是一模一样的逻辑。我把两者放一起对比过发现核心思想完全相通先拍快照再找大对象再看引用链最后定位到具体的闭包或 DOM 引用。区别只在于 MAT 面向 JVM 堆DevTools 面向 V8 堆MAT 的 Leak Suspects 对应 DevTools 里的Allocation instrumentation on timeline。如果你已经熟练掌握了 MAT其实前端的内存泄漏排查学起来非常快。7. 常见问题与实战避坑记录7.1 MAT 常见问题速查表把这几年用 MAT 过程中遇到的典型问题汇总成一张表可以直接收藏备用问题现象可能原因解决办法打开 hprof 报Java heap spaceMAT 自身堆内存太小调大MemoryAnalyzer.ini中的-Xmx打开 hprof 时报版本不支持hprof 由更高版本 JDK 导出升级 MAT 版本或确认 JDK 版本兼容性Leak Suspects 报告里没有明显嫌疑多个小泄漏点叠加或无强引用泄漏改用 Histogram Dominator Tree 逐个排查大对象打开大文件时 CPU 100% 很久MAT 正在构建索引和支配树等待即可如果太慢取消“计算精确保留堆”jmap -dump:live导致服务卡顿live触发 Full GCSTW 阻塞低峰期操作或改用不带live的 dump报告显示的类无法对应到业务代码第三方库/SDK 内部类名用引用链定位调用方必要时结合线程栈导出的 hprof 文件过大传输慢堆内存本身大用 gzip 压缩后再传输MAT 支持直接打开.gz旧版本 MAT 打不开新版 JDK 的 dumphprof 格式有修改升级 MAT 到最新版本7.2 三个判断泄漏的小技巧最后分享三个自己摸索出来的小技巧都是文档里找不到的东西。第一个连续两次 dump 对比。第一次在低峰期第二次在高峰期或者隔一段时间用 MAT 打开两份 dump对比同一个类的实例数量和保留堆。如果实例数量翻了倍数基本可以断定泄漏。这个对比方法比单看一份报告要可靠得多。第二个关注byte[]和char[]。很多时候 Histogram 里高峰期占内存的不是业务对象而是这两个基础数组。它们本身不携带业务属性必须点进去看引用链才能知道是什么业务场景创建的。我之前排查过一个疑似复杂故障最后发现只是日志队列太大把char[]堆满了链路上根本没有复杂问题。不要被基础类型数组吓到只要引用链看得准原因往往简单得让人想笑。第三个分析完别急着下线 MAT把报告导出成 HTML 或保存成快照文件。给团队复盘或者写故障报告的时候这些图片和引用链就是最有力的证据。不然下次要重新追溯又得把几 GB 的 hprof 再打开一遍白白浪费时间。排查内存泄漏本质上是做减法——从整堆对象里一层层做排除最终把范围缩小到一条引用链上。MAT 的价值就在于它提供了足够清晰的分析视角把原本黑盒一样的堆内存变成了一张可以导航的地图。真正上手跑一遍你就会发现它比想象中好用得多排查速度比靠猜代码快好几倍。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门