跪拜 Guibai
← Back to the summary

Handler.post() Doesn't Switch Threads — It Just Drops a Message in the Right Queue

Handler (Part 1): After post(), Which Thread Does the Runnable Execute On?

Many people, when first learning about Handler, memorize it as a single sentence:

Handler can switch threads.

For example, writing this in a child thread:

mainHandler.post(() -> updateUi());

In the end, updateUi() does execute on the main thread. This observation is correct, but the phrase "Handler switches threads" is too crude. It makes people think that post() is some kind of magic that directly moves the current call stack to another thread to continue running.

What actually happens is not that.

The thread calling post:
    is only responsible for wrapping the Runnable into a Message and placing that Message into the target MessageQueue.
​
The thread where the target Looper resides:
    retrieves this Message itself inside Looper.loop() and then executes Runnable.run().

So this article only answers one question:

After handler.post(runnable), why does the Runnable execute on the target thread?

Let's put the answer right at the front:

Handler is not a thread, nor is it the main body of CPU scheduling and code execution.
Handler provides message delivery and dispatch logic, and holds a reference to the target Looper.
Looper is bound to the current Thread upon creation.
post merely enqueues.
The target Thread calls Handler.dispatchMessage() inside Looper.loop(),
and ultimately, this Thread carries the execution of the Runnable, Callback, and handleMessage.

The source code in this article references the android-16.0.0_r4 tag of Android 16 QPR2. The main text only discusses the stable mechanisms of Handler and does not expand on version-specific implementation differences.

image.png

The complete minimal chain is:

Calling Thread
  -> Handler.post(Runnable)
  -> Runnable wrapped into Message.callback
  -> Handler.sendMessageAtTime()
  -> Handler.enqueueMessage()
  -> msg.target = current Handler
  -> MessageQueue.enqueueMessage()
​
Target Thread
  -> Looper.loop()
  -> MessageQueue.next()
  -> msg.target.dispatchMessage(msg)
  -> Handler.dispatchMessage()
  -> Runnable.run()

The most important boundary in this chain is:

The calling thread is only responsible for enqueuing;
The target Looper thread is responsible for retrieving and executing.

All subsequent source code analysis revolves around this boundary.

1. Is Handler a Thread?

No.

Handler itself does not create threads, nor does it start them. It merely stores several objects related to message delivery:

mLooper: The Looper reference held by this Handler.
mQueue: The MessageQueue behind this Looper.
mCallback: The callback passed in when constructing the Handler.
mAsynchronous: Whether messages sent by this Handler are asynchronous by default.

In plainer terms:

From the sending side:
    Handler is the entry point for delivering tasks to the target MessageQueue.
​
From the execution side:
    After a Message is retrieved by the Looper,
    the Handler is responsible for choosing the specific callback path via dispatchMessage.
​
What truly carries the execution of this code is still the target Thread.

So, to determine which thread a Runnable ultimately executes on, you cannot look at which thread called post(). You must look at which Looper this Handler holds, and which Thread that Looper is bound to.

A minimal example:

Handler mainHandler = new Handler(Looper.getMainLooper());
​
new Thread(() -> {
    mainHandler.post(() -> {
        // This is not a child thread.
        // This will execute on the thread where MainLooper resides.
    });
}).start();

There are two threads here:

Child Thread:
    Calls mainHandler.post().
​
Main Thread:
    Runs MainLooper.loop(), and finally executes Runnable.run().

Handler only decides "where to deliver". It does not decide "who calls post", nor does it directly schedule the CPU.

2. What Exactly Does Handler Hold?

From an object relationship perspective, Handler holds a reference to Looper, not directly to Thread. At runtime, this Looper is bound to the Thread that created it.

When creating a Handler in Android source code, the core logic can be abstracted like this:

public Handler(Looper looper) {
    mLooper = looper;
    mQueue = looper.mQueue;
}

Source code entry points:

Why is it now more recommended to explicitly pass a Looper? Because implicit construction fetches Looper.myLooper() from the current thread:

Handler handler = new Handler();

This line of code looks short, but it relies on the current thread having already called Looper.prepare(). If the current thread has no Looper, it will crash; if the current thread has a Looper, but it's not the thread you think it is, messages will be delivered to the wrong queue.

A more stable way to write it is:

Handler mainHandler = new Handler(Looper.getMainLooper());
Handler workerHandler = new Handler(handlerThread.getLooper());

This way, you can see at a glance which Looper the task will be delivered to.

Remember this judgment formula:

Which Looper does the Handler hold;
Which Thread was this Looper bound to upon creation;
The callback will be carried and executed by that Thread.

3. What is the Relationship Between Looper and Thread?

A more accurate statement is: Looper is bound to the current Thread upon creation.

Looper.prepare() creates a Looper in the current thread and places it into a ThreadLocal. This means each thread can query at most one Looper bound to itself via Looper.myLooper().

Source code logic skeleton:

