3、Kotlin 内存管理:引用类型、生命周期与 Companion 泄漏

做 BLE 通信的 Kotlin 开发,最怕什么?我个人的第一反应就是「内存泄漏」。数据收着收着,APP 突然 OOM 了,或者蓝牙回调里的 Activity 被偷偷 hold 住,整个 UI 层就再也回不去了。今天咱们就把 Kotlin 内存管理的底牌翻一翻——强引用、软引用、弱引用、虚引用到底怎么用,对象的生命周期是如何被 GC 盯上的,还有那个最容易被忽略的 Companion 对象泄漏问题。

强引用——默认的「钉子户」

咱们平时写 val a = SomeObject(),用的就是强引用。GC 只要发现一个对象还有强引用链连着,它就不会回收这个对象。说白了,就是你把钉子钉墙上,不动它就永远在那。

避坑提示: 我早期做 BLE 扫描时,把扫描回调直接写成了匿名内部类,结果这个类隐式持有了外部 Activity 的引用,Activity 退出了还在接收 BLE 广播,GC 完全动不了它。后来一查 MAT dump,那个 Activity 实例躺了十几个。
引用类型GC 回收时机典型用途
强引用永不回收(只要有可达路径)常规对象持有
软引用内存不足时回收缓存(图片、数据)
弱引用每次 GC 都可能回收观察者、回调、避免泄漏
虚引用任何时候都可能回收对象回收跟踪(NIO DirectBuffer)

软引用、弱引用、虚引用——三种「软柿子」

为什么要引入这些非强引用?我打个比方:强引用是你自己牢牢抓着的杯子,软引用是「杯子放在桌上但没动手」,弱引用是「杯子放在滑溜的斜坡上」,虚引用呢?你甚至连杯子在哪都不知道,只能收到它摔碎的通知。

  • 软引用(SoftReference):Java 保证在发生 OOM 之前,会把所有软引用对象清掉。适合做缓存,但要注意 Android 2.3 之后 GC 更积极回收软引用,所以别太依赖它做“永不丢失”的缓存。
  • 弱引用(WeakReference):只要 GC 一轮开始,弱引用对象就会被清理。最常见的场景就是 WeakReference<Activity> 传给后台线程,防止 Activity 被线程 hold 住。我记得有一次写 BLE 连接管理器,把所有回调监听器改成弱引用后,泄漏率直接降了 80%。
  • 虚引用(PhantomReference):你拿不到对象本身,只能通过引用队列知道它被回收了。主要用于 NIO 的 DirectByteBuffer 清理,普通业务代码用得少。
重点: 弱引用不能根除泄漏,只能简化预防。如果弱引用指向的对象被别的强引用链连着,它依然不会被回收。

Kotlin 对象生命周期与 GC 可达性

Kotlin 对象生命周期和 Java 一样分三个阶段:创建 → 可达 → 不可达 → 回收。但 Kotlin 多了不少语法糖,比如 lambda、object 表达式、内联类,这些很容易在你看不到的地方产生隐式引用。

GC 判断是否能回收,靠的是「可达性分析」。从 GC Roots(静态字段、栈帧本地变量、JNI 引用等)出发,扫一遍对象图。扫不到的,就是垃圾。扫得到的,再看是不是软/弱/虚引用——如果是软/弱引用且引用队列里已入队,那也可以回收。

// 一个常见的 GC Root 链示例(导致泄漏)
class BleManager {
    private val listeners = mutableListOf<BleCallback>()

    fun register(callback: BleCallback) {
        listeners.add(callback)  // 这里如果外部 Activity 的 callback 引用了 Activity,就泄漏了
    }
}

为什么上面会泄漏?因为 listeners 是 BleManager 的强引用字段,BleManager 本身往往是单例(GC Root)。于是整个链就变成了:
GC Root → BleManager → listeners → callback → Activity。
Activity 永远不可达 GC,躺在那占内存。

静态属性与 Companion 对象——Kotlin 泄漏的重灾区

Kotlin 的 Companion 对象其实就是一个静态内部类,它的成员是静态字段。你只要在 Companion 里放一个 var context: Context,那整个 Activity 就彻底别想回收了。我在项目中遇到过不止一次:开发同学为了方便,在 Utils 类的 Companion 里缓存了一个 BluetoothGatt 实例,结果退出页面后那个 Gatt 还连着底层,Activity 也被它强引用着。

⚠️ 静态属性泄漏典型场景:
  • Companion 对象中持有 Activity / Fragment / View 引用
  • 顶层属性(top-level property)直接赋值 val bleManager = BleManager(),单例的本质是静态字段
  • 伴生对象中注册监听器,但忘记注销

怎么修?我个人的习惯是:不在 Companion 或顶层属性中存放任何生命周期敏感对象的引用。实在要缓存,用 WeakReference 或者使用 Application Context。下面是我常用的一个模式:

class BleCallbackHolder {
    companion object {
        // 用弱引用代替强引用
        private val callbacks = mutableListOf<WeakReference<BleCallback>>()

        fun register(cb: BleCallback) {
            // 清理无效引用后再添加
            callbacks.removeAll { it.get() == null }
            callbacks.add(WeakReference(cb))
        }

        fun notifyAll(data: ByteArray) {
            callbacks.forEach { it.get()?.onDataReceived(data) }
        }
    }
}

你想想看,用弱引用之后,即使外部 Activity 销毁了,GC 标记它不可达,下轮 GC 就会清掉那些弱引用对象。BleCallback 拿到的 Activity 引用也就断了,不再泄漏。

Kotlin 引用类型与 GC 可达性关系 GC Roots 强引用 StrongRef 软引用 SoftRef 弱引用 WeakRef 虚引用 PhantomRef 可达 (Reachable) 不可达 (Unreachable) ⚠️ Companion/静态属性 = GC Root 强引用链将阻止任何对象回收 ✅ 弱引用 + Application Context 切断 Activity 泄漏链
我的小技巧: 每次写完 BLE 相关的回调注册,我都会在 onDestroy 里主动把监听列表清空。即使用了弱引用,也尽量做到显式移除,双重保险。

避坑指南——我亲历过的三个泄漏

  1. Companion 持有 View:曾经有一个自定义 View 的 companion object 里存了它的实例作为「全局可访问」变量,结果一个 Activity 里有好几个这样的 View,Activity 销毁后 View 对象全被静态引用钉住。最后把那个 companion 字段改成 WeakReference<CustomView> 才解决。
  2. BluetoothGattCallback 作为内部类:Kotlin 中写 btGatt.registerCallback(object : BluetoothGattCallback() { ... }) 时,这个匿名对象隐式持有了外部类的引用。如果外部类是个 Activity,Activity 退出时 Gatt 没关闭,回调还在,Activity 就泄漏了。正确做法是用 Application Context 注册 Gatt,或者让回调类做成静态内部类。
  3. 顶层 lazy 初始化 Listener:val bleListener by lazy { createListener() } 如果不是在 Application 里调用的,而是在某个 Activity 的 onCreate 里首次访问,那么 bleListener 就持有了那个 Activity 的引用(因为 lazy 闭包捕获了 this)。后续 GC 扫到全局单例时,这个 Activity 就被绑死了。