文章内容
本文根据视频《子线程中维护的 Looper 在无消息的时候应该怎么处理减少性能问题》整理。
子线程调用 Looper.loop() 后,即使没有消息,方法也不会自动返回。看到这里的无限循环,很容易产生两个疑问:它会不会一直占用 CPU?如果不再使用这条线程,又该怎样让它结束?
这两个问题需要分开回答:暂时没有消息时,Looper 会阻塞等待,不需要额外加休眠;业务不再需要这条消息线程时,应主动调用 quit() 或 quitSafely(),让循环有机会结束。 空闲是正常状态,超过业务生命周期仍然存活,才是需要处理的资源滞留问题。
一、没有消息时,线程究竟停在哪里

子线程建立消息循环的基本顺序是:
Looper.prepare();Handler handler = new Handler(Looper.myLooper());Looper.loop();// loop() 正常返回后,才会执行后续代码。prepare() 为当前线程准备 Looper 和 MessageQueue;Handler 绑定这个 Looper;loop() 则不断取出消息,交给对应的 Handler 分发。处理回调运行在哪条线程上,由 Handler 绑定的 Looper 决定。
在 Android 14 中,单轮取消息和分发的逻辑位于 Looper.loopOnce(),外层 loop() 重复调用它。把两层合并后,核心控制流可以表示为:
// 控制流示意,省略消息回收、异常及监控等逻辑。for (;;) { Message msg = queue.next(); if (msg == null) { return; } msg.target.dispatchMessage(msg);}关键在 queue.next():它不是“没有消息就立即返回”的查询方法,而是会等待下一条可执行消息。因此,外层虽然是无限循环,却不会在空队列上持续空转。
MessageQueue.next() 通过 nativePollOnce(ptr, nextPollTimeoutMillis) 进入 native 轮询。超时参数有三种典型取值:
| 参数 | 含义 |
|---|---|
0 |
不阻塞,先检查一次 |
| 正数 | 最多等待指定毫秒数,常用于等待延迟消息到期 |
-1 |
没有可确定的下一次消息执行时间,等待唤醒事件 |
队列为空、完成必要的空闲处理后,线程可以进入无限期等待。这里的“无限期”指没有设置超时时间,并不表示无法醒来:新消息入队时,队列会根据需要唤醒它;请求退出也会触发唤醒。
二、空闲等待为什么仍然需要管理
阻塞等待期间,线程不会因为 Java 层的无限循环持续消耗 CPU,但不能据此断言整条线程的 CPU 占用永远为零。消息处理、唤醒、空闲回调以及其他监听事件仍然可能让它运行。
空闲线程也仍然占用资源:线程栈和线程管理结构没有消失,Looper、MessageQueue 及相关 native 资源仍然存在。线程局部变量、待执行消息和回调还可能保留业务对象的引用。具体内存占用取决于运行环境,不能简单归结为固定的栈大小。
如果这条线程还要持续接收任务,等待正是预期行为。相反,如果页面或任务已经结束,却每次进入页面都再创建一条永不退出的消息线程,线程数量和引用关系就会不断累积。
因此,不能把“队列当前为空”当成销毁条件。应由拥有这条线程的组件判断:以后是否还会向它提交工作? 若还会使用,就保留等待;若使用已经结束,就停止投递并请求退出。
三、quit 与 quitSafely 改变了什么