static final ThreadLocal<Looper> sThreadLocal = new ThreadLocal<>();
​
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));
}
​
public static @Nullable Looper myLooper() {
    return sThreadLocal.get();
}

Source code entry points:

Here, avoid misunderstanding ThreadLocal as "other threads can never get this Looper object". A more precise boundary is:

When other threads call Looper.myLooper():
    They can only get the Looper bound to their own thread, which may be null.
​
However, other threads can still:
    via Looper.getMainLooper();
    via HandlerThread.getLooper();
    or via an existing object reference,
hold the target thread's Looper and deliver tasks to it.

ThreadLocal restricts "how the current thread queries its own Looper", not "the Looper object cannot be held by other threads".

So, ordinary threads do not have a Looper by default:

new Thread(() -> {
    Looper.myLooper(); // Default is null
}).start();

If you want an ordinary thread to become a Handler target thread, you must make it prepare a Looper and enter the loop:

new Thread(() -> {
    Looper.prepare();
​
    Handler handler = new Handler(Looper.myLooper());
​
    Looper.loop();
}).start();

Production code generally doesn't write this manually but uses HandlerThread to create a worker thread with a Looper. For now, just remember this: HandlerThread = Thread + Looper.prepare() + Looper.loop(). The third article will specifically expand on its differences from the main thread and Binder threads.

4. Why Doesn't the Main Thread Exit After main() Finishes?

The application's main thread entry point is in ActivityThread.main().

Stripping away irrelevant details, the main line is:

ActivityThread.main()
  -> Looper.prepareMainLooper()
  -> new ActivityThread()
  -> thread.attach(false, startSeq)
  -> Looper.loop()

Source code entry points:

This source code only answers one question:

Why doesn't the main thread end after executing main()?

The answer is: it finally enters Looper.loop().

The main thread's subsequent processing of Activity lifecycles, Service callbacks, broadcasts, input, and drawing scheduling essentially all rely on this long-running message loop to receive tasks. The main thread does not exist because of Handler; rather, the main thread first has MainLooper, and Handler(Looper.getMainLooper()) can then deliver tasks to it.

You can understand it this way:

main() is not the end of the main thread.
Looper.loop() is the entry point for the main thread's long-term work.

5. What Does post(Runnable) Actually Do?

The name post(Runnable) is easily misunderstood, making people think it will directly execute the Runnable. In the source code, it only does one thing: wrap the Runnable into Message.callback, and then go through the message sending process.

Logic skeleton:

public final boolean post(Runnable r) {
    return sendMessageDelayed(getPostMessage(r), 0);
}
​
private static Message getPostMessage(Runnable r) {
    Message m = Message.obtain();
    m.callback = r;
    return m;
}

Source code entry points:

So the Runnable is not called immediately. It is just stuffed into Message.callback.

Next, sendMessageDelayed() converts the delay into an absolute time when:

public final boolean sendMessageDelayed(Message msg, long delayMillis) {
    if (delayMillis < 0) {
        delayMillis = 0;
    }
    return sendMessageAtTime(msg, SystemClock.uptimeMillis() + delayMillis);
}

The delay for post(r) is 0, so when is basically the current uptime. The second article will expand on why when is important. For now, just know that the message already carries the time information of "when it can be retrieved" upon enqueuing.

6. When is Message.target Set?

This is the most critical step in the Handler delivery chain.

sendMessageAtTime() eventually reaches enqueueMessage(). Before enqueuing, the Handler writes itself into msg.target:

private boolean enqueueMessage(MessageQueue queue, Message msg, long uptimeMillis) {
    msg.target = this;
    return queue.enqueueMessage(msg, uptimeMillis);
}

Source code entry points:

The significance of msg.target is: after this message is retrieved by the target thread, which Handler should it be handed back to for dispatch.

That is to say:

Before enqueuing:
    Handler writes itself into Message.target.
​
After dequeuing:
    Looper finds this Handler back via msg.target.

This is why a Message can exist merely as data in the queue, but after being retrieved, it can still return to the correct Handler.

image.png

This diagram focuses on two handovers:

First handover:
    Handler.enqueueMessage sets msg.target to the current Handler.
​
Second handover:
    Looper.loopOnce retrieves the Message and then calls msg.target.dispatchMessage(msg).

What are the three callbacks in dispatchMessage(msg)?

There are two callbacks here with very similar names but completely different meanings:

Path Source Use Case
msg.callback post(Runnable) Execute a block of code on the target thread
mCallback Handler constructor parameter Handle Message via composition without subclassing Handler
handleMessage() Override Handler method Centrally process ordinary Messages via fields like what

These three are not called in parallel but are judged in order:

  1. If a Runnable exists, execute it directly and end;
  2. Otherwise, let Handler.Callback try to consume it;
  3. If Callback doesn't exist or returns false, finally enter handleMessage.

7. How Does Looper.loop Retrieve the Message?

The target thread runs long-term inside Looper.loop().

