尧图精选

深入解析Android Looper:从消息循环到主线程机制

🕒 发布时间:2026/9/13 3:56:57 📁 来源:尧图网络
Cant create handler inside thread that has not called Looper.prepare()这行红色日志几乎每个写过 Android 的开发者都见过。第一次遇到它时我以为只是自己 new Handler 的姿势不对后来把 Looper 源码翻了一遍才明白这背后藏着一整套贯穿线程调用栈的消息驱动模型。很多人用 Handler 发消息、更新 UI 都挺熟练但一旦被问到 Looper 是什么、prepare() 做了什么、loop() 为什么是个死循环就卡壳了。这篇文章我就围绕 Looper 这个核心把它的初始化、循环机制、消息分发、主线程特殊性和实战踩坑一次性讲透。既适合刚接触 Handler 机制的新人建立整体认知也适合准备面试、想深入源码细节的开发者查漏补缺。1. prepare() 与 ThreadLocalLooper 如何把自己钉在线程上1.1 从那个天天见的崩溃异常说起先还原一下典型的崩溃现场在子线程里执行了类似这样的代码——新建一个 Handler 并发送消息然后 App 直接抛异常退出。Thread { // 在子线程中直接创建 Handler Handler handler new Handler() handler.post { // 期望回到这个子线程执行任务 } }崩溃信息指向Handler()的构造函数内部最终落到Looper.myLooper()返回了 null于是抛出RuntimeException: Cant create handler inside thread that has not called Looper.prepare()。这个异常的字面意思非常直白你现在所在的线程没有调用过 Looper.prepare()也就是说这个线程还没有一个可用的 Looper所以 Handler 不知道把消息往哪送。换到主线程就不会有问题因为系统在 App 启动阶段已经帮主线程准备好了 Looper。1.2 ThreadLocal每个线程的专属变量阁楼要理解 Looper 为什么和线程强绑定得先看 ThreadLocal 这个数据结构。Java 里每个 Thread 对象内部都维护了一个 ThreadLocalMap可以把它想象成每个线程专属的储物柜线程 A 存进去的数据线程 B 去取只能拿到 null因为它们的储物柜是分开的。Looper 的绑定正是借助 ThreadLocal 实现的。源码里这句static final ThreadLocalLooper sThreadLocal new ThreadLocalLooper();prepare()做的事情本质上就是把当前线程和 Looper 实例这个映射关系写进当前线程自己的 ThreadLocalMap 里。ThrealLocal 的命名也很直白线程本地变量。Looper 选择存在这里保证了一个线程只能有一个 Looper而且获取时不需要加锁——因为每个线程只操作自己的变量阁楼天然线程安全。1.3 prepare() 源码级别的绑定逻辑Looper.prepare()的实现比想象中简单public static void prepare() { prepare(true); } private static void prepare(boolean quitAllowed) { if (sThreadLocal.get() ! null) { throw new RuntimeException(Only one Looper may be created per thread); } sThreadLocal.set(new Looper(quitAllowed)); }这里有个细节值得注意prepare()会先检查当前线程是否已经存在 Looper如果存在直接抛异常。这跟前面崩溃异常相互呼应——官方规定一个线程只能有一个 Looper想重复准备就直接报错。再看内部构造函数private Looper(boolean quitAllowed) { mQueue new MessageQueue(quitAllowed); mThread Thread.currentThread(); }Looper 构造时干了两件大事创建一个 MessageQueue消息队列记录当前线程对象。所以 Looper 和 MessageQueue 是一一对应的关系且都保存在当前线程的 ThreadLocalMap 中。1.4 为什么一个线程只能有一个 Looper这个问题面试常常问也常有人答不对。核心原因在于一个 Looper 内部持有一个 MessageQueue消息队列维护的是一个按执行时间排列的消息链表。如果同一个线程上存在多个 Looper每个 Looper 各管一个队列那这个消息的分发顺序立刻乱套两个循环都从各自队列里取消息线程无法决定先处理谁的就谈不上顺序性和一致性了。从设计角度也讲得通ThreadLocal 本身天然只允许一个 Looper 实例出现。这让我想起一个比喻一条单行道只能有一个交警在指挥交通如果站了两个交警各自发号施令车辆反而全部堵死。Looper 就是这条线程消息通道的交警。回到实战中如果你真需要在子线程里跑一个消息循环正确做法是先调用Looper.prepare()再new Handler()最后Looper.loop()。这个流程会在后面的实战章节展开。2. loop() 死循环背后的真相为什么主线程死循环却不卡死2.1 死循环的入口Looper.loop() 源码拆解看完 prepare很多人会继续追问prepare 只做了绑定那 Looper 怎么让线程持续处理消息答案是Looper.loop()。public static void loop() { final Looper me myLooper(); if (me null) { throw new RuntimeException(No Looper; Looper.prepare() wasnt called on this thread.); } final MessageQueue queue me.mQueue; for (;;) { Message msg queue.next(); // 关键阻塞点 if (msg null) { return; } msg.target.dispatchMessage(msg); // 回收消息到消息池 msg.recycleUnchecked(); } }这里最显眼的莫过于for (;;)这个死循环。很多人第一次看到会下意识觉得这不会把线程卡死吗——尤其是主线程它跑的就是这个死循环。要回答这个疑问得把视线移到queue.next()上。2.2 真正阻塞的不是循环而是 MessageQueue.next()如果只是读外层源码很容易误以为 loop() 在用 CPU 空转等消息。实际上阻塞发生在MessageQueue.next()内部。当消息队列里没有需要立即执行的消息时next() 会让线程进入休眠状态等有新消息入队时再唤醒它。这个机制依赖 Linux 的 epoll。简单类比你坐在工位上等快递如果每隔几秒就抬头看门口有没有快递员轮询又累又费电更好的做法是躺在床上睡一觉等快递员按门铃再起来epoll 阻塞。Android 的 native 层就是通过epoll_wait挂起线程等待文件描述符的可读事件来唤醒。所以完整的循环节奏是next() 无消息时阻塞休眠一旦有消息入队就唤醒线程dispatchMessage 处理完消息后回到 for 循环继续 next() 等下一个消息。CPU 利用率非常低主线程不会被死循环拖垮。2.3 ANR 的锅不该由 loop() 来背这里还要破除一个常见误区主线程 ANR 不是因为有死循环而是因为某个任务卡住了死循环的某一步。系统判定 ANR 的维度主要是三大类输入事件InputDispatching超时、广播Broadcast超时、服务Service执行超时。可以这样理解loop() 是一个快递分拣流水线每个包裹消息都需要被及时拆包处理。如果某个包裹特别难拆比如在主线程做网络请求、磁盘大文件读写、数据库操作后续所有包裹都会堵在传送带上。用户点屏幕产生的 Input 事件也排在后面系统迟迟等不到它被分发出去就判定 ANR。换句话说loop() 本身永远是活跃的真正让界面卡死的是某个耗时消息霸占了 dispatch 时沿线的时间片。这也是为什么官方反复强调不要把耗时操作放在主线程。理解了 loop() 的结构这句话背后的原理就清楚了。2.4 quit() 与 quitSafely()如何安然退出循环主线程的 Looper 不允许退出prepare(false)但自己创建的 Looper 通常需要能停止循环。Looper 提供两个方法quit()直接终止消息队列清除所有尚未处理的消息。quitSafely()标记退出但会等队列中已有的延时消息到期并处理完再退出循环。这两个方法的机制差异值得留意。quit()走的是MessageQueue.quit(false)底层会把还是待处理状态的消息全部回收掉quitSafely()走的是MessageQueue.quit(true)只移除还没有到执行时间的消息已到期和即将到期的消息先执行完再退出。实际开发中我一般倾向用quitSafely()尤其是消息队列里还有状态同步、UI 回调这类任务时野蛮 quit 容易造成后续逻辑拿不到预期结果。不过也有例外如果线程只负责心跳上报、轮询清理这类临时任务直接 quit() 反而干净利落。3. 消息从 sendMessage 到 handleMessage 的完整旅程3.1 dispatchMessage 的三种分发分支Handler 把消息 send 出去后最终通过msg.target.dispatchMessage(msg)回到 Handler。target 就是发送这条消息的 Handler 对象。dispatchMessage 的分发策略非常清晰优先级从高到低public void dispatchMessage(Message msg) { if (msg.callback ! null) { handleCallback(msg); // 第一优先Runnable 回调 } else { if (mCallback ! null) { if (mCallback.handleMessage(msg)) { return; // 第二优先Handler.Callback 接口 } } handleMessage(msg); // 第三优先重写 handleMessage() } }这三条分支对应三种常见的消息处理方式post(Runnable) 会把 Runnable 包装成 Message 的 callback 字段分发时直接运行 Runnable。构造 Handler 时传 Callback 接口可以在不继承 Handler 的情况下拦截消息返回 true 表示已处理。最常见的继承 Handler 并重写 handleMessage(Message)。理解这条优先级很重要。我曾经处理过一个 Bug某个页面同时用了handler.post(Runnable)和继承 Handler 的 handleMessage结果 Runnable 里的日志没有先走 handleMessage导致数据状态对不上。后来才发现post 包裹的 Runnable 本来就不经过 handleMessage这条分发链路的优先级设计是刻意为之的。3.2 消息池obtain() 背后的对象复用设计再看 loop() 里最后一个关键操作msg.recycleUnchecked()。每条消息处理完后会被放回一个全局的消息池等待下次被复用。Android 的消息池最大容量是 50通过Message.obtain()从池里取消息public static Message obtain() { synchronized (sPoolSync) { if (sPool ! null) { Message m sPool; sPool m.next; m.next null; m.flags 0; sPoolSize--; return m; } } return new Message(); }这层设计跟 JVM 对象回收的开销有关。Handler 消息流转极其频繁如果每发一条消息都 new 一个 Message 对象短时间会产生大量对象触发 GC 的频次也会上升。消息池本质上是对象池模式复用实例来降低内存分配和 GC 压力。实战建议是自己构造 Message 时优先用Message.obtain()或者handler.obtainMessage()不要直接 new Message。尤其是循环发消息、高频刷新的场景效果立竿见影。这里有一个常见的错误有人会在发送后把 Message 对象再拿来做别的用途这不可取因为消息一旦发送出去内部状态由消息队列管理外部不能再随意持有改写。3.3 同步屏障与异步消息机制里最容易被忽略的一层聊到 MessageQueue 的队列结构很多人只记得按时间排序的链表。队列里其实还特殊处理了一类屏障消息SyncBarrier。这个消息的 target 为 null不会直接分发给任何 Handler它的作用是挡住所有同步消息让扫描逻辑优先去查找异步消息。在 UI 渲染链路里这个机制扮演了重要角色。系统在需要立即执行 UI 绘制或输入处理时会往主线程消息队列插入一个同步屏障然后发送异步消息保证这些高优先级任务不会被普通同步消息阻塞。等关键帧处理完再移除屏障。普通排障时一般碰不到屏障消息但面试中却常被问到为什么 postSyncBarrier 不用 Handler有没有见过 target 为 null 的消息这类问题的落点就是同步屏障这个隐藏角色。也正因如此系统在处理 View 绘制、Choreographer 帧回调时能稳定抢占主线程的执行时机。3.4 从 Handler 到 Looper消息的顺序性与优先级把上面内容串起来看一条消息从 handler.sendMessage() 到 handleMessage 大概经历这样几个阶段Message.obtain() 取出或创建消息实例填充 what、obj、arg1/arg2 等字段。handler.enqueueMessage() 调用 MessageQueue.enqueueMessage() 插入消息链表。enqueueMessage() 按when执行时间找到合适的插入点保证队列按时间递增排列。Looper.loop() 通过 queue.next() 取出当前最早需要执行的消息。取出后通过 msg.target.dispatchMessage(msg) 走分发链路。handleMessage 或 Runnable.run() 处理完后消息被回收进消息池。这个链路也解释了为什么同一线程里发出去的消息整体是按序执行的队列按时间排序next() 每次取的都是当前时刻最早该处理的那一条。如果两条消息的执行时间相同则按入队先后顺序排列。Async 异步消息、Sync 同步消息只是针对屏障的筛选维度并不会破坏队列的时间顺序。4. 主线程 Looper 与 Android 系统的协同4.1 ActivityThread.main() 里的主 Looper 初始化主线程之所以能直接用 Handler是因为系统在应用进程启动时完成了初始化。看ActivityThread.main()public static void main(String[] args) { Looper.prepareMainLooper(); // ... 创建 Application、启动主线程 Handler 等 Looper.loop(); }prepareMainLooper()内部调用prepare(false)也就是不允许退出的 Looper。主线程消息循环必须一直在一旦 quit 就等于应用退出这就是主 Looper 和普通 Looper 最大的区别。这里还有一个值得留意的方法Looper.getMainLooper()。它返回主线程的 Looper 实例在很多场景都有用——比如你用new Handler(Looper.getMainLooper())就能在任意线程创建 Handler把任务切回主线程执行。Handler mainHandler new Handler(Looper.getMainLooper()); mainHandler.post(() - { // 切回主线程更新 UI });这种写法比记录一个全局 static Handler 更规范也更容易控制生命周期。4.2 H handler系统消息的首席大管家ActivityThread 内部有一个静态类 H 继承 Handler主线程里几乎所有的系统级消息都通过它来分发。比如 Activity 生命周期切换、Service 启动停止、Broadcast 分发、ContentProvider 相关操作等。H 的消息 what 值非常多从 1 到一百多包括H.LAUNCH_ACTIVITYH.PAUSE_ACTIVITYH.RESUME_ACTIVITYH.STOP_ACTIVITYH.SERVICE_ARGSH.RECEIVER系统把生命周期事件封装成消息塞进主线程队列由 H 在 loop() 中逐个处理从而驱动所有应用组件的运行。这跟普通开发者 Handler 的关系是一致的谁往主线程塞消息最终都由这个主 Loop 统一调度。H 只是其中影响力最大的一个 Handler。理解这层后很多为什么某个生命周期回调比某个 post 晚执行的问题就容易解释了生命周期相关的消息本身也有先来后到的入队顺序你 post 到队列里的任务如果排在某个生命周期消息后面那么自然等这个生命周期回调执行完才会轮到你。4.3 Looper 与 ANR、Input 响应、VSYNC 的关系主线程 Looper 不仅处理应用自身的逻辑消息还深度参与了 UI 渲染和输入响应。它们的交集在 MessageQueue 的这个特点上所有任务都在同一条消息循环上排队一旦循环被前一个任务堵住后续所有类型的任务都会延迟。Input 事件用户点击屏幕后InputDispatcher 通过 ViewRootImpl 把输入事件转化为消息投递到主线程队列。如果队列前面有一个执行很慢的消息点击事件就无法及时被处理最终触发 InputDispatching 超时表现为 ANR。VSYNCChoreographer 将垂直同步信号转为帧回调FrameCallback每一帧的测量、布局、绘制之间全部依赖主线程消息循环驱动。如果某个消息卡住超过两帧就会掉帧界面卡顿。ANR 检测系统会为主线程设置一系列超时监视器它们并不直接监控 loop() 本身而是通过检测目标消息是否在限定时间内被相应处理完来判断是否卡死。这也是为什么启动优化、列表流畅度优化、ANR 排查最终都会回到同一个核心主线程消息队列到底被谁堵住了。工具上可以用Looper.getMainLooper().getQueue()结合自定义的 Printer 去 dump 主线程执行日志定位耗时消息。Looper.getMainLooper().setMessageLogging(new Printer() { Override public void println(String x) { Log.d(MainLooper, x); } });开启消息日志后能在 logcat 里看到每条消息的分发时间点配合 Systrace 可以进一步分析耗时位置。4.4 isMainLooper 与 Looper.getMainLooper 的常见用法代码基建里判断当前是否主线程是很常见的需求public static boolean isMainThread() { return Looper.myLooper() Looper.getMainLooper(); }这个判断在封装网络层回调、图片加载库、数据库操作时常常用到如果现在不是主线程就通过主线程 Handler 去通知 UI 更新避免直接操作 View 导致崩溃。Looper.getMainLooper()也在线程切换时很常用。比如某个任务执行完后需要回到主线程但不希望直接定位到某个 Activity 的 Handler而是统一走主线程 LooperHandler mainHandler new Handler(Looper.getMainLooper()); mainHandler.post(updateTask);这种做法的好处是生命周期解耦不持有页面引用尤其适合基础库、工具类中的线程切换逻辑。5. 实战踩坑与排查经验5.1 子线程直接 new Handler 崩溃的应对回到文章开头那个异常。如果你确实需要在子线程维护一个自己的消息循环标准解法是调用Looper.prepare()完成线程与 Looper 的绑定。在 prepare 之后创建 Handler。调用Looper.loop()开启消息循环。需要结束循环时调用looper.quitSafely()。直接手写这套逻辑有点繁琐Android 官方提供了封装好的 HandlerThreadHandlerThread thread new HandlerThread(work_thread); thread.start(); Handler workHandler new Handler(thread.getLooper()) { Override public void handleMessage(Message msg) { // 在子线程处理消息 } }; // 发消息、延时任务都可以走 workHandlerHandlerThread 在 start() 后会自动执行 Looper.prepare() 和 Looper.loop()外部只需要拿 getLooper() 创建 Handler 即可。它适合串行处理任务队列的场景比如网络请求顺序执行、本地文件操作队列、日志异步写入等。如果你遇到的是子线程直接 new Handler 崩溃的报错先确认这个线程到底是普通 Thread还是 HandlerThread或者是 AsyncTask 的序列化线程池。防止自己重复踩。5.2 HandlerThread 适用的开发场景HandlerThread 最常见的用途是让耗时任务按队列顺序执行同时还能通过 Handler 定时、延时触发。这里我列几个实际项目中我常用到的场景日志上报业务日志先写入队列子线程逐个刷盘或上报避免阻塞主线程也避免并发写文件的竞争。轮询任务比如蓝牙扫描、传感器数据采集用 sendMessageDelayed 实现每个周期发一条消息给自己保证下次采集按时执行。数据库批量操作多条数据更新塞进同一个线程队列借用消息队列天然的顺序优势减少多线程事务冲突。图片转存、文件拷贝耗时 IO 操作从主线程剥离处理完通过主线程 Handler 回调 UI。使用 HandlerThread 有几个细节任务队列会无限积压的话要设置超时清空页面销毁时如果不是进程级长生命周期组件记得在 onDestroy 里调用 quitSafely()否则线程泄漏。5.3 内存泄漏Looper 生命周期与 Handler 匿名内部类Handler 内存泄漏是个老生常谈但很多人并不理解根因链条。核心链条是这样的主线程 Looper - MessageQueue - 某条 Message持 target - Handler 实例 - 外部类实例Activity/Fragment因为主线程 Looper 的生命周期跟随进程它内部的消息队列也常驻内存。如果你在 Activity 里用非静态内部类创建 Handler并且发送了一条延迟消息比如 sendMessageDelayed 延迟 5 分钟这条消息在等待执行期间会一直持有 Activity 引用Activity 无法被 GC 回收页面资源也不会释放。正确的修复姿势是把 Handler 定义成静态内部类。对 Activity 使用 WeakReference 弱引用。onDestroy 或 onStop 时调用handler.removeCallbacksAndMessages(null)清除所有消息。private static class SafeHandler extends Handler { private final WeakReferenceMainActivity activityRef; SafeHandler(MainActivity activity) { activityRef new WeakReference(activity); } Override public void handleMessage(Message msg) { MainActivity activity activityRef.get(); if (activity null || activity.isFinishing()) { return; } // 更新 UI } }这里的关键点不是用了弱引用就万事大吉而是removeCallbacksAndMessages(null)能及时把这条延迟消息从队列里摘掉从源头切断持有链。两者的目的稍有不同弱引用是防止处理消息时强持有 Activity而 remove 是让消息队列不再等待这条消息。5.4 IdleHandler 与启动优化零成本利用循环空闲期MessageQueue 里还有一个低调但实用的机制IdleHandler。当 looper 暂时没有需要立即处理的消息进入阻塞之前会回调一下 IdleHandler 列表。如果返回 false这个 IdleHandler 执行一次就会被移除返回 true 则保留下次空闲继续执行。这个特性经常用于启动优化和延迟初始化。比如冷启动时首页首帧的绘制消息往往排在关键位置如果此时直接加载大图、初始化大量 SDK会拖慢首帧时间。把这些任务交给 IdleHandler系统处理完紧急消息后才会执行它们对首帧的影响能降到最低。Looper.myQueue().addIdleHandler(new MessageQueue.IdleHandler() { Override public boolean queueIdle() { // 在这里做延迟初始化 initImageLoader(); initPushSdk(); return false; } });不过要提醒一个坑如果主线程一直不空闲比如动画一直在跑IdleHandler 的执行时间是不确定的不适合用于必须尽快执行的任务。同时需要留意它本身的耗时如果 idle 任务的实现也很重反而会拖慢下一次消息处理。实践时一般把任务拆小优先做内存需要预热的轻量级初始化。5.5 关于 Looper 你还可以继续深挖的方向聊到这里Looper 的核心脉络已经基本清晰。如果还想继续往下钻有两条路线可以选往底层看MessageQueue.next() 里的 nativePollOnce 到底如何与 epoll 交互native 层的 Looper 和 Java 层 Looper 怎么对应可以研究 AOSP 中android_os_MessageQueue.cpp和Looper.cpp的实现。往上应用层看Kotlin 协程的 Dispatchers.Main 其实就是基于 Handler 和 Looper 实现的深入理解 Looper 后再去读协程的 HandlerDispatcher 源码会轻松很多。这两条路线能帮助你建立起从消息循环到线程调度的完整知识树这也是很多大厂面试官层层追问的路径Handler 是什么 - Looper 是什么 - 为什么不死循环 - epoll 怎么实现 - 协程怎么复用这套机制。我个人这些年下来最大的体会是Handler 消息机制不是背八股它是一个非常好的线程调度入门样本。把 Looper 和 MessageQueue 的关系弄清楚再去看 RxJava 的调度器、协程的调度器都会有种似曾相识的感觉。所以在排查线程问题时我习惯先画清楚消息从哪里来、排在哪条队、由哪个 Looper 消费这三件事问题基本就解决一半了。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →