4、解读 Heap Dump:拿到 hprof 文件,用 MAT 或自带工具分析引用链
好不容易抓到了 hprof 文件,接下来怎么办?别急着开分析工具就一通乱点。我见过不少同事,拿到文件就怼进 MAT,然后盯着“Dominator Tree”发呆——那玩意儿容易把人带偏。其实啊,解读Heap Dump有一套稳扎稳打的套路,摸熟了以后,你也能像我一样在10分钟内定位到泄漏点。
先选工具:MAT 还是 Android Studio Profiler?
我个人习惯优先用 MAT (Memory Analyzer Tool),原因很简单:它给出的引用链更直观,而且能批量分析。Android Studio 自带的 Heap Viewer 也不错,但处理大文件时容易卡死。下面这张表格是我自己总结的选型建议:
| 工具 | 适用场景 | 我的经验 |
|---|---|---|
| MAT | 大文件(>200MB)、需要精确引用链 | 内存5GB+,导出HTML报告很方便 |
| Android Studio Profiler | 中小文件、快速看一眼 | 适合熟悉的项目,但引用链有时不完整 |
| leakcanary 自带分析 | 快速确认已知泄漏模式 | 点一下就出结果,但深度分析不够 |
leakcanary 的自动分析,它会在Logcat里给出疑似泄漏的类名。拿那个类名去MAT里搜,效率翻倍。MAT 分析四步法
拿到 hprof 文件后,我一般按下面四个步骤走。你跟着做一遍,下次自己操作时心里就有底了。
- 打开文件,等 Histogram 加载完 —— 别急,先看看整体对象分布。如果某个类的实例数特别多,可能就是嫌疑对象。
- 搜索可疑类 —— 比如 BLE 相关的
BluetoothGatt、BluetoothGattCallback、你的自定义BleManager。右键 ->Merge Shortest Paths to GC Roots-> 选exclude weak/soft references。 - 看引用链 —— MAT 会列出一条从 GC Root 到该对象的路径。重点关注那些 非预期的强引用,比如被
static变量、Application、Activity持有了。 - 对比快照 —— 如果之前做过一次正常操作的截图,用它做差值分析(
Compare Basket),多出来的对象就是泄漏的。
核心要点: 引用链上的每一级都要问一句“这个对象为什么还活着?” 尤其是 BLE 回调里常见的匿名内部类,很容易隐式持有外部的 Activity。
典型案例:一个 BLE 连接的泄漏路径
我去年在一个智能穿戴项目里就遇到过典型的 BLE 泄漏。那会儿产品经理天天催,我熬了两晚上才揪出来。先看代码:
// 错误写法:匿名内部类隐式持有 Activity
class BleScanActivity : AppCompatActivity() {
private fun startScan() {
bluetoothLeScanner.startScan(object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult?) {
// 这里持有外部 Activity 的引用
updateUi(result)
}
})
}
}
用 MAT 看引用链的时候,发现 BleScanActivity 的实例一直存在,顺着路径往上走:
BleScanActivity→LeakCanary$InternalLeakCanary(被观察者持有)- 但其实真正的根在
BluetoothLeScanner内部维护的Handler和回调列表里。扫描没停止,那个匿名ScanCallback就不会释放,导致整个 Activity 泄漏。
我当时的经验是:不要只看第一层引用,要多点开几层。很多泄漏是跨线程的,比如 BLE 的回调发到主线程 Handler,而那个 Handler 又持有 Activity 的 View 引用。
SVG:Heap Dump 分析流程总图
下面这张图是我自己总结的解读流程。从打开 hprof 到定位泄漏,每一步都标出来了,你可以保存下来当 debug 时参考。
我的避坑指南
说几个我踩过的坑,你遇到了可以直接跳过:
- 别只看 Dominator Tree —— Dominator Tree 算的是“如果我干掉这个对象,能释放多少内存”。但 BLE 泄漏往往对象不大(一个 Gatt 实例也就几 KB),Dominator 排不出来。要切换到
class list按实例数排序。 - 小心 WeakReference 的干扰 —— 有些框架(比如 LeakCanary 自己的观察者)会用 WeakReference 指向 Activity,那不代表泄漏。一定要用
Merge Shortest Paths to GC Roots (exclude weak)这个菜单。 - 我曾经被系统类的缓存迷惑过 —— 有一次看到
InputMethodManager持有一个 Activity 引用,以为是系统 Bug。后来才发现是第三方键盘库泄露的。如果你看到系统类的引用,先查一下它是不是正常缓存。
BluetoothDevice、BluetoothGatt 的 shared 实例)。不要被数量迷惑:系统蓝牙服务自己也会缓存一些对象,需要区分。判断方法:- 如果是
java.lang.ref.Finalizer 持有 → 可能是 pending 的 finalizer,不算泄漏。- 如果是你的应用代码中的强引用持有 → 那才是泄漏目标。
如何快速定位 BLE 特有的泄漏模式
根据我的经验,大部分 BLE 泄漏都逃不出下面这三种模式。做成表格方便你对照:
| 模式 | 表现 | 引用链特征 | 修复方法 |
|---|---|---|---|
| 未停止扫描 | Activity 退出后仍被 ScanCallback 持有 | Activity → ScanCallback → BluetoothLeScanner → Handler → Looper | 在 onDestroy 中调用 stopScan |
| Gatt 未关闭 | 连接断开后 BluetoothGatt 对象残留 | Activity → GattCallback → BluetoothGatt → BluetoothAdapter | 在 onDisconnected 中调用 close() |
| 第三方库回调未解注册 | 库内部持有了 Activity 的 Context | Activity → 第三方库单例 → SharedPreferences / WakeLock 等 | 使用 Application Context 或主动解绑 |
举个例子。有一次我排查“未停止扫描”的问题,MAT 显示引用链是这样的:
BleScanActivity instance
└─ mScanCallback (匿名类 $0)
└─ BluetoothLeScanner.mHandler
└─ Handler.mLooper
└─ (主线程 looper,GC Root)
看到了吧?链条很短,但问题就是 mHandler 里还保持着回调的引用。解决方式就是在 onPause 或 onDestroy 中调用 bluetoothLeScanner.stopScan(callback)。
善用 LeakCanary 自带的“泄漏痕迹”
其实 LeakCanary 2.x 已经帮我们做了很多工作。你点开通知栏里的泄漏报告,里面就包含了引用链的文本输出:
====================================
HEAP ANALYSIS RESULT
====================================
1 LEAK:
┬
├─ com.example.BleScanActivity
│ Leaking: YES (Activity finished)
│ GC Root: System class
│ └─ static BluetoothLeScanner.sHandler
│ └─ Handler.mQueue
│ └─ MessageQueue.mMessages
│ └─ Message.callback
│ └─ BleScanActivity$onScanResult$1 (anonymous)
╰─────────────────────────────
这个文本可以复制出来,直接发给同事看。不过它有时候会省略一些中间节点,所以我还是会再用 MAT 验证一次。我建议你把这个文本和 MAT 截图一起贴到 Jira 里,项目经理看了也放心。
#BLE01模式。
好了,解读 Heap Dump 的方法大体就是这样。不要被密密麻麻的对象吓到,一步一步来,从可疑类到引用链,再到修复验证。你手里有 LeakCanary + MAT 这两把刀,就算遇到再隐蔽的 BLE 泄漏也能拿下。