22、Binder 的常见问题:Binder 事务过大、Binder 线程耗尽、Binder 死锁的排查与解决。
Binder 用起来很顺手,但一旦出问题,往往让人抓狂。我在项目中遇到过好几次线上 Binder 异常,系统卡死、ANR 频发,最后定位到都是这三个问题之一:事务太大撑爆了缓冲区、线程池被占满、或者死锁导致互相等待。
今天我们就来聊聊这三个“Binder 杀手”。我会结合实战经验,告诉你它们是怎么发生的,怎么排查,以及怎么解决。
一、Binder 事务过大
Binder 传输数据是有大小限制的。默认情况下,一次 Binder 调用能传的数据不能超过 1MB(确切地说是 1MB - 2 个 page)。这个限制来自内核的 Binder 驱动,它用一块预分配的缓冲区来拷贝数据。
你想想看,如果你在 AIDL 接口里传一张 2MB 的图片,或者传一个包含几百个对象的 List,那很可能就超限了。这时候系统会抛出一个 TransactionTooLargeException。
核心原因:Binder 内核缓冲区大小固定为 1MB,且由所有正在进行的 Binder 事务共享。单个事务超过这个阈值就会失败。
1.1 如何排查?
我个人习惯先看 logcat。当发生 TransactionTooLargeException 时,系统会打印出调用栈和传输数据的大小。你可以用以下命令过滤:
adb logcat | grep -i "TransactionTooLarge"
另外,Android 8.0 之后,系统在 ActivityManager 里也加了监控。如果某个进程频繁抛这个异常,AMS 会输出警告日志。
还有一个更直接的方法——在代码里主动检查。Binder 提供了 Binder.getTransactionSize() 方法(API 29+),你可以在发送前预估一下数据大小。
1.2 怎么解决?
解决思路无非两种:压缩数据 或 分片传输。
- 压缩:如果传的是图片或序列化对象,先压缩再传。比如用 Bitmap.compress() 转成 JPEG,或者用 ProtoBuf 替代 Serializable。
- 分片:把大 List 拆成多个小批次,每次传 100 条,分 10 次传完。接收端再合并。
- 换通道:如果数据实在太大(比如几十 MB 的视频帧),就别走 Binder 了。用共享内存(MemoryFile)或者 ContentProvider 的 FileProvider 传文件路径。
避坑指南:我曾经在一个项目中,发现某个接口偶尔抛 TransactionTooLargeException,但大部分时候正常。后来一查,是因为那个接口传了一个 Bitmap,正常情况下只有 500KB,但用户拍了一张高分辨率照片后,Bitmap 解压出来直接飙到 3MB。嗯,从那以后我定了个规矩:所有 Binder 接口传图片,必须先压缩到 720p 以下。
二、Binder 线程耗尽
Binder 的线程池默认有 16 个线程(从 Android 8.0 开始,之前是 16 个,但不同版本略有差异)。如果同时有 16 个以上的 Binder 请求在等待处理,第 17 个请求就会阻塞,直到有线程空闲。
为什么会耗尽?说白了就是某个 Binder 服务端处理太慢,把线程都占住了。比如你在 UI 线程里同步调了一个 Binder 接口,而这个接口又去访问网络或者做磁盘 I/O,那它就会卡住一个 Binder 线程。如果同时有 16 个这样的调用,线程池就满了。
2.1 如何排查?
你可以用 adb shell dumpsys activity processes 查看进程的 Binder 线程状态。重点关注 binder threads 那一栏:
adb shell dumpsys activity processes | grep -A 10 "Process Record"
如果看到 Binder: 16/16 active,说明线程池已经满了。这时候任何新的 Binder 调用都会在驱动层排队,导致调用方 ANR。
另一个方法是看 /proc/[pid]/task/ 目录下的线程列表。Binder 线程的名字通常是 binder_1、binder_2 这样的格式。你可以用 ps -T -p [pid] 查看它们的运行状态。
2.2 怎么解决?
- 异步化:把同步 Binder 调用改成异步。服务端收到请求后,先返回一个 token,处理完再通过回调通知客户端。这样不会长时间占用 Binder 线程。
- 增加线程池:如果你确定服务端处理很快,只是并发量太大,可以调大 Binder 线程池上限。通过
Binder.setThreadPoolMaxSize()设置,但我不建议超过 32 个,否则上下文切换开销会很大。 - 优化服务端逻辑:检查服务端有没有在 Binder 线程里做耗时操作。比如数据库查询、网络请求、文件读写,这些都应该放到工作线程里做。
注意:千万不要在 Binder 回调里直接做耗时操作。我见过有人把图片解码写在 onTransact() 里,结果一张大图解码 200ms,16 个线程瞬间被占满。正确的做法是:onTransact() 里只做参数校验和任务投递,真正的处理交给 HandlerThread 或线程池。
三、Binder 死锁
Binder 死锁是最难排查的问题之一。它通常发生在两个进程互相等待对方释放 Binder 资源的时候。
举个例子:进程 A 持有锁 L1,然后调用进程 B 的 Binder 接口。进程 B 在处理这个请求时,需要获取锁 L2,而 L2 又被进程 A 持有(比如通过另一个 Binder 调用)。这时候就死锁了。
更隐蔽的一种情况是:Binder 线程池耗尽导致的死锁。进程 A 的 16 个 Binder 线程都在等待进程 B 的响应,而进程 B 的某个 Binder 接口又需要调用进程 A 的接口——但进程 A 已经没有空闲线程来处理这个回调了。这就形成了“线程池死锁”。
核心原因:Binder 调用是同步的,如果调用链形成环,且线程池被占满,就会死锁。
3.1 如何排查?
死锁的排查,我一般分三步走:
- 看 ANR 日志:如果系统 ANR,会在
/data/anr/traces.txt里记录所有线程的堆栈。搜索 "binder" 关键字,看看哪些线程卡在 Binder 调用上。 - 看内核日志:用
adb shell dmesg | grep -i binder,有时候内核会输出 Binder 驱动的状态信息,比如哪些节点在等待。 - 用
lsof看文件描述符:Binder 节点对应着/dev/binder的文件描述符。如果某个进程的 fd 被占满,也可能导致死锁。
还有一个高级技巧:在代码里加一个“看门狗”线程。如果某个 Binder 调用超过 5 秒没返回,就 dump 当前所有线程的堆栈。这样能快速定位死锁点。
3.2 怎么解决?
- 避免循环调用:设计接口时,尽量不要让两个服务互相依赖。如果必须互相调用,考虑用异步回调或者消息队列解耦。
- 使用 oneway 调用:如果某个 Binder 接口不需要等待返回值,声明为
oneway。这样调用方不会阻塞,也就不会占用 Binder 线程。 - 设置超时:Binder 调用默认没有超时。你可以用
Binder.setTransactionTimeout()设置一个超时时间(API 30+)。超时后驱动会主动断开连接,避免永久死锁。
避坑指南:我曾经遇到过一个死锁,排查了整整两天。最后发现是进程 A 的 Binder 线程在等待进程 B 的响应,而进程 B 在处理请求时,又通过 ContentProvider 回调了进程 A 的某个接口。但进程 A 的 Binder 线程池已经满了,所以回调永远得不到处理。解决方案很简单:把那个 ContentProvider 回调改成异步的,或者增加进程 A 的 Binder 线程池大小。嗯,从那以后我设计跨进程通信时,都会画一张调用关系图,确保没有环。
知识体系总览
下面这张图总结了 Binder 三大常见问题的核心原因、排查手段和解决方案。你可以把它当作一个快速参考。
这三个问题其实经常一起出现。事务过大导致某个 Binder 线程长时间卡在数据拷贝上,进而导致线程池耗尽,如果此时有循环依赖,就演变成死锁。所以排查的时候,我建议从最明显的异常日志入手,一步步缩小范围。
好了,Binder 的三大常见问题就聊到这里。记住:事务大小要控制,线程池要监控,调用链要避免环。做到这三点,大部分 Binder 问题都能提前规避。
公众号:蓝海资料掘金营,微信deep3321