Looper 暴露两个退出入口,它们把请求转交给 MessageQueue:
public void quit() { mQueue.quit(false);} public void quitSafely() { mQueue.quit(true);}MessageQueue.quit(boolean safe) 的核心逻辑如下:
void quit(boolean safe) { if (!mQuitAllowed) { throw new IllegalStateException("Main thread not allowed to quit."); } synchronized (this) { if (mQuitting) { return; } mQuitting = true; if (safe) { removeAllFutureMessagesLocked(); } else { removeAllMessagesLocked(); } nativeWake(mPtr); }}这里的执行顺序是:先标记 mQuitting = true,再按策略移除消息,最后发出 native 唤醒信号。状态更新和消息清理使用同一把锁,next() 随后在锁内检查时,能看到完成后的队列状态。
退出标志和唤醒各有作用。标志告诉队列“不再继续接收工作”;唤醒让正在等待的线程及时回来检查这个状态。只设置标志,阻塞中的线程可能没有机会读取它;只唤醒而没有退出状态,线程仍会按正常流程取消息或继续等待。
进入退出状态后,新消息的入队请求会失败,Handler.sendMessage()、post() 等方法会返回 false。重复请求退出会直接返回;这也意味着,先调用 quitSafely() 后再调用 quit(),不能靠第二次调用改变已经选择的清理策略。上述行为见 AOSP MessageQueue。
四、“安全退出”保留哪些消息

两个方法都不会中断已经开始执行的回调,区别在于如何处理仍留在队列中的消息。
| 消息状态 | quit() |
quitSafely() |
|---|---|---|
| 已取出、正在执行 | 不主动中断 | 不主动中断 |
| 队列中已到期、可正常派发 | 移除 | 保留并继续派发 |
| 队列中尚未到期 | 移除 | 移除 |
| 退出请求生效后新投递 | 拒绝入队 | 拒绝入队 |
quitSafely() 从 API 18 起提供。它的“安全”指处理已经到期的待执行消息,不承诺等待所有延迟任务,也不表示调用方会等待线程结束。这些 API 约定见 Looper 官方文档。
在本文源码版本中,安全退出通过 SystemClock.uptimeMillis() 取得清理时刻:when 大于该时刻的消息属于未来消息,会被移除。例如,队列中有两条已经到期的普通消息和一条一分钟后执行的延迟消息,安全退出会保留前两条,丢弃第三条。
对应的清理方法不需要等待未来时间到来。removeAllFutureMessagesLocked() 找到第一条未来消息,切断链表并回收后半段;removeAllMessagesLocked() 则遍历清理整条链表。消息回收会清除其持有的业务引用,符合条件的 Message 对象可以进入对象池复用。释放引用不等于相关对象立刻被 GC,也不等于进程内存立即下降。
这里讨论的是没有同步屏障的普通工作队列。同步屏障会影响消息是否可派发,不能把“到期消息全部执行”无条件套到存在屏障的内部队列上。
五、已经标记退出,为什么还能继续处理消息

理解 quitSafely() 的关键,是 next() 内两个判断的先后顺序:先尝试返回可执行消息,再检查退出标志。 以下示意保留了这个顺序:
// 普通无屏障队列的控制流示意。Message next() { final long ptr = mPtr; if (ptr == 0) { return null; } int timeoutMillis = 0; for (;;) { nativePollOnce(ptr, timeoutMillis); synchronized (this) { long now = SystemClock.uptimeMillis(); Message msg = mMessages; if (msg != null && msg.when <= now) { mMessages = msg.next; msg.next = null; msg.markInUse(); return msg; } if (mQuitting) { dispose(); return null; } timeoutMillis = msg == null ? -1 : (int) Math.min(msg.when - now, Integer.MAX_VALUE); } }}安全退出后,保留下来的到期消息仍然可以从前面的分支返回,随后被 Handler 分发。当没有可返回的消息时,next() 才走到 mQuitting 分支,调用 dispose() 释放队列持有的 native 资源并返回 null。
完整的正常退出流程是:
- 调用
quitSafely(),队列进入退出状态并移除未来消息。 nativeWake()发出唤醒信号,使阻塞的轮询有机会返回。- 工作线程继续处理保留下来的、可正常派发的到期消息。
next()检测到退出状态,清理并返回null。Looper.loop()返回,继续执行其后的代码。- 所属线程的
run()执行完毕,线程才真正结束。
如果调用退出时线程正在执行回调,退出请求不会把它从回调里拉出来。回调若一直不返回,循环也无法进入下一轮完成退出。Thread.interrupt() 不能代替 Looper 的退出协议;业务回调需要取消时,应另行设计协作式取消逻辑。
退出也可以由工作线程自己的消息回调发起。不能把唯一的退出调用放在 Looper.loop() 后面,因为正常情况下必须先有人请求退出,后面的代码才有机会执行。
六、在业务代码中落实生命周期

