☰
子线程创建Handler崩溃原因解析:Looper与消息机制核心原理
2026/10/22 6:19:59 网站建设 项目流程

实际开发里遇到这个崩溃,应该不少人都经历过:

java.lang.RuntimeException: Can't create handler inside thread that has not called Looper.prepare()

第一次看到这个日志时,我盯着屏幕愣了半天:我就是在子线程里new Handler(),它凭什么管我有没有调用Looper.prepare()?后来把 Handler、Looper、MessageQueue 这套消息机制彻底搞明白之后,才发现这个问题的答案,几乎串起了 Android 消息循环的整个核心逻辑。这篇文章就把“子线程中创建 Handler”这件事从头到尾讲透:为什么直接new Handler()会崩、标准的 prepare/loop 写法每一步做了什么、HandlerThread 到底帮我们省了哪些事,以及我在实际项目里踩过的几个坑。不管你是刚接触 Android 消息机制,还是用了很久 Handler 但没深究过细节,应该都能在这里找到想要的答案。

1. 先弄明白崩溃原因:Handler 与 Looper 的强制绑定关系

1.1 崩溃日志到底想告诉你什么

很多人遇到这个崩溃时,第一反应是去搜“怎么在子线程创建 Handler”,然后照着网上的答案加上Looper.prepare()就完事了。但如果不理解崩溃背后那行判断,换个场景照样会掉坑。

先看 Handler 构造函数里最核心的几行逻辑:

