MAT 工具分析堆转储,定位内存泄漏

MAT 工具分析堆转储,定位内存泄漏

MAT 工具分析堆转储,定位内存泄漏


「MAT 工具分析堆转储,定位内存泄漏」


Android 应用的内存泄漏问题,说大不大,说小不小。说它不大,是因为很多应用在用户手上运行三五分钟就被杀掉了,泄漏那点内存根本来不及累积成灾难。说它不小,是因为一旦泄漏发生在常驻组件——比如 Service、单例、或者某个生命周期超长的 ViewModel——那 OOM 崩溃就会在后台监控里持续刷屏,用户反馈里充斥着「用久了就卡」「切出去再回来就闪退」。Google Play 的 vitals 面板里,如果你的 out-of-memory 率超过同类应用的中位数,搜索排名和推荐位都会受影响。


定位内存泄漏的手段不少,LeakCanary 是开发阶段的首选,集成简单,自动检测,弹出通知告诉你哪个对象被谁持有了。但 LeakCanary 有它的边界:它只能检测它自己监控到的对象类型,配置不当会漏报;生产环境不能带着它跑,性能开销太大;最关键的是,当它报出一个泄漏链时,你看到的只是引用链的文本描述,对于复杂的业务代码,尤其是涉及多个模块交叉引用的情况,光靠 LeakCanary 的日志有时候根本理不清头绪。这时候你需要的是堆转储文件(heap dump),以及一个能把它吃进去、让你真正「看见」内存里发生了什么的工具。


Android Studio 自带的 Memory Profiler 可以导 hprof,也能做基本的实例查询和引用链追踪。但用过的人都知道,一旦堆转储超过几百 MB,Memory Profiler 的响应速度会急剧下降,搜索功能简陋, Dominator Tree、Histogram 这些真正有力的分析视图要么没有,要么藏得很深。对于需要频繁分析堆转储、或者要处理 GB 级别堆文件的开发者,Memory Analyzer Tool(MAT)至今仍是不可替代的选择。


MAT 最初是 Eclipse 基金会的一个项目,全称 Eclipse Memory Analyzer,现在托管在 eclipse.org/mat 上,完全免费开源。它的核心分析引擎基于 dominator tree 算法,这个算法由 IBM 的 Java 团队贡献,能在一秒内处理数亿个对象引用关系。MAT 的 1.15.0 版本(2024 年 1 月发布)已经支持 Java 17,对 Android 的 hprof 文件也有成熟的解析路径——虽然需要一点格式转换,后面会细说。


从 hprof 到 MAT:格式转换的坑


Android 系统导出的堆转储是 Android 特有的 hprof 格式,和 Java 标准 hprof 在字符串表、类结构描述上有差异。MAT 原生解析标准 Java hprof,直接打开 Android 的 hprof 会报错,或者解析出来的对象大小、类名完全错乱。


转换工具有两个选择。Android SDK 自带的 hprof-conv 位于 sdk/platform-tools/hprof-conv,命令行直接跑 hprof-conv android.hprof standard.hprof 即可。这个工具从 SDK 早期版本就有了,稳定可靠,但功能单一,只做格式转换,不做任何数据清理。另一个选择是 Android Studio 的 Memory Profiler 导出时勾选「Export to standard hprof format」,本质也是调了 hprof-conv,只是帮你封装了点击操作。


我个人更推荐命令行方式,因为实际工作中你经常需要批量处理。比如线上监控抓取到的堆转储,可能一次就有几十份,写个 shell 脚本循环调用 hprof-conv,再统一丢给 MAT 分析,效率远高于在 Android Studio 里一个个点导出。hprof-conv 的源码在 AOSP 的 system/core/libmemunreachable/ 附近能找到,虽然 Google 没有大张旗鼓地维护文档,但接口十年没变过,不用担心兼容性问题。