手写 prepare()、创建 Handler 和 loop() 适合理解原理。实际需要一条串行消息线程时,可以用 HandlerThread 封装这些步骤,但它仍然需要明确的关闭时机。
下面的示例要求启动、投递和关闭方法均由主线程调用,并假设 API 18 及以上:
private HandlerThread workerThread;private Handler workerHandler; // 由主线程调用。private void startWorker() { if (workerThread != null) { return; } workerThread = new HandlerThread("message-worker"); workerThread.start(); workerHandler = new Handler(workerThread.getLooper());} // 由主线程调用;false 表示当前无法接收任务。private boolean submitWork(Runnable task) { return workerHandler != null && workerHandler.post(task);} // 由主线程在所属业务生命周期结束时调用。private void closeWorker() { HandlerThread thread = workerThread; workerHandler = null; workerThread = null; if (thread != null) { thread.quitSafely(); }}getLooper() 会等待已启动线程完成 Looper 初始化,避免手写示例中“主线程已经开始投递,子线程的 Handler 却尚未创建”的竞态。重复启动检查也避免了每次操作都额外创建一条消息线程。
closeWorker() 清除投递入口并请求退出,但旧线程仍可能在处理保留的任务。若立即重新启动,旧线程和新线程可能短暂并存。业务若要求前一条线程完全结束后才能重建,应由后台协调代码等待旧线程 join(),不要在主线程上无界等待。
关闭时机应跟随线程的实际所有者:仅服务当前页面的线程可以在页面销毁时关闭;跨页面工作的线程应由更长生命周期的组件管理。若剩余排队任务已经失去意义,可选择 quit();若已到期任务仍需完成,则选择 quitSafely()。两者都不能撤销正在执行的业务操作。
应用主线程的 Looper 不允许退出,对它调用这两个方法会抛出 IllegalStateException。主线程消息队列的生命周期由框架管理,不能用退出主 Looper 的方式清理页面资源。
七、测试
验证时应分别观察空闲、投递和退出,而不是只看一行线程状态:
- 在手写线程的
Looper.loop()前后记录日志。启动后只看到前面的日志;投递消息后可以看到处理日志;正常退出后才看到后面的日志。 - 空闲时查看线程栈,通常能看到
MessageQueue.nativePollOnce。不同工具的状态标签可能不同,不能仅凭WAITING或RUNNABLE判断它是否忙等。 - 关闭后,在后台等待线程结束,再检查
isAlive()或线程列表。退出方法已经返回,不代表工作线程此刻已经终止。
对比 quit() 和 quitSafely() 时,还要控制并发顺序。若先投递消息再立即从主线程退出,工作线程可能已经取走部分消息,不能断言 quit() 一定让所有消息都不执行。
更稳定的实验方式是:先让工作线程进入一个由测试控制的等待回调;确认回调已经开始后,排入立即消息和足够远期的延迟消息;接着请求退出,最后释放当前回调。在普通无屏障队列中,quit() 会丢弃仍排队的消息,quitSafely() 会继续处理已到期消息,两者都会丢弃清理时尚未到期的消息。等待控制只用于测试,必须确保最终释放。
回到最初的问题,真正需要管理的是消息线程的使用期限。
- Looper 已经通过阻塞等待解决了空队列上的忙等问题;
- 业务代码要补上的是停止投递、请求退出,以及在有需要时确认线程已经结束。
本文由 AgentWorkFlow 生成