public Handler(@Nullable Callback callback, boolean async) { // 拿当前线程的 Looper mLooper = Looper.myLooper(); if (mLooper == null) { throw new RuntimeException( "Can't create handler inside thread " + Thread.currentThread().getName() + " that has not called Looper.prepare()"); } mQueue = mLooper.mQueue; mCallback = callback; mAsynchronous = async; }

关键就在Looper.myLooper()。它的实现其实特别简单,就是从 ThreadLocal 里把当前线程存放的 Looper 取出来:

public static @Nullable Looper myLooper() { return sThreadLocal.get(); }

如果当前线程从没调用过Looper.prepare(),那么 ThreadLocal 里存的 Looper 就是 null,Handler 构造函数检测到 null 后直接抛异常。

所以这个崩溃日志翻译成人话就是:“当前线程没有准备好消息循环,Handler 找不到可以绑定的 Looper,所以拒绝创建。”

等一下,那为什么平时在主线程里new Handler()从来没报过这个错?因为主线程的 Looper 是系统在我们进入应用之前就已经准备好了的,并不是 Handler 天生“默认支持主线程”。

1.2 ThreadLocal、Looper 与 MessageQueue:三个角色一条线

要把这个问题彻底搞清楚,得先说清楚 ThreadLocal、Looper、MessageQueue、Handler 这四个角色之间到底是什么关系。

我用一个生活化的类比吧。可以把每个线程想象成一个独立的小办公室:

  • Looper是这个办公室里的分拣员,他有一个固定的职责:不停从文件筐里取出新到的任务单,然后发给对应的人去处理。
  • MessageQueue是办公室门口的文件筐,发进来的消息都在这里排队。
  • Handler是办公室的对外窗口。你在任何地方往这个窗口递任务,任务都会进到分拣员所在办公室的文件筐里,等着分拣员处理。
  • ThreadLocal则是老板手里的登记表,记录“哪个办公室配了分拣员”。每个线程有且只能查自己办公室的登记记录,不能去别的办公室查,也不能替别的办公室登记。

关键点在于:一个线程默认是没有 Looper 的。必须有人在这个线程内部调用Looper.prepare(),这个线程才会被登记为“已配置分拣员”。而 Handler 在创建时必须找到一个分拣员(Looper),否则它不知道自己把消息往哪个办公室递,于是就只能抛异常了。

所以“子线程里创建 Handler 会崩”根本不是什么玄学,本质就是:你既没有给这个子线程准备 Looper,也没有显式告诉 Handler 去绑定某个已经存在的 Looper,那 Handler 自然巧妇难为无米之炊。

1.3 主线程的“天生 Looper”是哪里来的

既然每个线程默认都没有 Looper,那主线程为什么不用调用 prepare?因为系统在应用启动的时候已经替我们做完了。

Android 应用进程启动时,入口方法是ActivityThread.main(),里面有一段核心代码:

public static void main(String[] args) { ... Looper.prepareMainLooper(); ... Looper.loop(); ... }

Looper.prepareMainLooper()本质上就是在主线程上创建了一个 Looper,并且放到了 ThreadLocal 里;紧接着调用的Looper.loop()启动了主线程的消息循环。从这一刻起,主线程就像一台永远开着机的自动售货机,等着各类消息进来消费。

所以我们在 Activity 里直接写Handler handler = new Handler(),底层其实走到了Looper.myLooper(),拿到的是主线程那个已经存在的 Looper。正因为这套准备流程被系统做掉了,很多人就形成了一种“在 Java 代码里 new Handler 就是理所当然能跑”的错觉,直到某天在子线程里踩了坑。

2. 标准写法逐个拆:Looper.prepare()、new Handler()、Looper.loop()

2.1 一段基础但能跑通的子线程 Handler 示例

要在一段普通的 Thread 子线程里使用 Handler,标准三段式就是“先 prepare,再 new Handler,最后 loop”。我最早写出来的版本大概是这样的:

public class LooperThread extends Thread { private Handler mHandler; @Override public void run() { // 第一步:给当前线程创建 Looper Looper.prepare(); // 第二步:现在 myLooper() 有值了,可以安全创建 Handler mHandler = new Handler(Looper.myLooper()) { @Override public void handleMessage(@NonNull Message msg) { // 处理消息,这里的代码跑在 LooperThread 这个子线程上 if (msg.what == 1) { System.out.println("子线程收到消息: " + msg.obj); } } }; // 第三步:启动消息循环,让 Looper 开始从队列里取消息 Looper.loop(); } public void sendMessageToLooperThread(String content) { if (mHandler != null) { Message msg = Message.obtain(); msg.what = 1; msg.obj = content; mHandler.sendMessage(msg); } } }

调用方式:

LooperThread thread = new LooperThread(); thread.start(); // 等线程跑起来之后再发消息,避免 mHandler 还是 null thread.sendMessageToLooperThread("hello");

这里要特别提醒一个我一开始踩过的坑:mHandler是在run()里赋值的,线程 start 之后并不代表 run 已经执行完了。如果外部很快就调用sendMessageToLooperThread(),很有可能拿到一个 null 的 mHandler。我当时的解法是在 run 里给外部一个就绪回调,或者干脆用后面要讲的HandlerThread,它在设计上就考虑到了这个问题。

2.2 prepare() 里只有两件事:检查重复、创建 Looper

Looper.prepare()内部其实特别短,看一下源码就明白了:

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)); }

这段代码做了两件事:

  1. 检查当前线程的 ThreadLocal 里有没有已经存在 Looper。如果有,直接抛异常“每个线程只能创建一个 Looper”。这个检查在项目里非常重要,后面我会专门讲它引发的坑。
  2. 创建一个新的Looper对象,并放进 ThreadLocal。

而 Looper 的构造函数又会创建核心搭档 MessageQueue:

private Looper(boolean quitAllowed) { mQueue = new MessageQueue(quitAllowed); mThread = Thread.currentThread(); }

到这里,线程才拥有了自己的 Looper 和消息队列。我刚开始看源码时有个疑问:为什么 Looper 构造方法不能直接暴露给开发者调用,非要绕一层prepare()?后来想明白了,因为 Looper 和线程是强绑定的,放 ThreadLocal 是保“一对一”关系最稳妥的办法。如果是公开构造方法,开发者在哪一行代码里 new 出来的对象根本没法跟当前线程天然绑定,这套模型会乱套。

