Skip to content

Java Thread States Explained: What RUNNABLE Really Hides

Java thread states, each one demonstrated: what puts a thread in BLOCKED vs WAITING, why RUNNABLE can mean idle in a socket read, and how to read it in jstack.

java-concurrency java-threads
Gopi Gorantala
Reading Progress

On This Page

"socket-reader" #32 prio=5 os_prio=0 cpu=2.73ms elapsed=13.66s nid=464 runnable
   java.lang.Thread.State: RUNNABLE
	at sun.nio.ch.SocketDispatcher.read0(java.base@21.0.12.1/Native Method)
	at sun.nio.ch.NioSocketImpl.implRead(java.base@21.0.12.1/NioSocketImpl.java:307)
	at java.net.Socket$SocketInputStream.read(java.base@21.0.12.1/Socket.java:1099)

Thirteen seconds of life, under three milliseconds of CPU, and the JVM calls it RUNNABLE. That thread is doing nothing. It's waiting for a peer that will never send a byte.

If you've ever scanned a dump for "the threads that are running" and counted this one, you've been misled by a perfectly accurate enum. This post is about the six values of Thread.State: what puts a thread in each, which ones lie by omission, and how to read them in a dump without fooling yourself. Everything below comes from one lab on OpenJDK 21.0.12.1, 2 vCPU x86_64. That's the only JDK I ran. Where I say something about 24 or 25, I'll say I didn't run it.

RUNNABLE, 2 ms of CPU, and nothing happening

Here's what the lab printed for twenty-odd threads, each parked in a different way. State on the left, what the thread was doing on the right, CPU time as the thread's own ThreadMXBean clock saw it.

21.0.12.1 cores=2
not started                        NEW            0 ms cpu
after join                         TERMINATED     0 ms cpu
spinning loop                      RUNNABLE       291 ms cpu
Thread.sleep(60s)                  TIMED_WAITING  0 ms cpu
Object.wait()                      WAITING        0 ms cpu
Object.wait(60000)                 TIMED_WAITING  0 ms cpu
woken by notifyAll, lock held by me BLOCKED        0 ms cpu
timed waiter, same                 BLOCKED        0 ms cpu
synchronized, monitor taken        BLOCKED        0 ms cpu
holder (sleeping inside sync)      TIMED_WAITING  0 ms cpu
ReentrantLock.lock(), taken        WAITING        0 ms cpu
ReentrantLock.tryLock(60s), taken  TIMED_WAITING  0 ms cpu
other.join()                       WAITING        0 ms cpu
LockSupport.park()                 WAITING        0 ms cpu
socket read(), peer silent         RUNNABLE       2 ms cpu
ServerSocket.accept()              RUNNABLE       0 ms cpu
Process.waitFor()                  WAITING        0 ms cpu
virtual: Thread.sleep              TIMED_WAITING  n/a
virtual: ReentrantLock.lock        WAITING        n/a
virtual: synchronized, taken       BLOCKED        n/a
virtual: socket read(), silent     WAITING        n/a

Two RUNNABLE rows. One burned 291 ms of CPU in 300 ms of wall time. The other, 2 ms. Same state.

And look at the last row. A virtual thread in the same socket read reports WAITING. I didn't expect that, and I'll come back to it.

In one paragraph

Thread.State describes what the JVM thinks a thread is waiting for, not what the operating system thinks it's doing. RUNNABLE means the JVM isn't aware of any reason the thread can't run. It might be on a core, in a run queue, or sitting inside a native call that the JVM has no way to see into. BLOCKED has one meaning: trying to enter a synchronized monitor someone else holds. WAITING and TIMED_WAITING mean the thread parked itself (or called wait, join, sleep) and needs another thread, or a clock, to move it on. The Javadoc says the same, in fewer words, and then says these are JVM states that don't reflect OS thread states. Read that last clause twice.

Make every state show up on demand

