How Android's Main Thread Works: Looper, Handler, and the MessageQueue Event Loop

skydovesJaewoong Eum (skydoves)||24 min read

How Android's Main Thread Works: Looper, Handler, and the MessageQueue Event Loop

Every Android developer talks to the main thread without thinking about what it is. You write Handler(Looper.getMainLooper()).post { textView.text = result } to jump back from a background thread, you hear "don't block the main thread" repeated until it becomes a reflex, and you have seen the crash that arrives the moment you touch a View from the wrong thread. The surface is familiar enough that the structure underneath disappears. A thread, in plain Java, runs its run() method once and then dies. Yet the main thread of your app stays alive for the entire lifetime of the process, waking up to run a touch event, then a frame callback, then a Runnable you posted, then going back to sleep. The deeper question is what actually keeps that thread alive and feeds it work, one item at a time, in the right order, without burning the battery while it waits.

In this article, you'll dive deep into the machinery behind the main thread, exploring how Looper binds one event loop to a thread through ThreadLocal, how Looper.loop() pulls messages and dispatches them, how MessageQueue.next() blocks on a native poll without consuming CPU, how Message forms an intrusive linked list ordered by time and recycles through an object pool, how posting from another thread wakes the sleeping queue, how Handler.dispatchMessage decides what code to run, and how sync barriers and asynchronous messages let frame work jump the line.

The fundamental problem: A thread that stays alive and processes work over time

A thread in Java is built to do one job and exit. You give it a run() method, it executes top to bottom, and when run() returns the thread is finished. That model is wrong for a UI thread. The UI thread does not know in advance what work it will do. Touch events arrive when the user touches the screen, frame callbacks arrive sixty or more times a second, background threads hand it results at unpredictable moments. The thread has to outlive any single piece of work and keep accepting more.

The naive way to keep a thread alive is to never let run() return. You write an infinite loop that checks a shared collection of pending work and runs whatever it finds:

class BusyThread extends Thread {
    final Queue<Runnable> tasks = new ConcurrentLinkedQueue<>();

    public void run() {
        while (true) {
            Runnable task = tasks.poll();
            if (task != null) {
                task.run();
            }
        }
    }
}

This keeps the thread alive, and it processes work in order. The problem is the word while (true). When the queue is empty, tasks.poll() returns null immediately, the loop spins back to the top, polls again, gets null again, and repeats as fast as the CPU allows. A thread that has nothing to do is pinning a core at one hundred percent, draining the battery, and heating the device, all to discover over and over that there is no work. This is a busy wait, and it is the thing the real design has to avoid.

What you want instead is a thread that sleeps when there is no work and wakes the instant work arrives. Sleeping has to be real sleeping: the operating system parks the thread, the scheduler gives the CPU to someone else, and the thread consumes zero cycles until something pokes it. Android builds exactly this. The loop is Looper, the queue is MessageQueue, and the sleep and wake happen down in native code over a file descriptor. The rest of this article is how those three pieces fit together.

Looper: One event loop per thread, anchored by ThreadLocal

A Looper is the object that owns the event loop for a single thread. The relationship is one to one: a thread has at most one Looper, and a Looper belongs to exactly one thread. The mechanism that enforces this is ThreadLocal, which you can think of as a variable whose value is private to each thread. Two threads reading the same ThreadLocal see two different values, as if each thread had its own copy.

If you look at the static state in Looper, the ThreadLocal holds the per thread instance, and a separate static field holds the main thread's looper so any thread can reach it:

static final ThreadLocal<Looper> sThreadLocal = new ThreadLocal<Looper>();
private static Looper sMainLooper;  // guarded by Looper.class

final MessageQueue mQueue;
final Thread mThread;

A thread does not get a Looper automatically. You install one by calling Looper.prepare(), which constructs a Looper and stores it in the ThreadLocal for the calling thread. Looking at 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));
}

The guard is the rule that one thread can have only one loop. If the ThreadLocal already holds a Looper, a second prepare() throws. The Looper constructor is where the queue is born: it creates the MessageQueue and records which thread it is running on.

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

The main thread is a special case. You never call prepare() for it yourself. When your process starts, the framework runs ActivityThread.main(), and that method calls Looper.prepareMainLooper() followed eventually by Looper.loop(). The main looper variant is the same prepare() with quitAllowed set to false, plus it records the instance in the static sMainLooper field:

public static void prepareMainLooper() {
    prepare(false);
    synchronized (Looper.class) {
        if (sMainLooper != null) {
            throw new IllegalStateException("The main Looper has already been prepared.");
        }
        sMainLooper = myLooper();
    }
}

Two things follow from this. The main looper passes quitAllowed = false, which is why calling quit() on the main thread's queue throws "Main thread not allowed to quit". And sMainLooper is a process wide reference, which is what Looper.getMainLooper() returns, so any background thread can build a Handler that targets the main thread. The per thread lookup is myLooper(), which is just the ThreadLocal read:

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

This is also why constructing a plain Handler() on a thread that never called prepare() fails. The Handler constructor calls Looper.myLooper(), gets null, and throws "Can't create handler inside thread that has not called Looper.prepare()".

Looper.loop(): The infinite loop that dispatches and recycles

Once a thread has a Looper, calling Looper.loop() turns the thread into an event loop. This is the call that does not return until the queue quits, and on the main thread it is the last meaningful line of ActivityThread.main(). Every touch event, every frame, every posted Runnable your app ever handles runs inside this one loop.

The structure of loop() is a for (;;) that repeatedly calls a helper, loopOnce, and returns when that helper signals the loop should stop:

public static void loop() {
    final Looper me = myLooper();
    if (me == null) {
        throw new RuntimeException("No Looper; Looper.prepare() wasn't called on this thread.");
    }
    me.mInLoop = true;
    final long ident = Binder.clearCallingIdentity();
    final int thresholdOverride = getThresholdOverride();
    me.mSlowDeliveryDetected = false;

    for (;;) {
        if (!loopOnce(me, ident, thresholdOverride)) {
            return;
        }
    }
}

This article continues for subscribers

Subscribe to Dove Letter for full access to 40+ deep-dive articles about Android and Kotlin development.

Become a Sponsor