内存泄漏基础:什么是内存泄漏、GC Root、引用链

咱们写 Android 的,谁还没被内存泄漏坑过?

我记得刚入行那会儿,接手一个蓝牙配件的 App,连上设备后滑动列表越来越卡,到最后直接闪退。当时我还以为是 BLE 协议栈的问题,捣鼓了半天。后来用 LeakCanary 一跑,好家伙,GATT 对象泄漏了,十几个 Activity 副本挂在堆里。嗯,从那时起我才真正开始理解内存泄漏这回事。

今天咱们就把最基础的概念打扎实:什么是内存泄漏、GC Root 是什么、引用链怎么走。这些听起来很理论,但实际排查的时候,少了任何一个都抓不到根。

一、什么是内存泄漏

说白了,内存泄漏就是该回收的对象,因为被不恰当引用着,导致 GC 没办法把它请走。

Java / Kotlin 有自动垃圾回收(GC),但它只回收从GC Root出发不可达的对象。如果一个对象本来应该结束生命周期(比如 Activity 的 onDestroy 执行了),但莫名其妙还有一条引用链连着它,那它就成了“僵尸对象”,占着内存不放。

举个例子:你打开一个蓝牙设备列表 Activity,然后退出,正常情况下该 Activity 会被 GC 回收。但如果某个静态变量或者匿名内部类持有了它的引用,那 Activity 对象就会泄漏。

注意:泄漏不是“一次爆掉”。它是缓慢消耗的过程。内存像沙漏一样一点点漏,直到 OOM 或者卡顿到用户卸载 App。

我在项目中遇到过最典型的 GATT 泄漏场景:做 BLE 连接时,在 Service 里注册了 BroadcastReceiver,但退出时忘记 unregister,导致 Service 对象泄漏,连带它持有的 BluetoothGatt 对象也被捆绑着。一个 GATT 连接占几百 KB,开十几个设备就撑不住了。

二、GC Root:垃圾回收的“起点”

GC 从哪开始找活对象?它的起点就是GC Root。GC Root 是堆外的一些引用,GC 把它们当成“根”,然后沿着引用链遍历,所有可达的对象都是活的,不可达的就是垃圾。

常见的 GC Root 有哪些?我列个表:

GC Root 类型 说明
静态变量(Static) 类加载后一直存在的引用,最容易被忽略
当前线程(Thread) 包括主线程和后台线程,线程的局部变量都是根
系统类的内部引用 例如 ActivityThread、Application 等系统组件
JNI 全局引用 Native 层通过 JNI 持有的对象
栈中的局部变量 方法执行期间引用的对象

你想想看,假如一个静态变量 sGatt 指向了你刚刚打开的 BluetoothGatt 对象,那么这个 GATT 对象就成了 “假活” —— 它被 GC Root 直达,哪怕你的 Activity 早就 finish 了,它也不会被回收。

我的建议:排查泄漏时,先盯住静态变量和单例。我至少有三次修复泄漏,最后发现都是静态集合里忘了 remove。

三、引用链:找到泄漏的“线索”

有了 GC Root,下一个概念就是引用链。GC 从根出发,经过一系列引用,到达一个对象。这条路就是引用链。

反过来看,泄漏链就是从泄漏对象反推回 GC Root 的那条路径。LeakCanany 呈现的就是这条链。你看它的 report,最下面通常是 GC Root,中间是各种中间对象,最上面是泄漏对象。

举个例子:

┌── GC Root: System Class (android.app.ActivityThread)
│
├─ android.app.ActivityThread$ApplicationThread实例
│   └─ 静态字段 mH
│       └─ android.os.Handler实例
│           └─ 成员字段 mCallback
│               └─ com.example.MyActivity$1实例 (匿名内部类)
│                   └─ 隐式持有外部类引用
│                       └─ com.example.MyActivity实例 (已销毁,但未被回收)

这条链告诉我们:ActivityThread 作为 GC Root,通过 Handler 和匿名内部类,把我们本该释放的 Activity 给“绑架”了。

要点:LeakCanary 能精准地抓出泄漏点,但前提是我们得看懂这条链。链上每一个节点都是一个引用。如果你看到熟悉的类,基本就能定位原因。比如看到 BluetoothGattCallback 的匿名内部类,那多半是回调没取消注册。

我记得有一次排查 GATT 泄漏,链上出现了 BluetoothDevice 和 BluetoothGatt,中间夹着一个 Handler。后来发现代码里用 Handler 延迟发消息重连,但执行完没 removeCallbacks,导致 GATT 对象一直被 Handler 引用着。去掉延迟消息后问题解决。这就是引用链的实战价值。

四、整体关系一览

我把这三块东西画了张关系图,你一看就明白:

内存泄漏、GC Root 与引用链关系 GC Root 起点 静态变量 活跃线程栈 JNI 全局引用 引用链 (Reference Chain) 持有者对象 中间引用 泄漏对象 泄漏对象通过引用链可达 GC Root,所以 GC 认为它“活着”,永远不会回收。

五、实际排查时怎么用这三板斧?

  1. 看 LeakCanary 报告:先定位泄漏对象(通常是 Activity、Fragment 或 GATT 对象)。
  2. 追踪引用链:从泄漏对象向上看到 GC Root,找到每一个引用节点。
  3. 判断泄漏原因:是静态变量?Handler?匿名内部类?Listener 没取消?
  4. 修复:弱引用、及时清理、手动置空。

我曾经分享一个小技巧:在 LeakCanary 的 Heap dump 里看 BluetoothGatt 对象,如果发现它通过 mApplicationContext 连到了 Application 上,那基本是某个地方把 ApplicationContext 传给了 GATT 回调,而回调又持有了 Activity。这种链很隐蔽,但画出来就清楚了。