You need a JDK 21 (I used 21.0.12.1, any 21 will do) and java Lab.java. No dependencies. java -version should print something like openjdk version "21.0.12.1". Everything is a plain Thread, a latch-free Thread.sleep(300) to let the thread settle into whatever it's doing, and then getState().

import java.lang.management.*;
import java.net.*;
import java.io.*;
import java.util.concurrent.*;
import java.util.concurrent.locks.*;

public class Lab {
    static final Object MON = new Object();
    static final ReentrantLock RL = new ReentrantLock();

    static void settle() { try { Thread.sleep(300); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }

    static void show(String label, Thread t) {
        ThreadMXBean mx = ManagementFactory.getThreadMXBean();
        String cpu = t.isVirtual() ? "n/a" : String.valueOf(mx.getThreadCpuTime(t.threadId()) / 1_000_000) + " ms cpu";
        System.out.printf("%-34s %-14s %s%n", label, t.getState(), cpu);
    }

    public static void main(String[] a) throws Exception {
        System.out.println(System.getProperty("java.version") + " cores=" + Runtime.getRuntime().availableProcessors());
        // NEW / TERMINATED
        Thread n = new Thread(() -> {});
        show("not started", n);
        n.start(); n.join();
        show("after join", n);

        // RUNNABLE spin
        volatile_flag = true;
        Thread spin = new Thread(() -> { while (volatile_flag) Thread.onSpinWait(); }, "spin");
        spin.start(); settle(); show("spinning loop", spin);
        volatile_flag = false; spin.join();

        // TIMED_WAITING sleep
        Thread sl = new Thread(() -> { try { Thread.sleep(60_000); } catch (InterruptedException e) {} }, "sleeper");
        sl.start(); settle(); show("Thread.sleep(60s)", sl); sl.interrupt(); sl.join();

        // WAITING Object.wait / TIMED_WAITING wait(ms)
        Thread w = new Thread(() -> { synchronized (MON) { try { MON.wait(); } catch (InterruptedException e) {} } }, "waiter");
        w.start(); settle(); show("Object.wait()", w);
        Thread w2 = new Thread(() -> { synchronized (MON) { try { MON.wait(60_000); } catch (InterruptedException e) {} } }, "waiter-timed");
        w2.start(); settle(); show("Object.wait(60000)", w2);

        // BLOCKED: after notify, notifier still holds lock
        synchronized (MON) {
            MON.notifyAll();
            settle();
            show("woken by notifyAll, lock held by me", w);
            show("timed waiter, same", w2);
        }
        w.join(); w2.interrupt(); w2.join();

        // BLOCKED on monitor entry
        Thread holder; Thread blocked;
        CountDownLatch in = new CountDownLatch(1);
        holder = new Thread(() -> { synchronized (MON) { in.countDown(); try { Thread.sleep(60_000);} catch (InterruptedException e) {} } }, "holder");
        holder.start(); in.await();
        blocked = new Thread(() -> { synchronized (MON) { } }, "blocked");
        blocked.start(); settle();
        show("synchronized, monitor taken", blocked);
        show("holder (sleeping inside sync)", holder);
        holder.interrupt(); holder.join(); blocked.join();

        // ReentrantLock.lock -> WAITING, tryLock(timeout) -> TIMED_WAITING
        RL.lock();
        Thread rl = new Thread(() -> { RL.lock(); RL.unlock(); }, "rl");
        Thread rlt = new Thread(() -> { try { if (RL.tryLock(60, TimeUnit.SECONDS)) RL.unlock(); } catch (InterruptedException e) {} }, "rl-timed");
        rl.start(); rlt.start(); settle();
        show("ReentrantLock.lock(), taken", rl);
        show("ReentrantLock.tryLock(60s), taken", rlt);
        RL.unlock(); rl.join(); rlt.interrupt(); rlt.join();

        // join / park
        Thread other = new Thread(() -> { try { Thread.sleep(60_000);} catch (InterruptedException e) {} });
        other.start();
        Thread j = new Thread(() -> { try { other.join(); } catch (InterruptedException e) {} }, "joiner");
        j.start();
        Thread pk = new Thread(() -> LockSupport.park(), "parker");
        pk.start(); settle();
        show("other.join()", j); show("LockSupport.park()", pk);
        other.interrupt(); LockSupport.unpark(pk); j.join(); pk.join();

        // RUNNABLE but idle: blocked in socket read / accept
        try (ServerSocket ss = new ServerSocket(0)) {
            Thread cli = new Thread(() -> {
                try (Socket s = new Socket("127.0.0.1", ss.getLocalPort())) {
                    s.getInputStream().read();
                } catch (IOException e) {}
            }, "socket-reader");
            cli.start();
            Socket srv = ss.accept();
            settle(); settle();
            show("socket read(), peer silent", cli);
            Thread acc = new Thread(() -> { try (ServerSocket s2 = new ServerSocket(0)) { s2.accept(); } catch (IOException e) {} }, "acceptor");
            acc.setDaemon(true); acc.start(); settle();
            show("ServerSocket.accept()", acc);
            if (a.length > 0 && a[0].equals("hold")) {
                System.out.println("PID " + ProcessHandle.current().pid());
                Thread.sleep(45_000);
            }
            srv.close(); cli.join();
            acc.interrupt(); settle(); System.out.println("acceptor after interrupt(): alive=" + acc.isAlive() + " state=" + acc.getState());
        } catch (IOException e) { /* ignore */ }

        Process p = new ProcessBuilder("sleep", "30").start();
        Thread pw = new Thread(() -> { try { p.waitFor(); } catch (InterruptedException e) {} }, "proc-wait");
        pw.start(); settle(); show("Process.waitFor()", pw);
        p.destroy(); pw.join();

        // virtual threads
        Thread vs = Thread.ofVirtual().name("v-sleep").start(() -> { try { Thread.sleep(60_000);} catch (InterruptedException e) {} });
        Thread vl = Thread.ofVirtual().name("v-lock").start(() -> { RL.lock(); RL.unlock(); });
        RL.lock();
        Thread vm = Thread.ofVirtual().name("v-mon").unstarted(() -> { synchronized (MON) {} });
        CountDownLatch in2 = new CountDownLatch(1);
        Thread vh = Thread.ofPlatform().start(() -> { synchronized (MON) { in2.countDown(); try { Thread.sleep(5_000);} catch (InterruptedException e) {} } });
        in2.await(); vm.start(); settle();
        show("virtual: Thread.sleep", vs);
        show("virtual: ReentrantLock.lock", vl);
        show("virtual: synchronized, taken", vm);
        RL.unlock(); vs.interrupt(); vh.interrupt(); vh.join(); vm.join(); vs.join(); vl.join();
        try (ServerSocket ss = new ServerSocket(0)) {
            Thread vr = Thread.ofVirtual().name("v-read").start(() -> {
                try (Socket s = new Socket("127.0.0.1", ss.getLocalPort())) { s.getInputStream().read(); } catch (IOException e) {}
            });
            Socket srv = ss.accept(); settle();
            show("virtual: socket read(), silent", vr);
            srv.close(); vr.join();
        }
    }
    static volatile boolean volatile_flag;
}

Run it:

java Lab.java          # prints the table above, takes about 15 seconds
java Lab.java hold &   # same, but pauses 45 s after the socket rows and prints a PID
jcmd <pid> Thread.print | grep -A8 '"socket-reader"'

The table came out identical on every run I did, because nothing in it is a race. A thread that called sleep(60_000) is in TIMED_WAITING or the JVM has a bug. The settle() pause is the only timing assumption, and 300 ms on a 2-vCPU box was plenty. On a heavily loaded machine a thread might still be on its way into the monitor when you sample it; lengthen the pause before you decide the JDK is wrong.

Forcing levers, so you know what I did to make each one visible: a held ReentrantLock and a held monitor so the second thread has something to wait for; a 60-second sleep as a stand-in for "long enough that sampling always catches it"; a ServerSocket I accept on but never write to, so the reader has a peer that stays silent.

Cleanup is just killing the JVM. Everything in the lab either has a 60 s timer or dies when main returns, except one thing, which gets its own paragraph.

My first full run of this lab never exited. I let it sit for the two minutes my tool allowed, assumed I'd leaked a non-daemon thread from the virtual-thread block, and spent a while staring at that code. Wrong place. The leak was the ServerSocket.accept() thread. I'd called interrupt() on it at the end and assumed that was that. It wasn't. The next section has the numbers.

What moves a thread from one state to the next

Here's the whole machine:

              start()                         run() returns / throws
   NEW ------------------> RUNNABLE ---------------------------------> TERMINATED
                              |   ^
        monitor held by       |   |  monitor released and we win it
        someone else          v   |   (also: re-entry after wait/notify)
                           BLOCKED
                              |
   park(), wait(), join()     |   unpark(), notify(), target dies,
   no timeout                 v   interrupt()
                           WAITING --------> back through BLOCKED if it was wait()
                              |
   sleep(ms), wait(ms),       |   timeout elapses or the above
   parkNanos(), join(ms)      v
                        TIMED_WAITING

Three things in that diagram surprised me, in rough order of how long they cost.

BLOCKED is only about monitors. Not about "waiting for a lock". Waiting for a ReentrantLock is WAITING (or TIMED_WAITING with tryLock(timeout)), because ReentrantLock parks you through LockSupport.park, which is the same call as any other park. The lab shows it: ReentrantLock.lock(), taken is WAITING; synchronized, monitor taken is BLOCKED. I covered the dump-side of this in the deadlock post, where a ReentrantLock deadlock looks like two parking to wait for threads rather than two waiting to lock threads.

A thread you've notified is BLOCKED, not RUNNABLE. Rows 7 and 8 of the table. I called notifyAll() while still inside synchronized, and both waiters, one from wait() and one from wait(60000), moved to BLOCKED. They can't proceed until I leave the block, because returning from wait means re-acquiring the monitor. Two different states on the way in, the same state on the way out. The wait/notify post has the full story; the Javadoc line for BLOCKED ("or to re-enter one after calling Object.wait") says it too, in a clause that's easy to skip.

sleep inside synchronized is TIMED_WAITING, and the lock is still held. The holder row. The thread isn't BLOCKED, because it owns the monitor. The other thread is. If you only look for BLOCKED threads in a dump you'll find the victims and miss the cause, which is sitting in TIMED_WAITING (sleeping) with a locked <0x...> line under its frames.

And one that doesn't appear in the diagram because it isn't a transition at all. interrupt() on a thread sitting in accept():

acceptor after interrupt(): alive=true state=RUNNABLE

Nothing happened. A platform thread in ServerSocket.accept() or a classic Socket.read() doesn't respond to interrupt; you close the socket from another thread, and the blocked call throws SocketException. I went through which blockers respond in the interruption post. For this article the relevant part is the state: it stayed RUNNABLE the whole time.

How the JDK produces these values

Thread.State is an enum (since Java 5). Thread.getState() asks the JVM for a status int and maps it through a small helper. For platform threads that's a thin layer over HotSpot's own thread-state bookkeeping, which already distinguishes things like "blocked on monitor", "in Object.wait" and "parked". It folds "in Java", "in native" and "in VM" into the one word RUNNABLE. I'm saying that from the shape of the output, not from reading the HotSpot source, so treat it as a description of behaviour rather than a quote of implementation.

That folding is exactly why your socket reader says RUNNABLE. The thread is in a native read0, and from the JVM's side native code isn't something it can introspect for "waiting on I/O". Look at the top frame in the dump at the start of this post: SocketDispatcher.read0(Native Method). A native frame at the top of a RUNNABLE thread is the tell. It means the thread might be computing in C, or might be in a syscall that's been asleep for hours.

Virtual threads have their own getState() logic in VirtualThread. On JDK 21 the lab says: sleeping virtual thread, TIMED_WAITING; blocked in ReentrantLock.lock(), WAITING; waiting to enter a monitor, BLOCKED; reading from a silent socket, WAITING. That last one is different from the platform thread's answer, because a virtual thread that hits a blocking socket read doesn't sit in a native read. It parks, unmounts from its carrier, and the carrier goes off to run something else. By the time you ask for the state, there's no native frame to hide behind.

I tried to confirm the mapping by reading VirtualThread.threadState() at the jdk-21-ga tag through a summarising fetch. The summary claimed timed parks never produce TIMED_WAITING, which my own lab output contradicts on the same JDK. So I'm reporting what the program printed on 21.0.12.1 and not claiming I read the switch statement correctly. If you need the exact table, open the file; it's short.

Version notes, and what I did and didn't verify:

Java 5    Thread.State introduced (with the java.util.concurrent JMM rewrite)
JDK 21    virtual threads final (JEP 444); getState() works on them, with the
          differences above. Run: yes.
JDK 24    JEP 491: synchronized no longer pins virtual threads. How a virtual
          thread waiting for a monitor reports its state after that:
          NOT RUN. Retest the "virtual: synchronized" row on 24/25 before
          relying on it.

I spent most of two days on the question "when is a thread really idle" before this came together, and a good portion of that was convincing myself the answer is "you can't tell from the state alone." The enum was never designed to answer it.

How people misread the dump

Counting RUNNABLE as load. The commonest one. A service with 200 RUNNABLE threads and 2% CPU isn't busy; it has 200 threads in native reads. The discriminator is the top frame: a native method like read0, accept0, poll, epollWait (or Net.poll for NIO) means waiting on a descriptor. Compare cpu= in the thread header across two dumps taken a few seconds apart. If it doesn't move, the thread isn't running.

Treating BLOCKED as "waiting for a lock" in general. It's monitors only. A hung service where nothing is BLOCKED still can have a lock problem; it's WAITING (parking) on a ReentrantLock, Semaphore or CountDownLatch. Search for parking to wait for as well as waiting to lock.

Treating WAITING as an error. Idle pool workers sit in WAITING (parking) on the queue. A pool that's quiet and healthy has every worker there. What matters is whether the thing they're waiting for can ever happen.

Believing interrupt() will move a thread out of RUNNABLE. The acceptor above. If you want a shutdown path for a blocking socket, close the socket.

Using getState() in program logic. A thread's state can change between your call and your next line of code. The Javadoc for getState says it's meant for monitoring, not synchronization; I didn't re-fetch that page this run, so check the wording yourself, but the practical rule holds regardless: don't write if (t.getState() == WAITING) t.notify() and expect it to mean anything.

Waiting on Thread.sleep() to let the state change in a test. My lab does it, and it's a demo, not a test. A real test waits for the state with a bounded poll loop that gives up with a clear failure, never a fixed pause.

Reading states without fooling yourself

Whatever you're debugging, take at least three dumps a few seconds apart. Then, for each thread that matters:

  1. Read the state and the top frame, not just the state. RUNNABLE with a Java frame on top is probably computing. RUNNABLE with Native Method on top is probably waiting.
  2. For BLOCKED, find the waiting to lock <0x...> address, then search the same dump for locked <0x...>. That's the owner. Look at what the owner is doing: it's usually in TIMED_WAITING or RUNNABLE doing the slow thing.
  3. For WAITING (parking), look for parking to wait for <0x...> (a ...). The class name tells you which synchronizer. ReentrantLock$NonfairSync, Semaphore$NonfairSync, CountDownLatch$Sync, and ThreadPoolExecutor's queue each mean something different.

The HotSpot dump header says the same thing in plainer English. From a second lab (Dump.java, five threads), the same JDK:

"mon-blocked"  nid=617 waiting for monitor entry
   java.lang.Thread.State: BLOCKED (on object monitor)
	- waiting to lock <0x00000000a8085458> (a java.lang.Object)

"sleeper"      nid=618 waiting on condition
   java.lang.Thread.State: TIMED_WAITING (sleeping)

"obj-wait"     nid=619 in Object.wait()
   java.lang.Thread.State: WAITING (on object monitor)
	- waiting on <0x00000000a7f00000> (a java.lang.Object)

"rl-wait"      nid=620 waiting on condition
   java.lang.Thread.State: WAITING (parking)
	- parking to wait for  <0x00000000a808f998> (a java.util.concurrent.locks.ReentrantLock$NonfairSync)

"holder"       nid=616 waiting on condition
   java.lang.Thread.State: TIMED_WAITING (parking)

The parenthesis after the state (sleeping, parking, on object monitor) is the extra information getState() doesn't give you. The waiting for monitor entry, in Object.wait() and waiting on condition text on the header line is the older, JVM-internal vocabulary, and it's often quicker to scan than the state.

Virtual threads are the exception that costs you. A plain jcmd <pid> Thread.print doesn't list them. The JSON dump does:

jcmd <pid> Thread.dump_to_file -format=json /tmp/dump.json

I covered this in the deadlock post, where a virtual-thread deadlock was invisible to ThreadMXBean and to the text dump but present in the JSON one. Same JDK 21 caveat applies. A fuller guide to dumps is on my list as a separate post on jstack and jcmd.

For monitoring in production: a count of BLOCKED threads that stays above zero for a minute deserves a look; a pool whose workers are all WAITING while the queue is non-empty means the workers are waiting for something that isn't the queue; live thread count trending up means a leak. ThreadMXBean.getThreadInfo gives you the state, lock name and lock owner programmatically. I used it in the lab for CPU time.

Across upgrades: the six state names haven't moved since Java 5. What changes is which real behaviours map to which state, especially for virtual threads and monitors. Moving from 21 to 24 or 25, retest anything that parses states for virtual threads. I haven't.

Screenshot this bit