转换后的文件如果仍然很大,MAT 启动时可能会报内存不足。MAT 默认的堆上限在 MemoryAnalyzer.ini 里配置,Windows 和 Linux 版是这个 ini 文件,macOS 版因为应用包结构特殊,需要在 Eclipse/MemoryAnalyzer.app/Contents/Eclipse/ 目录下找到对应的 memoryanalyzer.ini。建议把 -Xmx 设为比你的 hprof 文件大 1.5 到 2 倍。比如 2GB 的堆转储,MAT 自身需要 3-4GB 的 JVM 堆才能流畅分析。机器内存不够的话,MAT 也支持在启动时加 -parse 参数走命令行解析,生成索引文件后再用图形界面打开,能省不少内存。


Dominator Tree:理解内存占用的核心视角


MAT 最强大的视图是 Dominator Tree,这个翻译过来叫「支配树」。概念源于图论:如果对象 A 的所有引用路径都经过对象 B,那么 B 支配 A。在内存分析语境下,这意味着如果 B 被回收,A 必然也被回收。Dominator Tree 把堆中所有对象按支配关系重组成一棵树,根节点是 GC Roots,每个节点的 retained heap 就是它所支配的所有对象的大小之和。


这个视角和直方图(Histogram)有什么区别?Histogram 按类名聚合实例,告诉你 java.util.HashMap$Node 有 500 万个实例,占 200MB。但 HashMap 节点本身可能是被业务层的各种 Map 持有,你定位不到罪魁祸首。Dominator Tree 则直接告诉你,某个 ActivityManager 单例的 retained heap 是 150MB,展开后能看到它支配了一个 ArrayList,里面存了 300 个 Bitmap,每个 Bitmap 又关联着一个 ActivityWindow decor view。泄漏的根因一目了然。


实际案例:去年我处理过一个线上 OOM,LeakCanary 报告说 MainActivity 泄漏,引用链指向一个 LocationCallbackFusedLocationProviderClient 持有。但 LocationCallback 明明已经在 onDestroy 里调了 removeLocationUpdates,为什么还泄漏?导入 MAT 后,Dominator Tree 显示 MainActivity 的 retained heap 有 80MB,远超正常值。展开支配链,发现 LocationCallback 内部有一个 AtomicReference<MainActivity>,这个引用在 removeLocationUpdates 的回调完成前被某个线程写入了新的 Activity 实例,而旧实例因为 AtomicReference 的强引用无法释放。更深层的原因是我们在 LocationCallback 里用 lambda 捕获了 Activity,编译器生成的合成类持有外部引用,而 AtomicReference 又持有了这个 lambda 实例。LeakCanary 的文本日志只显示到 LocationCallback,MAT 的 Dominator Tree 让我看到了 AtomicReference 这个中间层,以及它里面躺着的两个 Activity 实例——一个是当前正在显示的,一个是已经 finish 但还没被回收的。


Path to GC Roots:验证泄漏链的精确工具


Dominator Tree 告诉你「谁占着内存」,Path to GC Roots 告诉你「为什么它没被回收」。MAT 里选中任意对象,右键「Path to GC Roots」→「with all references」,会展开从该对象到 GC Roots 的完整引用链。注意这里要选「with all references」而不是「exclude weak/soft references」,因为 Android 的 WeakReference 在 ART 里的处理有时候和 HotSpot 不完全一致,过早排除弱引用可能漏掉关键信息。


Path to GC Roots 的展示方式有几种:树形、列表、合并后的最短路径。我通常先看合并视图,因为一条引用链可能经过多个中间对象,合并视图会把共同的祖先节点折叠,突出差异分支。比如一个 BitmapImageView 持有,ImageViewViewGroup 持有,ViewGroupActivitymDecor 持有,合并视图会把 Activity 以上的公共路径折叠,让你一眼看到是哪个 Activity 出了问题。


