36. 内存泄漏:Binder 引用计数管理不当导致的内存泄漏案例分析
内存泄漏这问题,做 Android 开发的应该都不陌生。但 Binder 层面的泄漏,往往藏得更深。你想想看,普通 Java 对象泄漏,MAT 一抓一个准。可 Binder 引用计数导致的泄漏,很多时候你 dump 完 heap 都看不出端倪——因为泄漏的是内核里的结构体,不是 Java 堆上的东西。
我个人习惯把 Binder 的内存泄漏分成两类:一类是 用户态引用计数失衡,另一类是 内核态 Binder 节点泄漏。今天咱们就结合几个真实案例,把这两类问题掰开揉碎了讲清楚。
36.1 引用计数的底层逻辑
先快速回顾一下 Binder 的引用计数机制。Binder 驱动里维护着两个关键计数器:ref->desc 和 proc->refs_by_desc。每次你通过 BC_ACQUIRE 或 BC_INCREFS 协议增加引用时,驱动都会做一次原子加操作。
说白了,这就是个经典的「谁申请,谁释放」问题。只不过 Binder 的引用计数跨越了用户态和内核态,一旦哪边多了一次 inc 或少了一次 dec,泄漏就产生了。
BC_ACQUIRE 必须对应一次 BC_RELEASE,每次 BC_INCREFS 必须对应一次 BC_DECREFS。这是铁律,没有例外。
36.2 案例一:Service 注册后未释放引用
我在项目中遇到过这样一个场景:一个系统服务在 onBind() 中返回了 Binder 对象,但调用方在 onServiceConnected() 之后,忘记调用 unlinkToDeath()。
你可能会说:「不调用 unlinkToDeath 顶多就是死亡通知收不到,怎么会泄漏?」
嗯,这里要注意了。linkToDeath 内部会调用 BC_INCREFS,增加 Binder 代理对象的引用计数。如果你只 link 不 unlink,这个引用计数就永远降不下来。当 Service 进程死亡时,驱动尝试清理这个引用,发现计数不为零,就会把这个 Binder 节点标记为「僵尸状态」,一直残留在内核里。
// 错误示例:只 link 不 unlink
private ServiceConnection mConnection = new ServiceConnection() {
@Override
public void onServiceConnected(ComponentName name, IBinder service) {
// 这里 link 了,但没地方 unlink
service.linkToDeath(mDeathRecipient, 0);
mService = IMyService.Stub.asInterface(service);
}
@Override
public void onServiceDisconnected(ComponentName name) {
// 很多人在这里忘记 unlink
// mService.asBinder().unlinkToDeath(mDeathRecipient, 0);
}
};
linkToDeath 和 unlinkToDeath 调用次数不匹配。后来我养成了一个习惯:linkToDeath 写完之后,立刻在同一段逻辑里写好对应的 unlinkToDeath,哪怕用 try-finally 包起来也要保证成对出现。
36.3 案例二:Binder 池中的引用泄漏
另一个常见场景是 Binder 线程池的引用管理。当 Binder 驱动向用户态发送 BR_TRANSACTION 时,会临时增加一次引用计数。正常情况下,用户态处理完事务后,通过 BC_FREE_BUFFER 释放,驱动就会减少这个计数。
但如果你在 native 层直接操作 Binder,自己管理 parcel 的缓冲区,就很容易漏掉 BC_FREE_BUFFER。我见过一个案例,某团队在 native 代码里用 IPCThreadState 手动处理事务,忘记调用 freeBuffer,结果每处理一次跨进程调用,内核里就多一个泄漏的 buffer 节点。
// native 层错误示例
status_t MyBinderService::onTransact(uint32_t code,
const Parcel& data,
Parcel* reply,
uint32_t flags) {
// 处理完业务逻辑后
// 忘记调用 IPCThreadState::self()->freeBuffer();
// 导致内核 buffer 泄漏
return NO_ERROR;
}
/proc/binder/proc 里的 refs 和 nodes 数量。如果某个进程的 refs 数量持续增长,基本可以断定是引用计数泄漏。再用 cat /d/binder/transaction_log 看事务日志,定位到具体是哪个 Binder 对象出了问题。
36.4 案例三:死亡通知的回调泄漏
这个案例比较隐蔽。当你在 Java 层注册 DeathRecipient 时,Binder 驱动会维护一个死亡通知列表。如果 Service 进程反复重启,每次重启都重新注册死亡通知,但旧的 DeathRecipient 对象没有被清理,就会造成泄漏。
为什么会这样?因为每次 linkToDeath 都会在驱动层创建一个新的 binder_ref_death 结构体。如果旧的 Binder 代理对象没有被 GC 回收,这些死亡通知结构体就会一直挂在驱动里。
// 错误示例:反复注册死亡通知
public void onServiceConnected(ComponentName name, IBinder service) {
// 每次连接都注册新的死亡通知
// 但旧的 mDeathRecipient 可能还在驱动里
service.linkToDeath(mDeathRecipient, 0);
// 应该先检查是否已经注册过
// if (!service.isBinderAlive()) {
// service.linkToDeath(mDeathRecipient, 0);
// }
}
36.5 泄漏检测与定位方法
说了这么多案例,咱们聊聊怎么定位这类问题。我总结了一套「三板斧」的方法:
- 看 proc 文件系统:
/proc/binder/proc/[pid]里能看到每个进程的 Binder 节点和引用数量。如果某个进程的nodes或refs持续增长,基本可以确定有泄漏。 - 抓 Binder 日志:开启
echo 1 > /d/binder/fails_on_error,然后抓取logcat -b binder。驱动会在引用计数异常时打印详细的错误信息。 - 用 strace 跟踪:
strace -e trace=ioctl -p [pid],观察BINDER_WRITE_READ的调用频率和参数。如果BC_ACQUIRE和BC_RELEASE数量不匹配,一眼就能看出来。
/proc/binder/proc/* 里的 refs 数量,用 diff 对比变化。哪个进程的 refs 只增不减,哪个进程就有问题。这个方法在线上环境排查时特别管用。
36.6 预防措施与最佳实践
最后说说怎么从源头上避免这类问题。我个人总结了四条铁律:
- 成对原则:所有 Binder 相关的资源申请和释放必须成对出现。写
linkToDeath的同时,就要写好unlinkToDeath。 - 生命周期绑定:把 Binder 引用的生命周期和组件生命周期绑定。比如在
ServiceConnection的onServiceDisconnected里做清理,在Activity的onDestroy里做unbindService。 - 使用 WeakReference:在
DeathRecipient的回调里,尽量使用WeakReference引用外部对象,避免因为死亡通知回调导致外部对象无法被 GC 回收。 - 定期审计:在代码 review 时,专门检查 Binder 相关的引用计数操作。我团队里有个 checkstyle 规则,强制要求
linkToDeath和unlinkToDeath出现在同一个方法块内。
说实话,Binder 引用计数泄漏这个问题,说难不难,说简单也不简单。难就难在它跨了用户态和内核态两个世界,排查起来需要同时懂 Java、C++ 和内核驱动。但只要你掌握了上面说的这些套路,再遇到类似问题,心里就有底了。
最后提醒一句:别等到线上出问题了才去排查。我建议在开发阶段就加上 Binder 引用计数的监控,比如在 Application 的 onTrimMemory 回调里检查一下 Binder 的引用数量变化。防患于未然,总比事后救火强。