简介:MemoryAnalyzer 1.6.1(20161125版)是面向Java开发、测试与运维人员的堆内存分析工具包,适用于排查内存泄漏、OOM、对象异常增长等性能问题。该版本为Windows平台win32.win32.x86_64构建,解压后可直接运行,内置Eclipse MAT的分析界面与插件依赖。压缩包共463个文件、约57.99MB,以174个jar库文件、44个html帮助页、31个xml与26个properties配置、48个png及20个gif界面资源为主,另含少量exe启动文件和说明文档,目录结构保留完整RCP应用形态。已有302人学习下载。借助支配树、直方图、泄漏嫌疑报告和引用链分析,可定位大对象持有关系与未释放引用;配套说明文档便于完成安装与初次使用,适合需要深入Java内存诊断的开发者作为离线工具留存。
1. 一个 8G 的 hprof 摊在面前,MemoryAnalyzer 1.6.1 的 win32.x86_64 包能替你回答什么
凌晨两点,一台线上实例的堆 dump 落地,文件 8.2G,业务方只丢过来一句「GC 之后内存不降」。这时候要回答的不是「有没有泄漏」这种笼统问题,而是三个更具体的:哪一类对象吃掉了最多的 retained heap、它是被谁攥住的、那条持有链上有没有一环本该断开却没断。Memory Analyzer 1.6.1 的 win32.win32.x86_64 包干的就是这件事——把 hprof 里那张二进制对象图,翻译成直方图、支配树、GC Roots 引用路径和一份自动生成的泄漏嫌疑报告。它是 Windows 64 位的独立桌面版,解压出来就能跑,不需要先搭一套 IDE 环境。适合的人很明确:要排查 OOM、要向别人解释内存曲线为什么掉不下来、要在上线前算清楚一个本地缓存到底占多少的 Java 后端和中间件同学。
2. 解压之后先别双击:目录、-vm 与 -Xmx 的三处改动
2.1 目录里真正要动的只有三个文件
解压得到一个 mat 目录,里面几十个文件,第一眼很容易懵。实际排查过程中会碰到的就那么几个,其余的碰了只会给自己找麻烦:
| 路径 | 作用 | 要不要动 |
|---|---|---|
| MemoryAnalyzer.exe | GUI 启动器,启动时读同目录的 ini | 不动,双击它就够 |
| MemoryAnalyzer.ini | 启动参数:指定 JVM、指定堆大小 | 必改,后面两节专门说 |
| ParseHeapDump.bat | 命令行解析入口,不出界面也能出报告 | 不动,但要知道怎么调 |
| plugins/、features/ | OSGi 插件本体,分析能力全在这里 | 别删,删了直接起不来 |
| configuration/ | 运行时配置缓存 | 只在启动异常时整个删掉让它重建 |
有一点要提前说清楚:这个包是不带 JRE 的。它假设你的机器上已经有一个能用的 Java 运行时,启动器会顺着 JAVA_HOME 或者 PATH 去找。所以「双击没反应」这件事,九成不是包坏了,是它没找到合适的 Java。先把这两件事理顺,再谈分析。
2.2 -vm:先把 32 位 Java 排除掉
Windows 上最容易翻车的地方,是机器里同时装了 32 位和 64 位两个 Java,PATH 里排在前面的偏偏是 32 位那个。表现是启动器闪一下就没,或者好不容易开了,-Xmx 怎么调都上不去 1.5G。原因很朴素:32 位进程的地址空间就那么大,你给它 8G 它也没地方放。
解决办法是在 ini 里把 JVM 路径钉死,不要依赖环境变量。注意-vm必须写在-vmargs之前,写反了不生效,这是个很安静的坑:
# MemoryAnalyzer.ini 里只改这两处,其余行保持解压原样 -vm C:/Java/jdk1.8.0_152/bin/javaw.exe -vmargs -Xmx6g路径用正斜杠或者双反斜杠都行,单反斜杠会被当转义。这里写javaw.exe而不是java.exe,是因为 javaw 不带控制台窗口,GUI 用着干净;但如果你要排查启动问题,临时换成java.exe就能看到控制台输出,报错信息会直接打出来,比翻日志快得多。
版本上,1.6.x 这条线是按 Java 8 时代的运行时做的。机器上如果是 Java 8,基本不用操心;如果是更高版本的 JDK,多数情况能起来,但偶尔会碰到启动器或解析阶段的小毛病。真遇到了别纠缠,把-vm指回一个 8 的 JDK,问题通常当场消失。这不是让你守着老版本不放,是排查工具本身不该成为排查对象。
2.3 -Xmx:给解析器留够堆,但不是越大越好
这是最多人凭感觉设错的一个参数。要点在于:MAT 打开 dump 时并不是把整个 dump 读进内存,它会先在磁盘上生成一套索引,然后按需读取。但索引、对象图的中间结构、以及你打开的每一个视图,都要占堆。所以 -Xmx 太小会在解析到一半时抛 OOM,太大则可能让 GC 停顿变长、界面发卡。
经验值是:-Xmx 至少给到 dump 文件大小的二分之一,条件允许就接近 dump 大小。8G 的 dump,机器有 32G 内存,就给-Xmx12g;机器只有 16G,给-Xmx6g也能跑,只是打开支配树会慢一些,得等。反过来,一个 500M 的 dump 你给 16G,除了让 GC 更懒之外没有任何好处。
还有一处磁盘账要算:解析会在 dump 同级目录生成一批同名的.index、.a1、.a2之类附属文件,总体积经常和 dump 本身一个量级。所以 dump 放哪个盘不是随便选的,见 2.4。
提示:-Xmx 的数值单位写
g或m都行,别写gb,启动器不认,会当成非法参数直接退出,而且退出得毫无提示。
2.4 先用命令行跑一遍,确认索引能落地
在双击 exe 之前,我一般会先用命令行把解析跑一遍。原因有两个:命令行能把错误直接打在屏幕上;跑完之后索引已经生成,再用 GUI 打开就是秒开,省掉盯着进度条发呆的时间。
# 进到 mat 目录,第一个参数是 dump 路径,后面是要生成的报告 id cd /d D:\mat ParseHeapDump.bat D:\dump\app-20240115.hprof ^ org.eclipse.mat.api:suspects ^ org.eclipse.mat.api:overview ^ org.eclipse.mat.api:top_components三个报告 id 分别对应泄漏嫌疑、概览、最大消费者,跑完会在 dump 旁边生成几个_Leak_Suspects.zip、_Overview.zip之类的压缩包,里面是可直接打开的 html。这一步的意义不只是提前出报告,更重要的是验证「这个目录能不能写」。索引文件是写在 dump 旁边的,如果 dump 放在只读共享盘、权限受限的目录、或者空间只剩几百兆的系统盘,解析会跑到一半失败,报错还相当含糊。先跑通命令行,等于把这个雷提前排掉了。
命令行跑完再开 GUI,加载会明显快,因为索引命中。如果哪天你发现同一个 dump 每次打开都要重新解析好几分钟,基本可以断定索引没写成功——回去看目录权限。
3. 四个视图各管一段:直方图、支配树、GC Roots 路径、Leak Suspects
3.1 Histogram:先看数量,再看 retained
Histogram 是打开 dump 后第一个该看的视图。它按类聚合,每行给出这个类的实例数、shallow heap、retained heap。新手最常见的误用是上来就按 shallow heap 排序,然后盯着char[]、byte[]、int[]发呆——数组类的 shallow size 求和当然最大,但这不构成任何结论。
正确的读法是分两轮。第一轮按Objects 列排序,找「数量异常」的类:一个业务对象出现几百万个实例,本身就说明有问题,不管它单个多小。第二轮按Retained Heap 列排序,找「体量异常」的类,这里才是真正占着内存不撒手的东西。两轮看完,你对这个堆的形状就有概念了。
视图顶部有几个分组开关值得用起来:
- Group by package:把散落在各处的类收拢到包下面,看哪个模块整体占得多。
- Group by class loader:容器环境里必备。同一个类被两个 ClassLoader 各加载一份,不分组的话你在列表里会看到两个同名类,很容易看漏。
- Group by superclass:排查集合类泛滥时好用,能把各种 Map 实现收拢成一行。
- 勾选Show unreachable objects:默认只统计从 GC Roots 可达的对象,如果解析时保留了不可达对象,勾上才能看到它们。
还有个小技巧:在 Histogram 某一行上右键,可以直接对这批实例做「列出所有对象」「算 retained set」「找 GC Roots 路径」,不用先跳去别的视图。这条右键菜单是整个工具里用得最频繁的入口。
3.2 Dominator Tree:retained size 才是账面上的钱
shallow heap 和 retained heap 的区别,是这个工具的核心概念,绕不过去。
- shallow heap:对象自身占的字节数,不含它引用的任何对象。一个
String大概 24 字节,它指向的char[]不计入。 - retained heap:这个对象被回收之后,能连带释放掉的总字节数。关键在于「连带」的范围怎么算——只算它独占支配的那部分。
支配关系的定义不复杂:从 GC Roots 出发到对象 B 的所有路径都必须经过 A,那么 A 支配 B。一个对象如果不是被单独持有,而是被好几个地方同时引用,那它就不完全由某一个持有者支配,retained size 会被分摊。这个定义解释了一个很多人困惑的现象:把一个缓存对象删掉,内存并不一定降下它 retained size 那么多,因为它引用的东西别处还拿着。
Dominator Tree 就是按支配关系排出来的一棵树,父节点的 retained size 一定大于等于子节点。看这棵树的方法是从上往下走:先看根下面那几个大头是谁,如果是Thread,那是线程栈相关;如果是某个ClassLoader,是类元数据;如果是业务侧的连接池或缓存容器,那就有戏了。一路展开到能叫出业务名字的类为止,通常不超过五层。
提示:支配树的计算代价不低,超大 dump 上第一次展开可能要等十几秒。别在这期间反复点刷新,越点越慢。
3.3 Path to GC Roots:为什么它还没被回收
找到可疑对象之后,下一个问题永远是「谁在引用它」。在任意对象上右键 → Path to GC Roots →exclude weak/soft references,这是默认动作,也是必须的动作。
原因在于,WeakReference、SoftReference这类引用不阻止回收。如果一个对象只是被软引用挂着,它其实随时可以被回收,不构成泄漏。不排除这些引用,你会看到一棵挂着几十层SoftReference→ReferenceQueue→ 内部结构的树,全是噪音。排除之后剩下的引用链,才是真正把对象钉在内存里的那几根钉子。
引用链读法是从下往上:最底下是你的对象,往上每一层是持有者,一直到Thread或ClassLoader这样的根。常见的几条典型链路:
| 链路形态 | 通常意味着 |
|---|---|
Thread→ThreadLocalMap→ 业务对象 | ThreadLocal 用完没 remove,线程池里线程不死,对象就一直活着 |
ClassLoader→classes→ 静态字段 → 容器 | 静态集合只增不减,典型的本地缓存没有淘汰 |
Thread→ 线程池Worker→BlockingQueue→ 任务对象 | 队列积压,任务对象里带着大 payload |
ClassLoader→ 某个监听器列表 → 业务对象 | 注册了监听器但没反注册,容器销毁后整棵图都没释放 |
这条链看完,基本就知道该去改哪一行代码了。
3.4 Leak Suspects:能省半小时,但不能当结论
Leak Suspects 是一份自动生成的报告,用一组启发式规则扫一遍堆,把「retained size 占比最高的若干支配者」「大量重复字符串」「finalizer 堆积」「空集合泛滥」之类的模式挑出来,配一张饼图和几段文字描述。
它的价值在于快。一个刚拿到的陌生 dump,先看这份报告,能在几分钟内建立大致印象,知道该往哪个方向挖。它的局限在于,启发式终究是启发式,它不认识你的业务。一个占 40% 内存的HashMap,报告会标红,但那可能就是你设计上要常驻的本地缓存,完全正常;反过来,一个只占 3% 但持续增长的泄漏,报告里大概率一笔带过。
所以我的用法是:把 Leak Suspects 当目录,不当判决书。从它标出的可疑点进去,用 3.1 到 3.3 那三套视图自己核实一遍,确认「这个对象的持有链确实不该存在」,才算数。报告的每一个 suspect 都可以点进去看细节,点进去之后走的还是直方图和支配树那一套,绕不开的。
4. OQL 与快照对比:把「看着像」变成可验证的数字
4.1 OQL 的三条基本骨架
OQL 是 MAT 内置的对象查询语言,语法长得像 SQL,查的是堆里的对象图而不是表。视图入口在工具栏的 OQL 面板,写完之后按执行,结果以表格形式出来。
绝大多数排查场景,用三种骨架就够了:
-- 骨架一:按类过滤,附加字段条件 SELECT s FROM java.lang.String s WHERE s.count > 200 -- 骨架二:算某个类独占持有的对象集合 SELECT AS RETAINED SET * FROM com.example.cache.LocalCache -- 骨架三:按引用类型捞,确认缓存挂在哪种引用上 SELECT * FROM java.lang.ref.SoftReference第一条里s.count是String内部的字符数量字段,能直接访问私有字段是 OQL 的一个便利之处,不用反射。第二条的AS RETAINED SET是关键语法,它返回的不是匹配到的对象本身,而是这些对象支配的整个集合,用来估算「清掉这个缓存能省多少」非常直接。第三条用来区分「内存被软引用占着」和「内存被强引用钉死」,这两种情况的处理方式完全不同——前者调-XX:SoftRefLRUPolicyMSPerMB或者干脆不管,后者必须改代码。
注意:Java 9 之后
String的内部表示从char[]改成了byte[]加一个编码标志位。用这个版本的 MAT 去打开新 JDK 产生的 dump,字段名和类型可能对不上,s.value之类的查询会报类型不匹配。遇到这种情况别硬改查询,从byte[]那一侧反查更快:先 Histogram 定位byte[],再对可疑的那批对象右键找 GC Roots 路径。
4.2 几个能直接抄的查询
排查中最常敲的几类查询,整理成可以直接改包名就用的形式:
-- 找超长字符串,定位拼接出来的大 payload SELECT s, s.value FROM java.lang.String s WHERE s.count > 5000 -- 找没被清理的 ThreadLocal 条目(值为 null 但 key 还在的残留) SELECT * FROM java.lang.ThreadLocal -- 找队列里堆积的任务,按集合大小排序 SELECT * FROM java.util.concurrent.LinkedBlockingQueue q WHERE q.count > 1000第二条需要说明一下:ThreadLocalMap的 Entry 是弱引用 key,key 被回收后 value 还挂在表里,形成「key 为 null 的僵尸 entry」。这类残留不会自己消失,线程池里的线程活得越久积得越多。用 OQL 把ThreadLocal全捞出来,再结合引用链看是哪个业务代码注册的,比盲猜快。
第三条里q.count是LinkedBlockingQueue内部记录元素个数的字段。同类的还有AtomicInteger的value、各种HashMap的table。写 OQL 之前先看一眼类的字段定义,比试错快。
4.3 两个 dump 做对比:Compare Basket 的用法
单看一个 dump 只能知道「现在占多少」,看不出「什么在涨」。真正的泄漏判断往往需要两个时间点的快照做差。Compare Basket 就是干这个的,操作路径如下:
- 在同一个 MAT 会话里,先后打开两个 dump(File → Open Heap Dump,选第二个文件时不要关掉第一个)。
- 在第一个 dump 的 Histogram 上右键,选Add to Compare Basket。
- 切到第二个 dump,同样在 Histogram 上右键,Add to Compare Basket。
- 打开 Compare Basket 视图,点右上角的感叹号按钮执行对比。
结果是一张三列的表:类名、快照一的实例数、快照二的实例数,可以按差值排序。涨得最凶的那几行,就是嫌疑最大的。
这个功能有几个前提条件,不满足的话结果会很误导人。两个 dump 必须来自同一个进程的连续运行,中间不能重启,一重启所有对象地址和类加载器 id 全变了,对比出来的差值毫无意义。另外两次采集的时机要选好:都在 Full GC 之后采,否则差值里混着大量还没回收的临时对象,噪音会盖过信号。
4.4 把结果导出再做二次聚合
MAT 的表格视图能做的事有限,很多时候需要把数据拿出来用别的工具算。Histogram 右键 → Export → CSV,OQL 结果右键 → Export,都能导出。导出的 CSV 里保留了类名、实例数、shallow、retained 四列,用表格软件按包名做一次透视,很容易看出「哪个模块在这次发版之后内存涨了」。
我习惯在排查结束后把关键视图都导一份存档。原因很现实:dump 文件太大,通常保留不了几天就得删,但那张 CSV 只有几十 KB,留半年都没问题。下一次同一个服务再出问题,把两份 CSV 放一起看,比重新打开两个 8G 的 dump 快得多。
5. 避坑与排查:五个在真实现场反复出现的翻车点
5.1 双击 exe 一闪而过,什么都没留下
现象:双击MemoryAnalyzer.exe,任务栏图标闪一下就没了,没有任何对话框,事件查看器里也看不出所以然。
原因:启动器找不到合适的 JVM,或者找到了一个 32 位的。这个包不捆绑运行时,它对环境是有要求的。有时候是 JAVA_HOME 指向了一个已经卸载的路径,有时候是 PATH 里排前面的是 32 位版本。
解决:把 ini 里的-vm显式指向一个 64 位 JDK 8 的javaw.exe(写法见 2.2),临时把javaw换成java跑一次看控制台报错。还有一种更隐蔽的情况:路径里有中文或空格,某些版本的启动器处理不好,把 mat 目录挪到纯英文短路径下再试。
5.2 解析卡在某个百分比不动,或者报索引创建失败
现象:进度条走到 60% 左右停住,硬盘灯不闪,等十分钟也没动静;或者直接弹窗提示无法创建索引文件。
原因:索引是写在 dump 同级目录的。dump 放在只读的网络共享盘、权限受限的目录、或者剩余空间不足的盘上,都会在这里炸。另一个原因是 dump 本身不完整——采集过程中进程被 kill、磁盘写满,文件头看着正常,尾部是截断的。
解决:先把 dump 拷到本地 NTFS 盘的一个普通目录,确认剩余空间至少是 dump 大小的两倍,再重新解析。如果换了目录还是失败,去比对一下文件大小和采集时日志里报的大小,对不上就是 dump 截断了,这份文件没救,只能重新采。
5.3 -Xmx 一路加到 16g,打开还是 OOM
现象:明明机器有 64G 内存,ini 里-Xmx16g也写了,解析到一半还是抛OutOfMemoryError: Java heap space。
原因:两个可能。一是-Xmx压根没生效,因为写在了-vmargs前面,或者 ini 里存在重复的-Xmx行、后一条覆盖了前一条。二是启动器用的是一个 32 位 JVM,堆上限被地址空间卡死,你写多少它都只能给到 1.5G 左右。
解决:打开 GUI 之后看 Help → About 里显示的 JVM 和最大堆,一眼就能确认参数有没有吃进去。没吃进去就回头检查 ini 的位置和重复行;显示的最大堆远小于设定值,就是 32 位 JVM 的问题,回到 5.1 处理。
5.4 Histogram 里全是 char[]、byte[],找不到业务类
现象:按 shallow heap 排序,前二十行清一色是基础类型数组和java.lang.String,翻好几页都看不到自己的包名。
原因:数组和字符串是所有业务的底层载体,按占用排序它们必然在前面。这不是工具的问题,是看法的问题。
解决:换成按 Objects 列排序找数量异常,或者用 Group by package 把视角抬到模块层。真要抓具体对象,用 3.1 里说的右键入口,从可疑的数组行直接「列出所有对象」,再顺着引用链往上爬两层,业务类就露出来了。
5.5 明明怀疑泄漏,Path to GC Roots 却显示对象可回收
现象:Histogram 里某类对象数量在涨,但对着其中一个实例找 GC Roots 路径,结果只有一条弱引用链,看起来随时能被回收。
原因:采集 dump 时用了jmap -dump:live,这个参数会先触发一次 Full GC 再 dump,那些本来在泄漏、但当时恰好没人引用的对象已经被回收掉了,自然看不到。用 live 参数采到的快照是「GC 之后的内存」,用它判断泄漏等于把证据先销毁了。
解决:怀疑泄漏时用不带 live 的jmap -dump:format=b,file=xxx.hprof <pid>,把不可达对象也留在快照里。代价是文件更大、里面有垃圾对象干扰,但排查泄漏要的就是完整现场。同时确认 MAT 解析时保留了不可达对象,Histogram 里勾上 Show unreachable objects 再看一次。
6. 把它接进日常:三件固定下来的小事
工具本身没什么神秘的,难的是让它在需要的时候真的能用上。我后来固定做了三件事。
第一件,把 dump 采集参数写进所有服务的启动脚本,不靠人临场敲命令:
java -Xms4g -Xmx4g \ -XX:+HeapDumpOnOutOfMemoryError \ -XX:HeapDumpPath=/data/dump \ -jar app.jarHeapDumpPath一定要单独指到一个空间充裕的盘。默认它写在进程工作目录下,很多 OOM 现场就是因为工作目录所在的分区满了,dump 写到一半失败,最后什么线索都没留下。
第二件,dump 文件命名带上服务名、时间戳和触发原因,比如order-svc_20240115-0231_oom.hprof。听起来是小事,但目录里堆了十几份java_pid1234.hprof的时候,你就知道这条规范值多少钱。
第三件,也是最容易忽略的一件:把 ParseHeapDump 的命令行固化成一个脚本,每次拿到 dump 先跑一遍批量报告,再开 GUI 细看。这里我把常用的几个报告 id 列一下:
| 报告 id | 产出内容 | 什么时候用 |
|---|---|---|
org.eclipse.mat.api:suspects | 泄漏嫌疑报告 | 每次必跑,用来建立第一印象 |
org.eclipse.mat.api:overview | 堆概览、大对象汇总 | 交接给别人时附上 |
org.eclipse.mat.api:top_components | 最大消费者列表 | 快速定位几个大头 |
org.eclipse.mat.api:system_properties | 系统属性快照 | 确认 JVM 参数和文件编码 |
修复上线之后,我会再做一次同样的流程:压测跑起来,采一份新 dump,用 Compare Basket 和修复前那份做差,看目标类的实例数是不是真的降下来了。这一步不能省——代码改了不代表对象就少了,可能是从 A 集合挪到了 B 集合,数字会告诉你真相。
从那以后我每次拿到 dump,都强制先走一遍「命令行解析 → Leak Suspects 看方向 → Histogram 两轮排序 → 支配树落地 → 引用链确认」这五步,不跳步,也不靠第一眼的直觉下结论。吃过一次亏:当时盯着一个占比 30% 的HashMap查了两天,最后发现真正的泄漏是另一个只占 2%、每十分钟涨一点的ThreadLocal残留。希望帮到你。
本文还有配套的精品资源,点击获取