当 handleMessage 返回后,Message 已经被清洗回收——而你的协程才刚刚被调度执行。深入源码,揭开这个隐藏颇深的时序 Bug。
众所周知,调用 Message.obtain() 时,Message 会优先检查缓存池中有没有可复用的实例。如果有就直接取出,没有才会 new:
public static Message obtain() {
synchronized (sPoolSync) {
if (sPool != null) {
Message m = sPool;
sPool = m.next;
m.next = null;
m.flags = 0; // clear in-use flag
sPoolSize--;
return m;
}
}
return new Message();
}
Message 内部维护了一个单向链表缓存池,sPool 是链表头,最大容量 50(MAX_POOL_SIZE)。每次 obtain 从链表头部取一个节点,每次 recycle 把节点插回链表头部——这是一个典型的 LIFO 栈结构。
Message 缓存池的本质就是一个用 next 字段串联起来的单向链表。同一个 Message 对象在"使用中 → 回收 → 再分配 → 使用中"之间不断循环,从未被 GC。
Handler 是 Android 消息机制的核心,每秒可能处理成百上千条消息。如果每条消息都用 new Message(),会产生大量临时对象,频繁触发 GC,导致 UI 卡顿。
对象池的设计在 Android 中随处可见——Message、MotionEvent、Parcel 等都采用了类似的模式。核心思路就是:用过的对象不要丢弃,洗干净了下次再用。
Looper 的核心就是一个死循环,不断从 MessageQueue 取消息、分发、回收:
public static void loop() {
final Looper me = myLooper();
if (me == null) {
throw new RuntimeException("No Looper; Looper.prepare() wasn't called on this thread.");
}
// ... 省略部分初始化代码 ...
for (;;) {
if (!loopOnce(me, ident, thresholdOverride)) {
return;
}
}
}
loop() 本身只是一个外壳,真正干活的是 loopOnce()。下面保留核心逻辑,删除性能监控等非关键代码:
private static boolean loopOnce(final Looper me,
final long ident, final int thresholdOverride) {
Message msg = me.mQueue.next(); // might block —— 步骤1:取出消息
if (msg == null) {
// MessageQueue 已退出,Looper 也该退出了
return false;
}
// ... 性能监控、观察者回调等 ...
try {
msg.target.dispatchMessage(msg); // 步骤2:分发处理
if (observer != null) {
observer.messageDispatched(token, msg);
}
} catch (Exception exception) {
if (observer != null) {
observer.dispatchingThrewException(token, msg, exception);
}
throw exception;
} finally {
ThreadLocalWorkSource.restore(origWorkSource);
// ...
}
// ... trace 收尾 ...
msg.recycleUnchecked(); // 步骤3:回收消息
return true;
}
关键点:无论成功或抛异常,步骤3 一定会执行。recycleUnchecked() 不在 try-catch 中,也不在 finally 中——它是在整个 try-catch-finally 块结束之后、return 之前执行的。这意味着即使 dispatchMessage 抛出异常被捕获并重新抛出,recycleUnchecked() 依然会被调用。
msg.target 是一个 Handler 类型的字段:
@UnsupportedAppUsage
/*package*/ Handler target;
调用链进入 Handler:
public void dispatchMessage(@NonNull Message msg) {
if (msg.callback != null) {
handleCallback(msg); // 方式1:Message 自带 Runnable
} else {
if (mCallback != null) {
if (mCallback.handleMessage(msg)) {
return; // 方式2:Handler 的 Callback 拦截
}
}
handleMessage(msg); // 方式3:我们最熟悉的重写方法
}
}
这里就是 handleMessage(msg) 被调用的地方——也就是我们重写的那个方法。
handleMessage 返回后,dispatchMessage 也返回,紧接着就是 recycleUnchecked():
@UnsupportedAppUsage
void recycleUnchecked() {
// 标记为"使用中"状态,防止在缓存池中被重复回收
flags = FLAG_IN_USE;
what = 0;
arg1 = 0;
arg2 = 0;
obj = null;
replyTo = null;
sendingUid = UID_NONE;
workSourceUid = UID_NONE;
when = 0;
target = null;
callback = null;
data = null; // 注意这里:Bundle 被置空
synchronized (sPoolSync) {
if (sPoolSize < MAX_POOL_SIZE) {
next = sPool;
sPool = this;
sPoolSize++;
}
}
}
顺便提一下,msg.recycle() 最终也是调用 recycleUnchecked(),只是多了一个 in-use 检查:
public void recycle() {
if (isInUse()) {
if (gCheckRecycle) {
throw new IllegalStateException("This message cannot be recycled because it "
+ "is still in use.");
}
return;
}
recycleUnchecked();
}
把以上步骤串联起来,一条 Message 在 Handler 世界中的完整旅程如下:
核心结论:一旦 handleMessage 执行完毕并返回,dispatchMessage 调用栈随即退栈,loopOnce() 紧接着调用 recycleUnchecked() 将 msg 的所有字段清零并丢入缓存池。不存在任何延迟——这是同步发生的。
来看一个看似正常的例子。两个 Handler 分别运行在两个独立线程中,相互通过协程发送消息:
class HandlerTest() {
var handlerA: Handler? = null
var handlerB: Handler? = null
fun start() {
// 线程A:运行 handlerA
Thread {
Looper.prepare()
handlerA = object : Handler(Looper.myLooper()!!) {
override fun handleMessage(msg: Message) {
val count = msg.data.getInt("count")
println("HandlerTest A:$count")
CoroutineScope(Dispatchers.IO).launch {
// BUG: 此时 msg 可能已被回收!
println("HandlerTest A Coroutine: ${msg.data.getInt("count")}")
delay(1000)
handlerB?.sendMessage(
Message.obtain().apply {
data = Bundle().apply {
putInt("count", count + 1)
}
}
)
}
}
}
Looper.loop()
}.start()
// 线程B:运行 handlerB
Thread {
Looper.prepare()
handlerB = object : Handler(Looper.myLooper()!!) {
override fun handleMessage(msg: Message) {
val count = msg.data.getInt("count")
println("HandlerTest B:$count")
CoroutineScope(Dispatchers.IO).launch {
delay(1000)
handlerA?.sendMessage(
Message.obtain().apply {
data = Bundle().apply {
putInt("count", count + 1)
}
}
)
}
}
}
Looper.loop()
}.start()
// 等待两个线程初始化完成后发送初始消息
Thread.sleep(500)
handlerA?.sendMessage(
Message.obtain().apply {
data = Bundle().apply {
putInt("count", 0)
}
}
)
}
}
运行上面的代码,日志输出如下:
HandlerTest B:1
HandlerTest A:2
HandlerTest A Coroutine: 0 // 期望读到 2,实际读到 0!
HandlerTest B:3
HandlerTest A:4
HandlerTest A Coroutine: 0 // 期望读到 4,实际读到 0!
HandlerTest B:5
HandlerTest A:6
HandlerTest A Coroutine: 0 // 又是 0!
可以看到,handleMessage 中直接访问 msg.data.getInt("count") 能正确读到计数值,但协程内部访问同一个 msg 的 data 却永远返回 0。
这是一个真实的、容易被忽视的 Bug。代码逻辑看起来完全正确——你在 handleMessage 里拿到了 msg 的引用,然后在协程里用它——协程闭包捕获了这个引用,凭什么读不到?
问题的根源在于两个时间线的错位:
这一切在同一个调用栈中同步完成,耗时微秒级。
协程的调度和线程切换需要时间,远慢于回收。
具体来说:
handleMessage 中调用 launch { },只是提交了一个任务给协程调度器,并不等待它执行。launch 返回后,handleMessage 方法立即结束。loopOnce() 紧接着调用 recycleUnchecked(),将 msg 的所有字段清空。Dispatchers.IO 的某个线程上开始执行,而它闭包捕获的 msg 已经被"洗白"了。一句话:handleMessage 是同步的,协程是异步的。handleMessage 一返回,msg 就被回收了。协程还没来得及执行,msg 已经是一张白纸。
用一张图来看清 Handler 线程和协程调度之间的时序错位:
步骤 3 和 4 之间是经典的竞争条件:回收总是先于协程执行。这不是偶然的——Handler 线程的回收是同步的、即时的;而协程需要经历调度器排队、线程分配、上下文切换等异步开销。
在 Kotlin 中写 msg.data,编译器实际调用的是 msg.getData():
public Bundle getData() {
if (data == null) {
data = new Bundle();
}
return data;
}
关键在这里:recycleUnchecked() 已经把 data 设为 null 了。所以 getData() 发现 data == null,就创建一个全新的空 Bundle 返回。
接着,Bundle.getInt("count") 的实现如下:
public int getInt(String key) {
unparcel();
return getInt(key, 0); // 默认值 0
}
新创建的空 Bundle 里根本没有 "count" 这个键,所以 getInt 返回默认值 0。
整个链条层层相扣,每一步看起来都"没毛病"——recycleUnchecked 清理数据是正常操作,getData 创建空 Bundle 是防御性设计,getInt 返回默认值是 API 约定。但组合在一起,就变成了一个静默吞掉数据的陷阱。
有些同学可能会问:recycleUnchecked() 不是把 flags 设为 FLAG_IN_USE 了吗?那之后调用 recycle() 不是会抛异常吗?
注意区分:FLAG_IN_USE 的作用是防止同一个 Message 被重复回收(即防止连续两次调用 recycle()),而不是阻止其他线程访问该 Message 的字段。FLAG_IN_USE 只是一个标志位,不提供任何并发保护。
更准确地说,当你从协程中访问 msg.data 时,msg 对象本身当然还是存在的(它只是在缓存池中,没有被 GC),但它的字段已经被清空了。你访问的是一个"洗干净等待下次使用的碗",而不是"被锁住不能用的碗"。
读到 0 至少是可预期的、可复现的。但还有一种更隐蔽的情况:
如果在 Message 被回收后、协程执行前,这个 Message 恰好被另一次 obtain() 复用并写入了新数据,协程将读到完全不属于当前业务的脏数据。
这个问题的可怕之处在于:
举个具体的例子。假设你有两个不同的业务消息——"加载用户信息"和"提交订单"——共用同一个 Message 缓存池。如果"加载用户信息"的 msg 在回收后、协程读取前,恰好被"提交订单"的 obtain 复用并写入了订单数据,那么协程读到的用户信息中可能混入了订单 ID 等不相关数据。
结果错误但固定,容易发现
结果随机且诡异,极难复现
最直接、最安全的做法:在 handleMessage 返回之前,把需要的数据全部提取出来,协程只使用提取后的值,不持有 msg 引用。
override fun handleMessage(msg: Message) {
// 1. 在 handleMessage 返回前提取所有需要的数据
val count = msg.data.getInt("count")
val name = msg.data.getString("name")
val data = msg.data.getBundle("extra")
// 2. 协程只使用局部变量,不持有 msg 引用
CoroutineScope(Dispatchers.IO).launch {
println("HandlerTest A Coroutine: $count") // 安全!count 是 Int 值类型
delay(1000)
handlerB?.sendMessage(
Message.obtain().apply {
data = Bundle().apply {
putInt("count", count + 1)
putString("name", name)
putBundle("extra", data)
}
}
)
}
}
这是最佳实践。在 handleMessage 的同步上下文中完成所有数据提取,协程只持有值类型或不可变对象的引用。
如果协程中的逻辑必须使用 Message 中的复杂数据结构,可以在 handleMessage 中同步拷贝一份:
override fun handleMessage(msg: Message) {
// 深拷贝 msg 中的数据
val msgData = Bundle(msg.data) // Bundle 的拷贝构造
val msgWhat = msg.what
val msgArg1 = msg.arg1
CoroutineScope(Dispatchers.IO).launch {
// 使用拷贝后的数据,与原 msg 完全解耦
val count = msgData.getInt("count")
// ...
}
}
这个问题不限于 Handler 和协程。任何"同步方法返回后对象被回收/修改,但异步回调还持有该对象引用"的场景,都面临同样的问题。
比如以下场景同样危险:
onBindViewHolder 中启动协程,协程内持有 ViewHolder 的 positiononClick 中 postDelayed 一个 Runnable,闭包捕获了外部可变状态核心原则:同步上下文负责"读",异步上下文负责"算"。永远在同步边界内完成数据提取,异步部分只处理不可变的值。
handleMessage 返回的那一刻,msg 就已经不属于你了——在同步边界内完成数据提取,不要让异步代码持有已回收对象的引用。
这个 Bug 的本质是对象池模式下引用生命周期与使用生命周期的错位。对象池假设对象只在"取出 → 使用 → 归还"这个同步区间内有效,而协程打破了这一假设——对象归还后仍有引用被异步持有。
这提醒我们:在设计 API 时,如果对象来自对象池,应该在文档中明确标注"此对象在方法返回后失效",或者在设计上就避免将池对象暴露给异步逻辑。Kotlin 协程的 channel.receive() 在设计上就没有这个问题——它返回的是消息的副本,而非池对象引用。