4、内存泄漏检测工具:LeakCanary原理与使用、MAT分析、Android Profiler实战

内存泄漏,说白了就是本该被回收的对象,因为被某个地方一直引用着,GC拿它没办法。时间一长,堆内存越吃越多,最后就是OOM。我见过不少项目,线上崩溃有一半都是这个原因。今天咱们就把三把利器——LeakCanary、MAT、Android Profiler——挨个捋一遍。

4.1 LeakCanary:自动化的泄漏侦探

LeakCanary是我个人最常用的工具。它不需要你手动dump堆,也不需要你懂太多底层原理。集成之后,一旦发生泄漏,通知栏直接弹出来,告诉你“哥们,这里漏了”。

4.1.1 原理:它怎么知道漏了?

核心思路其实很简单:监听Activity或Fragment的onDestroy。当它们销毁后,LeakCanary会等几秒,然后手动触发一次GC。如果对象还在,那就说明被某个引用链拽住了。

具体流程是这样的:

  • 通过Application.registerActivityLifecycleCallbacks监听Activity生命周期
  • 在onDestroy后,将Activity对象放入一个弱引用(WeakReference)
  • 等待5秒,触发GC,再检查弱引用是否被回收
  • 如果没回收,就dump堆快照,分析引用链

嗯,这里要注意:它用的是弱引用+引用队列的组合。弱引用指向Activity,如果Activity本该被回收,弱引用就会进入引用队列。如果队列里没出现,那就说明还有强引用在。

核心代码片段(简化版):

// 伪代码,展示核心逻辑
class RefWatcher {
    private val queue = ReferenceQueue<Any>()
    
    fun watch(watchedObject: Any) {
        val weakRef = KeyedWeakReference(watchedObject, queue, key)
        // 延迟5秒后检查
        checkRetained(weakRef)
    }
    
    private fun checkRetained(ref: KeyedWeakReference) {
        // 手动触发GC
        Runtime.getRuntime().gc()
        System.runFinalization()
        // 检查引用队列
        if (ref.get() != null) {
            // 泄漏了!dump堆
            dumpHeap()
        }
    }
}

4.1.2 使用:三步搞定

集成LeakCanary非常简单。我习惯在debug模式下开启,release下关闭。

// build.gradle
dependencies {
    debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.12'
    releaseImplementation 'com.squareup.leakcanary:leakcanary-android-no-op:2.12'
}

// Application中无需额外代码,自动初始化
class MyApp : Application() {
    override fun onCreate() {
        super.onCreate()
        // 2.x版本自动注册,不需要手动写
    }
}

跑起来之后,如果有泄漏,通知栏会弹出。点进去就能看到完整的引用链。我曾经在一个老项目里,一打开就发现十几个泄漏,全是静态变量持有Activity导致的。

避坑指南:我曾经遇到过LeakCanary误报的情况。比如某些三方SDK在后台持有Activity引用,但其实是设计如此。这时候可以加@IgnoreLeak注解跳过,或者配置白名单。

4.2 MAT:堆快照的显微镜

LeakCanary能告诉你“漏了”,但有时候你想自己深挖。比如看看某个对象到底占了多少内存,或者对比两个堆快照的差异。这时候MAT就派上用场了。

4.2.1 获取堆快照

你可以用Android Studio的Profiler直接dump,也可以用adb命令:

// 先找到进程ID
adb shell ps | grep com.your.package
// dump堆到文件
adb shell am dumpheap com.your.package /data/local/tmp/heap.hprof
// 拉取到本地
adb pull /data/local/tmp/heap.hprof .

注意:Android导出的hprof格式和标准Java不一样,需要用Android Studio的sdk工具转换一下:

// 转换命令
hprof-conv heap.hprof heap-converted.hprof

4.2.2 常用分析技巧

打开MAT后,我一般先看这几个视图:

  • Histogram:列出所有类的实例数量和占用大小。按Retained Heap排序,最大的往往就是问题所在。
  • Dominator Tree:展示对象之间的支配关系。说白了就是谁“控制”着谁,谁占了大头。
  • Path to GC Roots:从泄漏对象出发,找到GC Roots的引用链。这是定位泄漏的杀手锏。

举个例子:你发现Bitmap对象特别多,右键选择“Merge Shortest Paths to GC Roots”,排除软引用、弱引用,剩下的就是强引用链。我经常在这里发现某个静态集合里存了一堆图片没清理。

注意:MAT分析时,如果堆文件太大(超过1GB),建议加大MAT的启动内存。在MemoryAnalyzer.ini里修改-Xmx参数,比如-Xmx2048m。

4.3 Android Profiler:实时监控的仪表盘

LeakCanary是事后诸葛亮,MAT是事后解剖。而Android Profiler可以让你在运行时实时观察内存变化。我个人习惯先开Profiler跑一遍场景,看到内存只增不减,再上LeakCanary定位。

4.3.1 基本操作

在Android Studio中,点击底部的“Profiler”标签,选择你的设备和进程。然后切换到“Memory”面板。你会看到一条实时曲线,代表Java堆、Native堆、代码、栈等各部分的内存占用。

我常用的几个操作:

  • 点击“Dump Java Heap”:手动抓取当前堆快照,可以查看所有对象。
  • 点击“Record Allocation”:记录一段时间内的对象分配情况。适合排查“谁在疯狂new对象”。
  • 点击“Force GC”:手动触发GC,看看内存能不能降下来。

4.3.2 实战:定位频繁GC

有一次我遇到一个页面滑动特别卡。打开Profiler一看,内存曲线像锯齿一样,每隔一两秒就掉一次。这说明GC太频繁了。

我点了“Record Allocation”,然后滑动页面几秒钟。停止记录后,按包名过滤,发现有个工具类每秒都在new一个StringBuilder。其实完全可以用复用对象来解决。改完之后,曲线平滑多了。

实战小技巧:在Profiler中,你可以按住Shift键框选一段内存曲线,然后点击“Dump”只导出这一段时间的堆快照。这样文件小,分析起来也快。

4.4 三把利器的配合使用

说了这么多,它们到底怎么配合?我一般按这个流程来:

  1. 先用Android Profiler跑一遍,看看整体内存走势。如果发现内存只增不减,或者GC频繁,说明有问题。
  2. 集成LeakCanary,让它自动检测泄漏。通知栏弹出后,直接看引用链,大部分问题都能在这里解决。
  3. 遇到复杂情况,比如Native泄漏或者大对象问题,用MAT深挖。对比两个时间点的堆快照,找出差异对象。

举个例子:有一次线上OOM,Profiler看到Native堆涨得厉害。LeakCanary查不到,因为它是Java层的。我用MAT对比了正常和异常时的堆快照,发现有个线程池创建了大量线程没销毁。嗯,最后定位到是某个三方库的bug。

个人建议:不要等到线上出问题才想起来用这些工具。每次开发新功能,都跑一遍Profiler看看内存变化。养成习惯后,很多泄漏在开发阶段就被扼杀了。

好了,内存泄漏检测这块就聊到这儿。下一章咱们聊聊更底层的——如何从源码层面优化内存分配策略。你想想看,如果能控制对象在哪个区域分配,那性能提升可不是一星半点。