3、Kotlin 内存管理:引用类型、生命周期与 Companion 泄漏
做 BLE 通信的 Kotlin 开发,最怕什么?我个人的第一反应就是「内存泄漏」。数据收着收着,APP 突然 OOM 了,或者蓝牙回调里的 Activity 被偷偷 hold 住,整个 UI 层就再也回不去了。今天咱们就把 Kotlin 内存管理的底牌翻一翻——强引用、软引用、弱引用、虚引用到底怎么用,对象的生命周期是如何被 GC 盯上的,还有那个最容易被忽略的 Companion 对象泄漏问题。
强引用——默认的「钉子户」
咱们平时写 val a = SomeObject(),用的就是强引用。GC 只要发现一个对象还有强引用链连着,它就不会回收这个对象。说白了,就是你把钉子钉墙上,不动它就永远在那。
| 引用类型 | 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 引用也就断了,不再泄漏。
避坑指南——我亲历过的三个泄漏
- Companion 持有 View:曾经有一个自定义 View 的 companion object 里存了它的实例作为「全局可访问」变量,结果一个 Activity 里有好几个这样的 View,Activity 销毁后 View 对象全被静态引用钉住。最后把那个 companion 字段改成
WeakReference<CustomView>才解决。 - BluetoothGattCallback 作为内部类:Kotlin 中写
btGatt.registerCallback(object : BluetoothGattCallback() { ... })时,这个匿名对象隐式持有了外部类的引用。如果外部类是个 Activity,Activity 退出时 Gatt 没关闭,回调还在,Activity 就泄漏了。正确做法是用 Application Context 注册 Gatt,或者让回调类做成静态内部类。 - 顶层 lazy 初始化 Listener:
val bleListener by lazy { createListener() }如果不是在 Application 里调用的,而是在某个 Activity 的 onCreate 里首次访问,那么bleListener就持有了那个 Activity 的引用(因为 lazy 闭包捕获了 this)。后续 GC 扫到全局单例时,这个 Activity 就被绑死了。