> ## Content Index
> Fetch the complete content index at: https://www.ggorantala.dev/llms.txt
> Use this file to discover other available public pages before exploring further.

# Java Thread States Explained: What RUNNABLE Really Hides
- URL: https://www.ggorantala.dev/java-thread-lifecycle-states/
- Published: 2026-10-27T14:00:00.000Z
- Updated: 2026-10-30T14:00:30.000Z
- Description: 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.
- Author: Gopi Gorantala
- Tags: java-concurrency, java-threads

```log
"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.

```log
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()`.

```java
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:

```bash
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:

```log
              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](https://ggorantala.dev/java-deadlock/?ref=ggorantala.dev), 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](https://ggorantala.dev/java-wait-notify-notifyall/?ref=ggorantala.dev) 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()`:

```log
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](https://ggorantala.dev/java-thread-interrupt-interruptedexception/?ref=ggorantala.dev). 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:

```log
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:

```log
"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:

```bash
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](https://ggorantala.dev/java-wait-notify-notifyall/?ref=ggorantala.dev) has the details.