有个细节很多人忽略:MAT 的 Path to GC Roots 默认只显示强引用路径,但 Android 里 CleanerPhantomReference 也在逐渐增多,尤其是 Bitmap 的 native 内存释放从 Android 8 开始大量依赖 NativeAllocationRegistry,后者底层用了 Cleaner。如果你怀疑泄漏和 native 内存有关,需要在 MAT 的偏好设置里打开「show unreachable objects」相关选项,或者手动检查 dominator tree 里是否有大量对象被标记为 unreachable but retained——这通常意味着它们还在 finalization 队列里排队,或者 Cleaner 还没来得及执行。


OQL:用 SQL 查询堆转储


MAT 的 Object Query Language 是一个被低估的功能。语法类似 SQL,但查询的是堆中的对象实例。比如你想找出所有 android.graphics.Bitmap 实例,且宽度大于 1000 的:


SELECT * FROM android.graphics.Bitmap WHERE mWidth > 1000

OQL 支持复杂的链式访问,比如查某个特定 Activity 的所有 ImageView 子类:


SELECT * FROM android.widget.ImageView WHERE toString(this.mContext).contains("MainActivity")

toString() 是 OQL 的内置函数,会把对象转成字符串匹配。这个用法在排查特定页面泄漏时非常高效,尤其是当你的应用里有大量相似页面,Dominator Tree 里看到的 Activity 实例都是混淆后的类名 a.b.c 这种,通过 OQL 查 mContext 的字符串内容可以快速定位。


OQL 的局限在于它对 Android 的数组类型支持不够友好。比如 byte[] 在 MAT 里显示为 byte[],但 OQL 的 SELECT __PLACEHOLDER_ITALIC_0__ FROM java.lang.Object WHERE toString(this).contains("byte[]"),虽然效率低一些,但能绕过这个坑。


另一个坑是 OQL 查询大结果集时容易触发 MAT 的内存限制。如果查询返回几百万个对象,MAT 的 UI 会卡死。建议先用 SELECT count(*) ... 估算数量,或者把查询条件写得更严格,缩小范围。


MAT 的报表功能:Leak Suspects 和 Top Consumers


对于不熟悉的堆转储,MAT 的自动报表能快速给出方向。File → Run Leak Suspects Report,MAT 会分析 dominator tree 的异常大节点,按 retained heap 排序,给出可能的泄漏嫌疑。这个报表不是万能的,它基于启发式规则,比如「某个类实例数远超预期」「某个对象的 retained heap 占堆的百分比过高」,对于已知的常见泄漏模式(如 Activity 被静态 Handler 持有)识别率不错,但对于业务代码特有的设计问题,经常误报或漏报。


Top Consumers 报表则按类加载器、包名、或者自定义维度聚合内存占用。分析第三方 SDK 的内存影响时很有用,比如接入某个推送 SDK 后 OOM 率上升,Top Consumers 按包名聚合能看到 com.xxx.push 包下的 retained heap 占比,再下钻到具体类。


这两个报表我通常作为初筛手段,真正的定位还是要靠 Dominator Tree 和 OQL 的手工分析。自动报表的一个实用场景是给非内存分析专家的同学快速同步信息,比如把 Leak Suspects 的 HTML 导出发给后端同事,让他们确认某个单例的持有是否符合预期。


和 Android Studio Memory Profiler 的互补关系


Android Studio 的 Memory Profiler 在实时性上有优势,可以录制内存分配的时间线,看到每个对象的分配栈。对于「泄漏发生在哪个调用路径」这种问题,Memory Profiler 的 allocation tracking 比 MAT 更直接。但 allocation tracking 的开销极大,不能长时间开启,而且录制的数据量一大,Android Studio 本身就会卡顿。


我的实际 workflow 是:开发阶段用 LeakCanary 快速发现,复现阶段用 Memory Profiler 的 allocation tracking 确认泄漏对象的分配栈,抓取 hprof 后用 MAT 做深度分析。如果泄漏只在特定用户场景下出现,线上监控通过 Debug.dumpHprofData() 抓取堆转储,上传到服务器后,我在本地用 MAT 批量分析。


