内存泄漏基础:什么是内存泄漏、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 引用着。去掉延迟消息后问题解决。这就是引用链的实战价值。
四、整体关系一览
我把这三块东西画了张关系图,你一看就明白:
五、实际排查时怎么用这三板斧?
- 看 LeakCanary 报告:先定位泄漏对象(通常是 Activity、Fragment 或 GATT 对象)。
- 追踪引用链:从泄漏对象向上看到 GC Root,找到每一个引用节点。
- 判断泄漏原因:是静态变量?Handler?匿名内部类?Listener 没取消?
- 修复:弱引用、及时清理、手动置空。
我曾经分享一个小技巧:在 LeakCanary 的 Heap dump 里看 BluetoothGatt 对象,如果发现它通过 mApplicationContext 连到了 Application 上,那基本是某个地方把 ApplicationContext 传给了 GATT 回调,而回调又持有了 Activity。这种链很隐蔽,但画出来就清楚了。