2.3 loop() 是“死循环”,但它不会吃满 CPU

很多人看到Looper.loop()的源码就心里一紧,因为里面扑面而来就是一个 for 死循环:

public static void loop() { final Looper me = Looper.myLooper(); if (me == null) { throw new RuntimeException("No Looper; Looper.prepare() wasn't called on this thread."); } ... for (;;) { Message msg = queue.next(); if (msg == null) { return; } ... msg.target.dispatchMessage(msg); ... msg.recycleUnchecked(); } }

这里爷青回级别的疑问出现了:死循环不会把线程卡死吗?不会把我们手机 CPU 跑满吗?答案是:不会。关键在于queue.next()这个方法的阻塞逻辑。

MessageQueue 在消息队列为空的时候,会进入一种等待状态。内部的nativePollOnce会调用底层 Linux 的 epoll 机制,让线程真正地“睡过去”,直到有人往队列里插入新消息时通过nativeWake把它唤醒。简单说就是:没有消息的时候线程休眠,有消息进来才被唤醒工作。这跟 Android 整个系统“没事就睡,有事才醒”的驱动模型是同一个套路。

我当初为了验证这一点,专门写了个小 Demo:在子线程里跑Looper.loop(),然后把线程的 CPU 占用打印出来。结果发现空闲时线程占用几乎为 0,完全不是想象中那种疯狂空转的死循环。

2.4 只 prepare 不 loop:消息发了也白发

既然 loop 是启动消息循环的那一步,那如果只调 prepare、不调 loop,会发生什么?

答案是:Handler 可以创建成功,消息也可以放进 MessageQueue,但没有任何人去消费队列里的消息。更糟糕的是,子线程的run()方法执行完就结束了,线程随之消亡,ThreadLocal 里的 Looper、队列里的消息全部变成无根之木。

我举个很典型的错误示例:

Thread thread = new Thread(new Runnable() { @Override public void run() { Looper.prepare(); Handler handler = new Handler(Looper.myLooper()); handler.post(new Runnable() { @Override public void run() { System.out.println("这条消息会执行吗?"); } }); // 注意:这里没有调用 Looper.loop() } }); thread.start();

运行结果是什么?大概率是控制台什么都不打印,程序还正常结束。因为消息虽然在 prepare 之后成功入队,但 run 方法到底了,线程结束,消息根本来不及被分发。我见过不少人在自己封装的子线程线程池里这么写,以为prepare()之后消息就一定会被处理,最后排查半天发现是漏了loop()。

所以标准顺序必须是:prepare → new Handler → loop,三者缺一不可。

3. 更省心的官方方案:HandlerThread 帮你做的三件事

3.1 HandlerThread 内部运行流程

如果每个子线程都要手写 prepare 和 loop,代码倒是不复杂,但工程上有一点很别扭:Handler 和外部线程之间的通信时机不好控制,生命周期也容易出问题。后来我发现 Android 早就把这套东西封装好了,叫HandlerThread。

HandlerThread 继承自 Thread,把 “prepare + loop” 这件事在内部做完了。看它的核心逻辑:

@Override public void run() { mTid = Process.myTid(); Looper.prepare(); synchronized (this) { mLooper = Looper.myLooper(); notifyAll(); } Process.setThreadPriority(mPriority); onLooperPrepared(); Looper.loop(); mTid = -1; }

这里其实就回答了一个很多人纠结的问题:HandlerThread 到底帮我们做了什么?答案是三件事:

  1. 自动调用Looper.prepare(),兄弟线程不再需要自己准备。
  2. 在 Looper 创建完成之后,通过notifyAll()唤醒等待的调用方,让外部可以安全拿到 Looper。
  3. 自动设置线程优先级,并且支持自定义 priority,对后台任务非常友好。

第 2 点尤其关键,它解决了我前面提到“线程 run 还没执行完,mHandler 还是 null”的问题。

3.2 使用 HandlerThread 的正确姿势

用 HandlerThread 创建子线程 Handler,代码比手动写法简洁得多:

// 1. 创建并启动 HandlerThread HandlerThread workerThread = new HandlerThread( "image-loader", // 线程名字,方便调试 Process.THREAD_PRIORITY_BACKGROUND // 后台优先级,可选 ); workerThread.start(); // 2. 通过 getLooper() 拿到子线程的 Looper,再创建 Handler Handler workerHandler = new Handler(workerThread.getLooper()) { @Override public void handleMessage(@NonNull Message msg) { // 这里在子线程执行耗时操作 handleImageLoading(); } }; // 3. 使用完之后务必退出,否则线程会一直活着 workerThread.quitSafely();

getLooper()内部是同步等待逻辑:

public Looper getLooper() { if (!isAlive()) { return null; } synchronized (this) { while (isAlive() && mLooper == null) { try { wait(); } catch (InterruptedException e) { } } } return mLooper; }

正是因为 run 里先用notifyAll()通知,外部才能从getLooper()毫无风险地拿到非 null 的 Looper。这句话说得通俗一点就是:线程说“我的分拣员已经上班了”,外部的 Handler 才敢拿着任务去递。

3.3 手动 prepare 和 HandlerThread 怎么选

我用了相当长时间的手动写法之后,又切到 HandlerThread,期间总结了一张对比表:

对比项手动 Thread + prepare/loopHandlerThread
代码量多,需要自己维护少,官方封装好
Looper 获取方式线程内部私有,外部拿不到外部通过 getLooper() 安全获取
线程优先级默认,需自己调用 setPriority构造时可指定
退出时机无法优雅退出,一般做成常驻线程提供 quit / quitSafely
适用场景需要完全自定义生命周期绝大多数后台串行任务

个人建议是:如果只是需要一个长期存活、不断处理消息的子线程,直接用 HandlerThread,不用纠结。除非你对线程的创建和销毁有特殊要求,必须自己控制 run 里每一步,那手动写法也能用,但是要特别注意消息循环和退出逻辑。

4. 子线程建 Handler,不一定非绑子线程:聊聊指定 Looper

4.1 回主线程需求下的经典写法

聊完 HandlerThread,再来说一个很容易被误解的场景:在子线程里创建 Handler,并不一定得绑定当前子线程的 Looper。实际开发中,我们经常会写出这样的代码:

Thread worker = new Thread(new Runnable() { @Override public void run() { // 模拟耗时的网络请求 String result = doNetworkRequest(); // 注意:这里是拿到主线程的 Looper 来创建 Handler Handler mainHandler = new Handler(Looper.getMainLooper()); mainHandler.post(new Runnable() { @Override public void run() { // 这里的代码运行在主线程,可以放心更新 UI mTextView.setText(result); } }); } }); worker.start();

我把这段代码贴给组里一个刚入行的同事看,他问了句很经典的话:“Handler 是在子线程里 new 出来的,为什么run()里却能更新 UI?”

原因在于:Handler 把消息投递到哪个线程处理,不取决于 Handler 在哪个线程被创建,而取决于它绑定的 Looper 属于哪个线程。上面这个mainHandler绑定的是主线程的 Looper,所以post进去的消息最终会被主线程的消息循环取出来并执行。

4.2 Handler 构造函数的演进:别再用无参实现了

在很多旧代码里,创建 Handler 最常见的写法是new Handler()加匿名内部类:

Handler oldStyleHandler = new Handler() { @Override public void handleMessage(@NonNull Message msg) { // ... } };

这种写法在子线程里直接会崩,原因前面已经分析过了。而在主线程里虽然能跑,但从 Android 11(API 30)开始,无参构造函数已经被标记为 deprecated。官方推荐的做法是显式传入 Looper:

// 推荐 Handler handler = new Handler(Looper.getMainLooper()); // 或者传入当前线程 Looper,语义更明确 Handler handler = new Handler(Looper.myLooper());

这样改有一个显而易见的好处:代码里能直接看出来这个 Handler 绑定的是哪个 Looper,避免了“咦这里怎么不执行”的困惑。我接手过一个老项目,里面好几个 Handler 都是默认构造,后来排查消息不执行问题的时候,发现竟然有人在子线程里 new 了 Handler 并直接 post 了 UI 更新任务,原因就是代码里没写清楚绑定的 Looper,导致大家默认以为它跑在主线程。

4.3 myLooper 与 getMainLooper 的区别

Looper.myLooper()和Looper.getMainLooper()是很容易混淆的两个方法,我把它们放在一起对照说明:

  • Looper.myLooper():返回当前线程绑定的 Looper。如果当前线程没有调用过Looper.prepare(),返回值是 null。所以普通子线程调用它大概率是 null。
  • Looper.getMainLooper():返回主线程的 Looper。不会受调用线程影响,任何时候调用都能拿得到。

表格对比:

方法返回什么会不会为 null句子理解
Looper.myLooper()当前线程 Looper可能为 null我这个办公室有没有分拣员?
Looper.getMainLooper()主线程 Looper基本不会主办公室的分拣员是谁?

要在子线程里创建一个跟随主线程消息循环的 Handler,就用new Handler(Looper.getMainLooper());要让 Handler 跟随当前子线程的消息循环,就必须先确保当前线程已经 prepare 过,再用new Handler(Looper.myLooper())或者new Handler()。

5. 真实踩坑记录:从线程退出到内存泄漏

5.1 消息发了没反应:线程结束,队列跟着消亡

我踩过最隐蔽的一个坑,是在一个线程池里发消息偶尔会丢失。当时代码大概长这样:

public void startWorker() { Thread worker = new Thread(new Runnable() { @Override public void run() { Looper.prepare(); sHandler = new Handler(Looper.myLooper()); Looper.loop(); // 只准备,不循环 } }); worker.start(); }

注意看,我这个代码里其实写了Looper.loop(),但因为同事后来在 run 方法末尾又加了一段“收尾逻辑”,把 loop 给搞没了。结果就是:rust 线程 run 走完,线程结束,ThreadLocal 里的 Looper 被打包回收,消息队列也随之消失,后边发进来的消息就像石沉大海,日志什么都没有。

排查这种问题的思路其实很简单:在 Handler 的handleMessage第一行加日志,在sendMessage之前也加日志,看看是消息没发出去,还是发出去没人消费。如果是后者,优先怀疑消息循环没启动,或者线程已退出。

5.2 同线程重复 prepare:第二个 Looper 直接崩溃

有一段时间我在写一个线程池工具,把Looper.prepare()封装在一个 Runnable 里,希望通过同一个 Runnable 反复创建线程来复用逻辑。结果发现同一个 Runnable 被同一个线程执行两次时,第二次调用 prepare 会崩,崩溃信息是:

java.lang.RuntimeException: Only one Looper may be created per thread

原因看前面prepare()源码就很清楚了:sThreadLocal.get()里已经存在 Looper,不允许再创建一个。这个崩溃提醒我两件事:

  1. prepare()只能在线程内调用一次,别把 prepare 放在一个会被反复执行的公共代码路径里。
  2. 如果一个线程需要长期持有一个 Looper,那就让它一直持有下去,也正因如此,HandlerThread 把 prepare 和 loop 写成了成对出现的结构。

5.3 匿名 Handler 持有外部引用:Activity 无法释放

这是老生常谈但又特别常见的坑。当我们在 Activity 里通过匿名内部类创建一个 Handler 时,这个 Handler 对象会隐式持有外部 Activity 的引用:

public class MainActivity extends AppCompatActivity { // 这是典型的“泄漏写法” private final Handler mHandler = new Handler(Looper.getMainLooper()) { @Override public void handleMessage(@NonNull Message msg) { updateUi(); } }; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); // 模拟一个延迟消息 mHandler.postDelayed(new Runnable() { @Override public void run() { updateUi(); } }, 60 * 60 * 1000); } }

消息如果一直排在队列里,Handler 一直被引用,而 Handler 又通过匿名内部类持有 Activity,Activity 就无法回收。这种泄漏在 LeakCanary 里能被检测到,表现常常是“Activity 被一个 Handler 对象持有”。

标准解法是使用静态内部类加弱引用:

public class MainActivity extends AppCompatActivity { private static class SafeHandler extends Handler { private final WeakReference<MainActivity> mActivityRef; SafeHandler(Looper looper, MainActivity activity) { super(looper); mActivityRef = new WeakReference<>(activity); } @Override public void handleMessage(@NonNull Message msg) { MainActivity activity = mActivityRef.get(); if (activity != null) { activity.updateUi(); } } } private final SafeHandler mHandler = new SafeHandler(Looper.getMainLooper(), this); }

另外,页面对不可见的时刻,记得把消息从队列里移除掉:

@Override protected void onDestroy() { super.onDestroy(); // 移除还没执行的消息,尽量避免无用消息 mHandler.removeCallbacksAndMessages(null); }

5.4 子线程 Handler 别碰 UI:跨线程更新准则

子线程 Handler 的handleMessage默认跑在子线程。如果你在子线程 Handler 里写了TextView.setText(),大概率会看到熟悉的崩溃:

android.view.ViewRootImpl$CalledFromWrongThreadException: Only the original thread that created a view hierarchy can touch its views.

这个崩溃很直接地告诉了我们一条规则:只有创建视图的那条线程才能修改视图。在子线程 Handler 里做耗时任务是没问题的,做完之后如果想更新界面,可以再发一条消息到主线程 Handler,或者切换到主线程执行。

我习惯的写法是用一个全局主线程 Handler:

public final class UiHandler { private static final Handler HANDLER = new Handler(Looper.getMainLooper()); public static void runOnUiThread(Runnable action) { HANDLER.post(action); } }

这样在子线程 Handler 里也能很清晰地执行 UI 更新。

5.5 退出时机:quit 与 quitSafely 的差别

用 HandlerThread 时,很多人忽略了它默认是不会自己停的。只要不调用quit()或quitSafely(),线程就会一直在Looper.loop()里等消息,这在线程池场景里会白白占着线程不释放。我在项目里曾经因为漏掉退出,导致后台线程数量肉眼可见地在 LeakCanary 和线程列表里越攒越多。

quit()和quitSafely()的区别也值得记一下:

  • quit():立刻退出消息循环,清空并丢弃队列里所有还没处理的消息。
  • quitSafely():先把队列里已经存在的消息处理完,然后退出;尚未处理完的延时任务会被直接清除。

从名字就能看出来,quitSafely()更符合“优雅退出”的诉求。如果你是在做一些需要保证任务完整性的场景(比如保存文件、上报数据),别用quit(),否则消息会丢得猝不及防。

我在实际项目中一般这样收尾:

@Override protected void onDestroy() { super.onDestroy(); if (mWorkerThread != null) { mWorkerThread.quitSafely(); } }

上面这些都是我自己踩过坑之后总结出来的点。特别是线程退出和内存泄漏这两块,现在每次写子线程 Handler 相关的代码,我都会在代码 review 时重点盯一下:prepare 是否成对、loop 是否真正被调用、HandlerThread 有没有退出、Handler 有没有持有外部强引用。

最后再分享一个很实用的小技巧:如果你在调试时想知道某个 Handler 的消息到底跑在哪个线程,直接在handleMessage里打印一句Thread.currentThread().getName(),会比看一堆堆栈猜来猜去快得多。毕竟子线程 Handler 这个知识点,难就难在它不是“记住代码”就行,而是要理解“消息去向取决于 Looper,而不是 Handler 的创建位置”。理解了这一条,很多与此相关的奇奇怪怪的问题,基本都能一眼看穿。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询