这里有个版本相关的细节:Android 11(API 30)开始,Debug.dumpHprofData() 在应用 targetSdkVersion 为 30+ 时,需要 MANAGE_EXTERNAL_STORAGE 权限或者使用 Context.getExternalFilesDir() 路径,否则会因为分区存储限制写入失败。很多线上监控方案没处理好这个,导致 Android 11+ 的用户抓不到堆转储。我们当时的做法是统一写到 getApplicationContext().getExternalFilesDir("heap_dumps"),上传后删除,避开权限问题。


MAT 的局限:不是为 Android 设计的


必须诚实地说,MAT 的 Android 支持是「能跑」而不是「原生优化」。最大的痛点是对象大小的计算。Android 的 ART 和 dalvik 在对象头大小、数组对齐方式、引用压缩(compressed oops)等方面和 HotSpot 有差异,MAT 解析 Android hprof 时,对象 shallow heap 的数值是估算的,不是精确值。对于 32 位和 64 位 ART,这个估算误差可能在 10%-20%。分析相对大小时不影响结论,但如果你需要精确到字节的内存占用统计,MAT 的数字不能直接用。


另一个局限是线程和锁的分析。MAT 能显示 Thread 对象及其持有的引用,但 Android 的 AsyncTask 线程池、HandlerThreadCoroutineDispatcher 背后的线程复用机制,在 MAT 里看起来都是普通的 ThreadThreadGroup,没有 Android 特有的语义标注。比如 Kotlin 协程的 DispatchedContinuation 对象在堆里有大量实例,MAT 不会告诉你这是哪个 CoroutineScope 创建的,需要你自己结合业务代码的协程使用模式去推断。


还有 UI 的古老问题。MAT 的界面是 Eclipse SWT 风格,在 4K 屏上字体模糊,暗色模式不支持,快捷键和主流 IDE 不一致。这些不影响功能,但长期用下来确实影响体验。社区有过一些 Eclipse 插件尝试改善,比如 mat-apple-ui-enhancements,但维护都不活跃,2023 年后基本停滞。我个人是配合系统的窗口缩放和自定义 CSS 勉强用着,毕竟分析引擎的速度无可替代。


一个完整的分析流程示例


去年处理过一个 WebView 相关的泄漏,值得作为完整案例。线上监控显示 WebViewActivity 的泄漏率在 15% 左右,LeakCanary 报告引用链是 WebViewAwContentsBrowserAccessibilityManagerAccessibilityManager 的 service listener 列表。但这个链看起来像是系统组件的正常持有,为什么会导致 Activity 泄漏?


抓取 hprof,hprof-conv 转换后导入 MAT。Dominator Tree 里找到 WebViewActivity 的实例,retained heap 120MB,明显异常。Path to GC Roots 展开,发现 BrowserAccessibilityManager 确实在 AccessibilityManagermAccessibilityStateChangeListeners 里,但 listener 的持有者是 AwContents 的内部类,而 AwContentsWebView 持有,WebViewWebViewActivity 持有。这条链本身没有循环引用,正常情况下 Activity finish 后应该全部释放。


关键发现在 OQL 查询:SELECT * FROM org.chromium.android_webview.AwContents WHERE mDestroyed == true。返回了 30 多个实例,它们的 mDestroyed 字段为 true,表示 AwContents.destroy() 已经被调用过,但这些实例仍然存在。再查这些 AwContents 的 GC Roots,发现它们被 AwContentsClient 持有,而 AwContentsClient 又被 WebViewContentsClientAdapter 持有,后者是一个 AwContents.InternalAccessDelegate 的实现,注册在 AwContentsmInternalAccessAdapter 字段。