Source code logic skeleton:

public static void loop() {
    Looper me = myLooper();
    for (;;) {
        if (!loopOnce(me, ident, thresholdOverride)) {
            return;
        }
    }
}

Inside loopOnce(), it fetches the next message from MessageQueue.next():

private static boolean loopOnce(...) {
    Message msg = me.mQueue.next();
    if (msg == null) {
        return false;
    }
​
    msg.target.dispatchMessage(msg);
    msg.recycleUnchecked();
    return true;
}

Source code entry points:

Here, grasp one action:

The target thread is not directly pushed to run by post.
The target thread actively retrieves messages from MessageQueue.next() within its own loop.

So when you ask "why does the Runnable execute on the main thread", the answer is not "because the child thread switched it over", but:

Because mainHandler holds a MainLooper reference;
The Runnable is wrapped into a Message and placed into MainLooper's MessageQueue;
The main thread is inside Looper.loop();
The main thread retrieves this Message from its own MessageQueue;
Finally, the main thread calls Runnable.run().

8. Why Does dispatchMessage Execute the Runnable First, Not handleMessage?

Many people have encountered this problem:

Handler handler = new Handler(Looper.getMainLooper()) {
    @Override
    public void handleMessage(Message msg) {
        // Why doesn't handler.post(runnable) enter here?
    }
};
​
handler.post(() -> doWork());

The reason lies in dispatchMessage().

Logic skeleton:

public void dispatchMessage(Message msg) {
    if (msg.callback != null) {
        handleCallback(msg);
    } else {
        if (mCallback != null) {
            if (mCallback.handleMessage(msg)) {
                return;
            }
        }
        handleMessage(msg);
    }
}

Source code entry point:

The priority is:

1. If msg.callback != null:
       Execute Runnable.run()
​
2. Else if a Callback was passed during Handler construction:
       Execute Callback.handleMessage()
​
3. Else:
       Execute Handler.handleMessage()

post(Runnable) puts the Runnable into Message.callback, so it takes the first path. It generally will not enter your overridden handleMessage().

This is also why, when troubleshooting "Handler didn't enter handleMessage", you should first look at the sending method:

sendMessage(msg):
    Usually goes to Callback or handleMessage.
​
post(runnable):
    Usually goes to Message.callback, i.e., Runnable.run.

9. So, Which Thread Does the Runnable Finally Execute On?

Let's return to the opening example:

Handler mainHandler = new Handler(Looper.getMainLooper());
​
new Thread(() -> {
    mainHandler.post(() -> updateUi());
}).start();

Breaking it down according to the source code chain:

Child Thread
  -> mainHandler.post(runnable)
  -> getPostMessage(runnable)
  -> msg.callback = runnable
  -> sendMessageAtTime(msg, now)
  -> msg.target = mainHandler
  -> MainLooper.mQueue.enqueueMessage(msg)
​
Main Thread
  -> Looper.loop()
  -> MainLooper.mQueue.next()
  -> Retrieves msg
  -> msg.target.dispatchMessage(msg)
  -> msg.callback.run()
  -> updateUi()

So the final execution thread is:

The thread where the Looper held by mainHandler resides.

It is not:

The thread that called post.

Nor is it:

The thread where the Handler object was new'd.

If a Handler is created like this:

Handler workerHandler = new Handler(workerThread.getLooper());

Then no matter if you call workerHandler.post() on the main thread, a Binder thread, or a thread pool thread, the final execution of Runnable.run() will be on the thread where workerThread.getLooper() resides.

10. Vague Statements vs. Accurate Statements

First group:

Vague statement:
    Handler switches threads.
​
Accurate statement:
    Handler delivers tasks to the target Looper's MessageQueue,
    and the actual execution occurs on the thread where the target Looper resides.

Second group:

Vague statement:
    Handler is the main thread.
​
Accurate statement:
    Handler holds a reference to some Looper;
    if it holds MainLooper, the message will execute on the main thread.

Third group:

Vague statement:
    post executes immediately.
​
Accurate statement:
    post merely enqueues;
    when it executes depends on the target MessageQueue and Looper.

Fourth group:

Vague statement:
    handler.post didn't enter handleMessage, meaning Handler didn't execute.
​
Accurate statement:
    post(Runnable) prioritizes the Message.callback path,
    so what executes is Runnable.run, not handleMessage.

11. Conclusion

handler.post() is not magic thread-switching.

It merely wraps the Runnable into a Message and places it into the MessageQueue of the Looper held by the Handler.

What truly executes the Runnable is not the thread that called post(), but the thread where the target Looper resides.

Summarized in one sentence:

The calling thread is only responsible for enqueuing; the target Looper thread is responsible for retrieving and executing.

This article resolves "how a task enters the target thread from the calling thread". But after the task is enqueued, there is a second question:

The Message is already in the queue, why isn't it executed immediately?

The next article will discuss this: Why isn't MessageQueue an ordinary queue?

Further Reading