  • RUNNABLE means the JVM knows no reason the thread can't run. It doesn't mean it's running. Native frame on top and flat CPU time means it's waiting.
  • BLOCKED is only about synchronized, including re-entry after wait. Every other wait, including ReentrantLock.lock(), is WAITING or TIMED_WAITING.
  • A thread sleeping inside synchronized is TIMED_WAITING and still holds the lock. Find the owner with the locked <0x...> line.
  • interrupt() on a thread in classic socket accept() or read() does nothing. Close the socket.
  • Always take three dumps. One dump tells you where a thread is; three tell you whether it moves.

Questions people ask about thread states

What is the difference between BLOCKED and WAITING in Java? BLOCKED means the thread wants to enter a synchronized block and another thread holds the monitor. WAITING means the thread called a method like Object.wait(), Thread.join() or LockSupport.park() and needs another thread to wake it. ReentrantLock.lock() waits are WAITING, not BLOCKED. Checked on JDK 21.

Why is my thread RUNNABLE when it's waiting for I/O? Because the JVM reports a thread inside a native call as RUNNABLE; it can't see why the native call hasn't returned. A classic blocking socket read() is the usual example. A virtual thread in the same call reports WAITING on JDK 21 because it parks instead.

Does Java have a RUNNING state? No. RUNNABLE covers both running on a CPU and ready to run. The Javadoc says a RUNNABLE thread may be waiting for OS resources such as the processor.

Can a thread go from WAITING back to RUNNABLE directly? A thread in Object.wait() can't. When it's notified it moves to BLOCKED until it can re-acquire the monitor, then to RUNNABLE. Park-based waits (LockSupport, ReentrantLock, join) return to RUNNABLE without that step. The wait/notify post has the details.

java-concurrencyjava-threads

Staff engineer architecting Kafka and Flink platforms for banking, where a dropped event is a reconciliation break, not a retry. Through javahandbook.com, I share production-tested guidance on building reliable event-driven systems.

Comments