36. 内存泄漏:Binder 引用计数管理不当导致的内存泄漏案例分析

内存泄漏这问题,做 Android 开发的应该都不陌生。但 Binder 层面的泄漏,往往藏得更深。你想想看,普通 Java 对象泄漏,MAT 一抓一个准。可 Binder 引用计数导致的泄漏,很多时候你 dump 完 heap 都看不出端倪——因为泄漏的是内核里的结构体,不是 Java 堆上的东西。

我个人习惯把 Binder 的内存泄漏分成两类:一类是 用户态引用计数失衡,另一类是 内核态 Binder 节点泄漏。今天咱们就结合几个真实案例,把这两类问题掰开揉碎了讲清楚。

36.1 引用计数的底层逻辑

先快速回顾一下 Binder 的引用计数机制。Binder 驱动里维护着两个关键计数器:ref->descproc->refs_by_desc。每次你通过 BC_ACQUIREBC_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);
    }
};
避坑指南: 我曾经在一个多媒体项目中排查了整整两天,才发现是 linkToDeathunlinkToDeath 调用次数不匹配。后来我养成了一个习惯: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;
}
我的调试技巧: 遇到 Binder 泄漏时,我会先看 /proc/binder/proc 里的 refsnodes 数量。如果某个进程的 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 泄漏检测与定位方法

说了这么多案例,咱们聊聊怎么定位这类问题。我总结了一套「三板斧」的方法:

  1. 看 proc 文件系统/proc/binder/proc/[pid] 里能看到每个进程的 Binder 节点和引用数量。如果某个进程的 nodesrefs 持续增长,基本可以确定有泄漏。
  2. 抓 Binder 日志:开启 echo 1 > /d/binder/fails_on_error,然后抓取 logcat -b binder。驱动会在引用计数异常时打印详细的错误信息。
  3. 用 strace 跟踪strace -e trace=ioctl -p [pid],观察 BINDER_WRITE_READ 的调用频率和参数。如果 BC_ACQUIREBC_RELEASE 数量不匹配,一眼就能看出来。
一个实用的脚本: 我写过一个简单的 shell 脚本,每隔 5 秒抓一次 /proc/binder/proc/* 里的 refs 数量,用 diff 对比变化。哪个进程的 refs 只增不减,哪个进程就有问题。这个方法在线上环境排查时特别管用。

36.6 预防措施与最佳实践

最后说说怎么从源头上避免这类问题。我个人总结了四条铁律:

  • 成对原则:所有 Binder 相关的资源申请和释放必须成对出现。写 linkToDeath 的同时,就要写好 unlinkToDeath
  • 生命周期绑定:把 Binder 引用的生命周期和组件生命周期绑定。比如在 ServiceConnectiononServiceDisconnected 里做清理,在 ActivityonDestroy 里做 unbindService
  • 使用 WeakReference:在 DeathRecipient 的回调里,尽量使用 WeakReference 引用外部对象,避免因为死亡通知回调导致外部对象无法被 GC 回收。
  • 定期审计:在代码 review 时,专门检查 Binder 相关的引用计数操作。我团队里有个 checkstyle 规则,强制要求 linkToDeathunlinkToDeath 出现在同一个方法块内。
Binder 引用计数泄漏检测流程 步骤1:检测异常 /proc/binder/proc 持续增长 步骤2:定位进程 对比 refs 变化趋势 步骤3:分析原因 抓取 Binder 日志 是哪种泄漏? 用户态引用泄漏 link/unlink 不匹配 BC_ACQUIRE/RELEASE 失衡 内核态节点泄漏 Binder 节点僵尸化 buffer 未释放 死亡通知泄漏 反复注册未清理 DeathRecipient 堆积 检测 → 定位 → 分析 → 修复,形成闭环

说实话,Binder 引用计数泄漏这个问题,说难不难,说简单也不简单。难就难在它跨了用户态和内核态两个世界,排查起来需要同时懂 Java、C++ 和内核驱动。但只要你掌握了上面说的这些套路,再遇到类似问题,心里就有底了。

最后提醒一句:别等到线上出问题了才去排查。我建议在开发阶段就加上 Binder 引用计数的监控,比如在 ApplicationonTrimMemory 回调里检查一下 Binder 的引用数量变化。防患于未然,总比事后救火强。