问题根源:我们在 WebViewActivity.onDestroy 里调了 webView.destroy(),但 WebView 的 destroy 逻辑在 Chromium 层是异步的,如果 Activity finish 时 WebView 还在加载资源,AwContents 的销毁会延迟到资源加载完成。而 WebViewContentsClientAdapter 作为内部类持有外部 WebView 的引用,WebView 又通过 mContext 持有 Activity。更深层的原因是我们在 WebViewClient.onPageFinished 里有一个回调闭包,这个闭包被 Chromium 的 C++ 层通过 JNI 持有,直到页面资源完全释放。Java 层的引用链在 MAT 里清晰可见,但如果没有 OQL 查询 mDestroyed 状态,很难把「已经调用 destroy 但未释放」和「根本没调用 destroy」区分开。


修复方案不是简单的「在 onDestroy 里加弱引用」,而是调整 WebView 的初始化方式,把 WebViewActivityWindow 里 detach 后再调用 destroy,并且拦截后续的所有 loadUrl 调用。更重要的是,我们把 WebView 的创建从直接 new WebView(this) 改为 new WebView(getApplicationContext()),让 WebViewmContext 指向 Application 而不是 Activity,切断这条泄漏链中最关键的一环。这个改动后,WebViewActivity 的 retained heap 从 120MB 降到 3MB,线上 OOM 率下降了一个数量级。


工具链的整合:从开发到线上


MAT 不是孤立使用的,它需要嵌入到完整的内存监控体系里。我们的实践是:开发阶段 LeakCanary 自动检测,CI 阶段跑 androidx.benchmark:benchmark-macro-junit4MemoryUiAutomatorBenchmark 做回归测试,线上通过 Firebase Performance Monitoring 的自定义 trace 记录 Runtime.getRuntime().totalMemory() 的峰值,超过阈值触发 Debug.dumpHprofData()


堆转储的上传需要谨慎处理,因为 hprof 文件包含用户数据,可能泄露敏感信息。我们的做法是:抓取后立即用 hprof-conv 转换,然后用自定义脚本过滤掉 Stringchar[] 中匹配手机号、邮箱正则的实例,再把类名做混淆映射的反向还原(用 R8 的 mapping 文件),最后加密上传到内部服务器。这个流程的脚本开源在 GitHub 上,搜 android-hprof-sanitizer 能找到几个类似实现,我们参考了 Square 的 leakcanary-deobfuscation 插件的设计。


MAT 的分析结果我们会归档到内部 wiki,用 MAT 的「Report → Export to HTML」功能生成可分享的报表。Dominator Tree 的截图虽然直观,但静态图片无法交互,HTML 报表保留了对象的点击展开能力,方便同事之间异步 review。


最后的建议


如果你还没用过 MAT,建议从一个小型 hprof 开始,比如 LeakCanary 检测到泄漏后,它本身也会保存 hprof 文件在 /sdcard/Download/leakcanary-.../ 目录下,转换后用 MAT 打开,和 LeakCanary 的文本报告对比着看,理解 Dominator Tree 的视角差异。


MAT 的学习曲线确实比 Android Studio 的图形界面陡,但一旦跨过门槛,它处理大规模堆转储的能力、OQL 的灵活查询、以及 dominator tree 算法带来的精确 retained heap 计算,都是定位复杂内存问题的利器。Android 的内存管理在持续演进,ART 的 GC 从 CMS 到 CC(Concurrent Copying),再到未来的 generational GC,对象的生命周期模式在变化,但「找到不该存在的对象,理解它为什么还存在」这个核心逻辑不变。MAT 是围绕这个逻辑构建的最成熟工具之一,值得在工具箱里占有一席之地。


工具下载地址:eclipse.org/mat,当前稳定版 1.15.0,支持 Windows、Linux、macOS,完全免费。macOS 用户如果遇到「无法验证开发者」的提示,需要在系统偏好设置里手动允许,或者从命令行 xattr -cr MemoryAnalyzer.app 清除隔离属性。

NDK 开发的 CMake 配置模板分享 2026-06-25

评论区