Why does Java's RUNNABLE state cover both a thread actively running and a thread blocked on I/O, and what are the consequences for diagnosing performance with thread dumps?
answer
- RUNNABLE = on-CPU OR ready OR blocked-on-I/O
- JVM doesn't model OS scheduling or kernel I/O waits
- socketRead0/epollWait under RUNNABLE = idling on network, not CPU
- Read stack frames + per-thread CPU time, not the state label
- Virtual threads target exactly this I/O-wait inefficiency
basics
~20 sJava's RUNNABLE lumps together threads on a CPU, threads queued to run, and threads waiting on I/O, because the JVM can't reliably tell these apart from the OS. So 'RUNNABLE' in a dump doesn't prove a thread is actually using CPU.
solid answer
~50 sJava's Thread.State has no separate states for 'running', 'ready', or 'blocked on I/O' — all three are RUNNABLE. The reason is portability and observability: whether a runnable thread is on a core or in the OS run queue is the operating system's business, and a thread blocked inside a native socket read looks, to the JVM, like it's just executing native code. The JVM doesn't model OS-level I/O waits, so it reports RUNNABLE. The diagnostic consequence is big: in a thread dump, a thread shown RUNNABLE deep in a `socketRead0` or `epollWait` frame is NOT burning CPU — it's waiting on the network. So you can't equate 'RUNNABLE count' with 'CPU demand'. To find real CPU hogs you correlate thread dumps with per-thread CPU time (e.g. top -H, async-profiler), and you read the actual stack frames rather than trusting the state label.
go deeper
Knows RUNNABLE means 'running or able to run' and that it isn't the same as BLOCKED/WAITING.
Recognizes that I/O-blocked threads appear RUNNABLE and that the state alone doesn't prove CPU usage.
Explains why the JVM merges running/ready/I/O-wait, and diagnoses dumps by reading native frames plus per-thread CPU time rather than trusting the label.
Ties the RUNNABLE-overload to OS scheduling boundaries and the motivation for virtual threads, and builds observability practices (JFR/profilers) that separate on-CPU from off-CPU work.
## The puzzle Java's `Thread.State` enum has **RUNNABLE** but no `RUNNING`, no `READY`, and no `IO_WAIT`. Yet at the OS level those are genuinely different conditions. Why the coarse mapping, and what does it cost you? ## What 'RUNNABLE' actually means in Java The Javadoc defines RUNNABLE as: a thread executing in the JVM that **may** be waiting for an OS resource such as a processor. Read that carefully — it deliberately covers: 1. A thread **actually on a CPU core** right now. 2. A thread **ready and queued** by the OS scheduler, waiting only for a core. 3. A thread **blocked on I/O** (a socket read, file read, etc.). ### Why merge running and ready? The distinction between 'currently on a core' and 'queued, waiting for a core' is owned entirely by the **OS scheduler**. The JVM has no portable, reliable way to observe it, and it changes nanosecond to nanosecond. Modeling it would make the state racy and OS-specific, so Java collapses both into RUNNABLE. ### Why does I/O-wait count as RUNNABLE? When a Java thread does, say, `socket.getInputStream().read()`, it eventually calls a **native** method (`socketRead0`) that traps into the kernel and the thread sleeps until data arrives. But Java's state model only knows about *Java-level* blocking constructs — monitors (BLOCKED) and `wait`/`join`/`park` (WAITING/TIMED_WAITING). It has **no notion of kernel I/O blocking**, so from the JVM's perspective the thread is 'executing native code' = RUNNABLE. (This is exactly the wait that virtual threads were designed to make cheap by unmounting the carrier.) ## The diagnostic trap Because RUNNABLE is overloaded, **a RUNNABLE thread is not necessarily consuming CPU.** Two pitfalls: 1. **Mistaking I/O waits for CPU work.** A thread dump full of RUNNABLE threads parked in `socketRead0`/`epollWait` is *not* a CPU problem — it's threads idling on the network. Throwing more CPU at it won't help; the bottleneck is downstream latency or connection-pool sizing. 2. **Counting RUNNABLE ≠ measuring demand.** The number of RUNNABLE threads overstates real CPU pressure if many are I/O-bound. ## How to diagnose correctly - **Read the stack frames, not the label.** A RUNNABLE thread sitting in `socketRead0`, `epollWait`, `read`, or similar native frames is waiting on I/O. A RUNNABLE thread in your hot business-logic loop or a tight `compute()` is the real CPU consumer. - **Correlate with per-thread CPU time.** Tools like `top -H -p <pid>` (Linux), `jstack` + native thread IDs, or a sampling profiler (async-profiler, JFR) show which threads actually accumulate CPU time. JFR/async-profiler distinguish on-CPU from off-CPU. - **Take multiple dumps.** A thread genuinely RUNNABLE-and-on-CPU will show *moving* stacks across successive dumps; an I/O-waiter shows the *same* native frame repeatedly. ## The takeaway RUNNABLE is a **coarse** label by design — it trades precision for portability and stability. Treat it as 'not blocked on a Java-level lock/wait', not as 'using CPU'. Real performance triage reads the frames and measures CPU time, never just the state column.
- How do platform threads blocked on I/O differ from virtual threads doing the same?A platform thread blocked in native I/O holds its OS thread (shown RUNNABLE, idle). A virtual thread blocking on I/O unmounts from its carrier platform thread, freeing the carrier to run other virtual threads — so blocking I/O no longer pins an OS thread.
- Given a thread dump, how do you tell a CPU-bound RUNNABLE thread from an I/O-bound one?Inspect the top stack frames: native I/O frames (socketRead0, epollWait) mean I/O wait; application/compute frames mean CPU work. Confirm with per-thread CPU time and by comparing successive dumps for stack movement.
saying these in an interview costs you the question
- Assuming every RUNNABLE thread is consuming CPU.
- Believing Java has a separate I/O-wait or RUNNING state.
- Diagnosing a CPU bottleneck purely from the RUNNABLE thread count.
- Thinking socket I/O shows as BLOCKED or WAITING in a dump.