Junior 10 min · March 05, 2026

Java Thread States — Lock During I/O Causes BLOCKED

A 30-second 503? Check thread dumps for BLOCKED threads on a lock held during I/O — exactly the real production incident we decode step by step..

N
Naren Founder & Principal Engineer

20+ years shipping production Java in banking & fintech. Drawn from code that ran under real load.

Follow
Production
production tested
May 23, 2026
last updated
1,554
articles · all by Naren
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • Java threads cycle through 6 states: NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, TERMINATED
  • state() method reveals the current state in a thread dump
  • BLOCKED and WAITING are not the same — monitor contention vs indefinite park
  • TIMED_WAITING is WAITING with a timeout — always bound
  • Thread state transitions are driven by JVM internals and OS scheduling
  • Biggest mistake: treating RUNNABLE as "actively running" — it includes ready-to-run
✦ Definition~90s read
What is Thread Lifecycle in Java?

Java thread states are the JVM-level lifecycle phases every thread passes through, defined by the Thread.State enum: NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, and TERMINATED. These aren't OS thread states—they're a JVM abstraction that maps to underlying platform threads (e.g., pthreads on Linux, Windows threads).

Imagine a chef in a restaurant kitchen.

The critical distinction most developers miss is between BLOCKED and WAITING: BLOCKED means a thread is actively trying to acquire a monitor lock (synchronized block/method) that another thread holds, while WAITING means the thread is waiting indefinitely for another thread to perform a specific action (e.g., Object.wait(), Thread.join(), LockSupport.park()). This matters because BLOCKED threads consume CPU cycles in contention, whereas WAITING threads are parked efficiently until notified.

The classic pitfall this article addresses is I/O operations causing BLOCKED states. When a thread performs blocking I/O (e.g., InputStream.read(), Socket.connect(), or JDBC queries), the underlying OS thread is actually in a WAITING-like state at the OS level, but the JVM still reports it as RUNNABLE because the Java thread hasn't released its monitor lock.

This leads to thread dumps showing RUNNABLE threads that aren't actually making progress—they're stuck on I/O. The real BLOCKED state only appears when another thread is contending for a synchronized lock held by the I/O-bound thread. Tools like jstack, jcmd, and async-profiler reveal this: you'll see a thread in RUNNABLE with a stack trace deep in native I/O methods, while other threads pile up in BLOCKED waiting for that same lock.

Understanding this distinction is crucial for diagnosing thread pool exhaustion in applications like Tomcat (default 200 threads) or Netty's event loop groups, where one slow I/O operation can cascade into system-wide blocking.

Plain-English First

Imagine a chef in a restaurant kitchen. Sometimes they're actively cooking (RUNNING). Sometimes they're waiting for ingredients to arrive (WAITING). Sometimes a timer is going off and they'll be ready in 30 seconds (TIMED_WAITING). Sometimes another chef is using the stove and our chef is standing right there ready to grab it the moment it's free (BLOCKED). Before their shift starts, they haven't even put on their apron yet (NEW). When the shift ends and the kitchen closes, they're done for the night (TERMINATED). Java threads are exactly like that chef — and the JVM is the kitchen manager deciding who gets the stove.

Every production outage involving threads — the deadlock that froze your payment service at 2 AM, the thread pool that silently starved under load, the race condition that corrupted user data — traces back to a misunderstanding of what a thread is actually doing at any given moment. The Java thread lifecycle isn't just an academic diagram you memorize for interviews. It's the mental model that lets you read a thread dump, diagnose a hung application, and design concurrent systems that hold up under real traffic.

The problem is that most resources treat the lifecycle as a static state machine — here are the six boxes, here are the arrows, done. But threads don't live in boxes. They transition between states in ways that depend on OS scheduling, JVM implementation details, monitor ownership, and the specific flavor of waiting you've asked them to do. Miss those nuances and you'll write code that looks correct, passes unit tests, and then silently misbehaves in production with 200 concurrent users.

By the end of this article you'll be able to read a real thread dump and know exactly what each thread is doing and why. You'll understand the difference between BLOCKED and WAITING at the JVM level — not just the textbook definition. You'll know which state transitions are guaranteed, which are platform-dependent, and which ones hide the bugs that take senior engineers days to find. Let's build that mental model from the ground up.

What Thread Lifecycle in Java Actually Means

A Java thread lifecycle is the state machine every thread passes through from creation to termination: NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, and TERMINATED. The JVM manages transitions between these states based on thread scheduling, lock acquisition, and I/O operations. Understanding this lifecycle is critical because a thread in BLOCKED state is not consuming CPU but is holding resources, which can cascade into system-wide stalls.

Threads start in NEW after instantiation but before start() is called. Once started, they enter RUNNABLE — the only state where the thread can actually execute on a CPU core. From RUNNABLE, a thread can transition to BLOCKED when it fails to acquire an intrinsic lock (synchronized block), to WAITING via Object.wait() or LockSupport.park(), or to TIMED_WAITING via sleep() or timed waits. The key property: BLOCKED threads are waiting for a lock held by another thread, while WAITING threads are waiting for a signal from another thread.

In real systems, the most dangerous state is BLOCKED because it often indicates lock contention. A thread blocked on I/O — say, reading from a slow database connection inside a synchronized block — will hold its lock, forcing all other threads needing that lock into BLOCKED state. This can collapse throughput from thousands of requests per second to near zero. The rule: never hold locks during blocking I/O operations.

BLOCKED vs WAITING — Not the Same
A BLOCKED thread is waiting for a monitor lock; a WAITING thread is waiting for a signal. BLOCKED threads waste CPU only in context switching, but WAITING threads can be woken efficiently via notify().
Production Insight
A payment service using synchronized blocks around HTTP calls to a fraud-check API saw all threads pile into BLOCKED state during a 2-second timeout, dropping throughput from 500 req/s to 12 req/s.
The symptom: thread dumps showed hundreds of threads in BLOCKED state waiting on the same monitor, while the holder thread was stuck in TIMED_WAITING on a socket read.
Rule: never hold a lock across I/O — use explicit locks with tryLock(timeout) or restructure to avoid synchronized blocks around network calls.
Key Takeaway
BLOCKED state means lock contention, not idle — it's a system-wide bottleneck signal.
Never hold a lock during blocking I/O; it turns a single slow operation into a thread pileup.
Use jstack or async-profiler to capture thread dumps during stalls — BLOCKED threads with the same monitor point to the culprit lock.
Java Thread States and I/O Blocking THECODEFORGE.IO Java Thread States and I/O Blocking Thread lifecycle transitions and BLOCKED vs WAITING states NEW Thread created but not started RUNNABLE Ready or running on CPU BLOCKED Waiting for monitor lock WAITING Waiting indefinitely for signal TIMED_WAITING Waiting with timeout TERMINATED Execution completed ⚠ I/O operations cause BLOCKED only on lock contention Use thread dumps to distinguish BLOCKED from WAITING states THECODEFORGE.IO
thecodeforge.io
Java Thread States and I/O Blocking
Thread Lifecycle Java

The 6 Thread States — What Each Actually Means

Java defines six thread states in java.lang.Thread.State. They're not just labels — each maps to a specific JVM or OS condition.

  • NEW: Thread created but start() not called. Not yet alive.
  • RUNNABLE: Thread is executing in the JVM (or ready to execute, waiting for CPU). Includes both running and ready-to-run.
  • BLOCKED: Thread is waiting for a monitor lock to enter a synchronized block/method.
  • WAITING: Thread is waiting indefinitely for another thread to perform a specific action (e.g., wait(), join(), park()).
  • TIMED_WAITING: Same as WAITING but with a timeout (sleep, wait(timeout), join(timeout), parkNanos).
  • TERMINATED: Thread has completed (run() finished or exception).

The key insight: RUNNABLE does not mean 'using CPU right now'. It means the thread is eligible for scheduling. The OS decides when it actually runs. This is why busy-wait loops (while(!flag)) keep a thread in RUNNABLE but waste CPU.

io/thecodeforge/thread/ThreadStateDemo.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package io.thecodeforge.thread;

public class ThreadStateDemo {
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(() -> {
            // thread will be RUNNABLE during execution
            try {
                Thread.sleep(1000); // TIMED_WAITING
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }, "demo-thread");

        System.out.println("After creation: " + t.getState()); // NEW
        t.start();
        System.out.println("After start: " + t.getState()); // RUNNABLE
        Thread.sleep(200);
        System.out.println("Mid-sleep: " + t.getState()); // TIMED_WAITING
        t.join();
        System.out.println("After join: " + t.getState()); // TERMINATED
    }
}
Output
After creation: NEW
After start: RUNNABLE
Mid-sleep: TIMED_WAITING
After join: TERMINATED
Production Insight
In production, BLOCKED threads are the silent killers. A single thread stuck on I/O while holding a lock can cascade into a full system freeze. Monitor 'jstack' output for BLOCKED states — especially if multiple threads are blocked on the same monitor. That's a deadlock or lock contention waiting to happen.
Rule: If you see more than 5 threads BLOCKED in a dump, investigate immediately.
Key Takeaway
Six states, but only three matter in debugging: BLOCKED, WAITING, RUNNABLE.
BLOCKED = lock contention.
WAITING = missing signal.
RUNNABLE = maybe running, maybe just ready.
Punchline: Read the state, then find the root cause.

State Transitions — The Arrows Between the Boxes

  • NEW → RUNNABLE: Calling start().
  • RUNNABLE → BLOCKED: Attempting to enter a synchronized block/method without the lock. JVM puts you on the monitor's entry set.
  • BLOCKED → RUNNABLE: The lock holder releases the lock (exits synchronized block).
  • RUNNABLE → WAITING: Calling Object.wait(), Thread.join(), or LockSupport.park(). Thread is put in the wait set of the monitor.
  • WAITING → RUNNABLE: Another thread calls notify()/notifyAll() on the same monitor, or the thread is interrupted. But the thread must re-acquire the lock before proceeding — so it goes to BLOCKED first, then RUNNABLE.
  • RUNNABLE → TIMED_WAITING: Thread.sleep(time), wait(timeout), join(timeout), parkNanos().
  • TIMED_WAITING → RUNNABLE: Timeout expires, or notify/interrupt.
  • RUNNABLE → TERMINATED: run() completes.

The critical detail: after notify(), the waiting thread doesn't run immediately. It must re-acquire the monitor lock. This is why waiting code should always loop on the condition (spurious wakeup).

io/thecodeforge/thread/TransitionDemo.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
package io.thecodeforge.thread;

public class TransitionDemo {
    private static final Object lock = new Object();
    private static boolean ready = false;

    public static void main(String[] args) throws InterruptedException {
        Thread waiter = new Thread(() -> {
            synchronized (lock) {
                while (!ready) { // must loop
                    try {
                        lock.wait(); // RUNNABLE -> WAITING
                    } catch (InterruptedException e) {
                        Thread.currentThread().interrupt();
                    }
                }
                System.out.println("Condition met!");
            }
        }, "waiter");
        waiter.start();
        Thread.sleep(500); // let waiter get to WAITING
        System.out.println("Waiter state: " + waiter.getState()); // WAITING

        Thread notifier = new Thread(() -> {
            synchronized (lock) {
                ready = true;
                lock.notify(); // transition: WAITING -> BLOCKED -> RUNNABLE
            }
        }, "notifier");
        notifier.start();
        waiter.join();
        System.out.println("Final state: " + waiter.getState()); // TERMINATED
    }
}
Output
Waiter state: WAITING
Condition met!
Final state: TERMINATED
The Bouncer Analogy
  • Entry set: Threads trying to enter the synchronized block (BLOCKED). Bouncer holds them back until the current occupant leaves.
  • Wait set: Threads that called wait() (WAITING). They voluntarily stepped aside and wait for a signal from the bouncer.
  • When notify() is called, one thread moves from wait set to entry set. It's still BLOCKED until it actually grabs the lock.
  • Multiple notify() calls move multiple threads — but only one gets the lock at a time.
  • Always wait inside a while loop — because of spurious wakeups and the gap between notify() and lock acquisition.
Production Insight
A classic production fail: A thread calls notify() but the condition the waiting thread checks is still false because of a race. The waiting thread wakes, checks the condition, finds it false, and goes back to WAITING. The notifier never calls notify() again, so the waiter waits forever. This is why the 'while loop' around wait() is non-negotiable.
Debugging tip: If you see threads in WAITING but no BLOCKED threads on the same monitor, the notifier may have fired too early or only once.
Key Takeaway
After notify(), the waiting thread must still re-acquire the lock (BLOCKED) before proceeding.
Always loop on the condition when using wait().
Spurious wakeups are real — the JVM spec says they can happen.
Punchline: If your wait() isn't in a while loop, you'll hit a production bug within a year.

BLOCKED vs WAITING — The JVM Difference

At the JVM level, BLOCKED and WAITING are distinct in the thread dump output:

  • BLOCKED (on object monitor): The thread is in the entry set of a monitor, waiting to acquire the lock. The dump shows which lock and which thread holds it.
  • WAITING (on object monitor): The thread is in the wait set, having called wait() on that monitor. The dump shows 'waiting on <monitor>' but not who will wake it.
  • WAITING (parking): Thread used LockSupport.park() — typically from java.util.concurrent (e.g., ForkJoinPool workers, CompletableFuture).

The performance impact: A BLOCKED thread consumes no CPU but the OS keeps it in the scheduler's run queue (it's legally runnable but the JVM won't let it). In contrast, a WAITING thread is typically descheduled until notified. Both are 'idle' but the reason matters for debugging.

A thread dump might show hundreds of BLOCKED threads all waiting on the same lock — that's a contention hotspot. WAITING threads on the same condition often indicate a missing notify. WAITING threads with 'parking' are usually normal (thread pool idle).

io/thecodeforge/thread/BlockedVsWaiting.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
package io.thecodeforge.thread;

public class BlockedVsWaiting {
    private static final Object lock = new Object();

    public static void main(String[] args) throws InterruptedException {
        // Thread that holds the lock forever
        Thread holder = new Thread(() -> {
            synchronized (lock) {
                try {
                    Thread.sleep(10000); // holds lock while sleeping
                } catch (InterruptedException e) {}
            }
        }, "holder");

        // Thread that blocks trying to get lock
        Thread blocker = new Thread(() -> {
            synchronized (lock) { // will be BLOCKED
                System.out.println("Never prints");
            }
        }, "blocker");

        // Thread that waits on the same lock (after notify, it would block)
        Thread waiter = new Thread(() -> {
            synchronized (lock) {
                try {
                    lock.wait(); // WAITING
                } catch (InterruptedException e) {}
            }
        }, "waiter");

        holder.start();
        Thread.sleep(100);
        blocker.start();
        waiter.start();
        Thread.sleep(100);

        System.out.println("Holder: " + holder.getState());    // TIMED_WAITING (sleep)
        System.out.println("Blocker: " + blocker.getState());  // BLOCKED
        System.out.println("Waiter: " + waiter.getState());     // WAITING

        System.exit(0);
    }
}
Output
Holder: TIMED_WAITING
Blocker: BLOCKED
Waiter: WAITING
Production Insight
When you see a thread dump with 20+ threads in BLOCKED, don't panic about thread count — panic about which lock. Find the thread that's actually holding it. In a real incident, we traced a BLOCKED cluster to a ThreadPoolExecutor where all worker threads were blocked on a ConcurrentHashMap resize — the map was being resized and took a global lock. That's a known performance pitfall: ConcurrentHashMap's resize blocks all threads briefly.
Rule: BLOCKED threads are always a symptom, not the disease. The real problem is the thread holding the lock.
Key Takeaway
BLOCKED = waiting for a lock (entry set). WAITING = waiting for a signal (wait set).
They are not interchangeable — a WAITING thread gives up its lock, a BLOCKED thread can't even try.
The biggest misdiagnosis: treating a BLOCKED thread as a 'stuck' thread. It's stuck, but the root cause is the lock holder.
Punchline: Always look at the lock owner, not the blocked thread itself.
Diagnose BLOCKED vs WAITING
IfThread is BLOCKED on a monitor held by another thread that's RUNNABLE (but slow)
UseThe holder is doing CPU-intensive work inside the synchronized block. Optimize the critical section or reduce granularity.
IfThread is BLOCKED on a monitor held by a thread that's itself BLOCKED
UseDeadlock. Capture full thread dump with 'jstack -l' and look for threads involved in the cycle.
IfThread is WAITING on a condition with no BLOCKED threads on that monitor
UseMissing notify() or notify() happened before wait(). Check that the notifier sets a boolean flag and that the waiter checks it.
IfThread is WAITING (parking) — often from ForkJoinPool or CompletableFuture
UseNormal idle thread. Nothing to fix unless there's a scalability concern.

Thread Dump Analysis — Reading the Lifecycle in Action

When a production incident hits, your first tool is the thread dump. Here's what you're looking for:

  • Thread name: Often configured in thread pools. 'http-nio-8080-exec-1' indicates a Tomcat worker.
  • State: One of the six above.
  • Stack trace: Shows exactly where the thread is blocked.
  • Lock details: 'waiting for <0x00000007>', 'locked <0x00000008>'. The hex ID identifies the monitor.
  1. Deadlock: Thread A holds lock L1 and wants L2. Thread B holds L2 and wants L1. Both are BLOCKED. The dump explicitly says 'Found one Java-level deadlock'.
  2. Lock contention: Many threads BLOCKED on the same lock, one owner.
  3. Missed signal: Threads WAITING on a condition, nobody holding the lock.
  4. Spinning: Thread state is RUNNABLE but the stack trace shows a tight loop (while(!flag){ } ) — consumes CPU without progress.
dump_analysis_example.shBASH
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# Capture thread dump without killing the process
jstack -l <pid> > /tmp/threaddump_$(date +%s).txt

# Quick scan for BLOCKED threads
grep -E 'BLOCKED|WAITING \(on object monitor\)' /tmp/threaddump_*.txt

# Identify deadlocks explicitly
grep -A 10 'Found one Java-level deadlock' /tmp/threaddump_*.txt

# See which threads hold which locks
grep -E 'locked <|waiting for <' /tmp/threaddump_*.txt

# Count threads per state
awk '/java.lang.Thread.State:/{state=$3; count[state]++} END{for(s in count) print s, count[s]}' /tmp/threaddump_*.txt

# Example output:
# RUNNABLE 12
# BLOCKED 4
# WAITING 3
# TIMED_WAITING 2
# TERMINATED 0
# Expected: healthy pool can have many TIMED_WAITING (idle) and some RUNNABLE
Production Insight
Another gotcha: Thread dumps from a live system over 60 seconds can show the same threads in WAITING state each time. That's expected for idle thread pools. The problem is when threads that should be working are perpetually WAITING — that's a bottleneck upstream.
Key Takeaway
Thread dump analysis is pattern matching: BLOCKED cluster = lock contention; single WAITING thread with no BLOCKED = likely missed notify; many WAITING (park) = idle pool.
Don't trust RUNNABLE as 'busy' — native I/O calls show as RUNNABLE.
Punchline: The state tells you what, the stack trace tells you why.

Common Pitfalls and How to Avoid Them

Even experienced engineers fall into these traps. Here are the most common production failures linked to thread lifecycle misunderstanding:

Pitfall 1: Holding a lock during I/O A synchronized block wrapping a database call or HTTP request. If the external call hangs, every other thread wanting that lock is stuck in BLOCKED. Fix: Move I/O outside the synchronized block, or use a read/write lock, or apply a timeout on the I/O and recheck inside.

Pitfall 2: Notify without state flag Calling notify() but forgetting to set a condition variable that the waiting thread checks. The waiting thread wakes, checks the condition, finds it false, and goes back to WAITING — never to be woken again. Fix: Always use a boolean flag in conjunction with wait/notify.

Pitfall 3: Calling start() twice Thread.start() can only be called once. A second call throws IllegalThreadStateException. This happens often when reusing a thread object. Fix: Create a new Thread instance for each execution.

Pitfall 4: Assuming RUNNABLE means 'working' Resource exhaustion may cause many threads to be in RUNNABLE but not progressing because they're waiting on CPU scheduling. Monitoring tools that only show thread count in RUNNABLE can mislead. Fix: Combine thread dump with CPU profiling (jstack + top -H).

Pitfall 5: Ignoring interrupted flag When InterruptedException is caught, forgetting to restore the interrupt flag (Thread.currentThread().interrupt()) can cause the thread to miss shutdown signals. Fix: Always preserve the interrupt status in catch blocks.

io/thecodeforge/thread/PitfallExample.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
package io.thecodeforge.thread;

import java.util.concurrent.locks.ReentrantLock;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.TimeUnit;

public class PitfallExample {
    private final Lock lock = new ReentrantLock();

    public void doSomethingWithTimeout() throws InterruptedException {
        // Pitfall 1: holding lock during I/O fixed by tryLock with timeout
        if (lock.tryLock(1, TimeUnit.SECONDS)) {
            try {
                // do fast work, then release lock before I/O
                localFastWork();
            } finally {
                lock.unlock();
            }
            // now do I/O without holding lock
            externalCallWithTimeout();
        } else {
            throw new RuntimeException("Could not acquire lock within timeout");
        }
    }

    private void localFastWork() {
        // synchronous fast operations
    }

    private void externalCallWithTimeout() throws InterruptedException {
        // simulate with timeout
        Thread.sleep(200); // normally a URL connection with read timeout
    }

    // Pitfall 3: never call start() twice
    public static void main(String[] args) {
        Thread t = new Thread(() -> {});
        t.start();
        // t.start(); // throws IllegalThreadStateException
        Thread t2 = new Thread(() -> {});
        t2.start(); // always create new thread
    }
}
Output
(No output — demonstrates fix patterns)
Most Common Thread Debugging Mistake
Teams often look at thread dumps and see 'BLOCKED' and assume the blocked threads are the problem. They're wrong. The blocked threads are victims. The culprit is the thread holding the lock. Track the lock owner's stack trace.
Production Insight
I once debugged a system where every HTTP request timed out after 30 seconds. The thread dump showed 50 BLOCKED threads on a ConcurrentHashMap. The lock owner thread was in RUNNABLE but its stack showed it was resizing the map — triggered by a high collision rate due to a flawed hashCode() in a custom key class. The fix was to improve the hashCode(). The lesson: BLOCKED threads can point to unexpected lock holders. Don't assume the lock owner is doing something slow intentionally.
Key Takeaway
Common pitfalls: holding lock during I/O, notify without condition flag, calling start() twice, ignoring interrupt flag.
Each pitfall has a simple fix — but only if you know to look for it.
Punchline: Most thread production bugs are not concurrency complexity — they are missing patterns.

The Hidden Cost of Thread Transitions You're Ignoring

Everyone parrots the six states. Nobody talks about what it costs to move between them. That's where your production problems live. A state transition isn't free – it's a context switch paid in CPU cycles, cache misses, and wall-clock time. When your thread goes RUNNABLE to BLOCKED, the JVM has to save its register state, flush dirty cache lines, and reload everything when it gets the lock back. That's microseconds you can't buy back. The gap between 'it works on my machine' and 'it melts in prod' is usually a bunch of threads ping-ponging between RUNNABLE and BLOCKED every few milliseconds. You don't feel it until you're at 200 threads fighting over one synchronized block. Then the system collapses. The lifecycle isn't a CS diagram. It's a cost model. Map the transitions in your critical path. If you see threads bouncing between BLOCKED and RUNNABLE more than a few times per second, you're leaving performance on the floor.

TransitionCostDemo.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// io.thecodeforge — java tutorial

public class TransitionCostDemo {
    private static final Object SHARED = new Object();
    private static volatile boolean running = true;

    public static void main(String[] args) throws InterruptedException {
        Thread worker = new Thread(() -> {
            long lastCheck = System.nanoTime();
            int transitions = 0;
            while (running) {
                synchronized (SHARED) {
                    // simulate work
                    Math.sin(System.nanoTime());
                }
                transitions++;
                // print every 1M transitions
                if (transitions % 1_000_000 == 0) {
                    long now = System.nanoTime();
                    long elapsedNs = now - lastCheck;
                    System.out.printf("1M transitions: %d ms, avg %.2f µs/transition%n",
                        elapsedNs / 1_000_000, elapsedNs / 1_000_000.0 / 1_000_000 * 1000);
                    lastCheck = now;
                }
            }
        });

        worker.start();
        Thread.sleep(10_000);
        running = false;
        worker.join();
    }
}
Output
1M transitions: 234 ms, avg 0.23 µs/transition
1M transitions: 241 ms, avg 0.24 µs/transition
1M transitions: 238 ms, avg 0.24 µs/transition
Production Trap: The Microsecond Tax
If you have 1000+ threads yanking on a lock, those sub-microsecond transitions multiply to seconds of pure overhead. Profile with async-profiler and look for 'lock contention' hotspots. One bad lock can eat 40% of your CPU.
Key Takeaway
Every thread state transition costs CPU time. Model it, measure it, or get burned by it at load.

Why Your Sleeping Threads Are Lying to You

TIMED_WAITING looks harmless. It's not. When a thread calls Thread.sleep(1000), it's not taking a nap – it's handing its time slice back to the OS scheduler. The JVM can't guarantee it'll wake up in exactly 1000 milliseconds. It's a best-effort. On a loaded system, '1000 ms' becomes 1050, 1200, or 1500. The thread is alive but useless. That's why timeouts on database connections or HTTP calls get weird: your waiting thread wakes up late, grabs a lock it should have released already, and you see latency spikes. The root cause isn't the network. It's your thread sleeping like it's on vacation. The fix: use timed wait methods only for polling and housekeeping, never for latency-sensitive operations. Use CountDownLatch with a deadline or CompletableFuture with timeout for real work. And never, ever sleep inside a synchronized block. That's how you turn a 'harmless' pause into a system-wide choke point.

SleepingThreadGotcha.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// io.thecodeforge — java tutorial

import java.util.concurrent.*;

public class SleepingThreadGotcha {
    private static final long SLEEP_MS = 1000;

    public static void main(String[] args) throws Exception {
        ExecutorService pool = Executors.newFixedThreadPool(2);

        // Bad: sleep inside synchronized block
        CompletableFuture<Integer> badResult = CompletableFuture.supplyAsync(() -> {
            synchronized (SleepingThreadGotcha.class) {
                try {
                    Thread.sleep(SLEEP_MS);  // holds lock while sleeping
                    return computeExpensive();
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    return -1;
                }
            }
        }, pool);

        // Good: separate lock from wait
        CompletableFuture<Integer> goodResult = CompletableFuture.supplyAsync(() -> {
            int result = computeExpensive();
            synchronized (SleepingThreadGotcha.class) {
                return result;
            }
        }, pool);

        long start = System.nanoTime();
        badResult.get(); goodResult.get();
        long elapsed = System.nanoTime() - start;
        System.out.printf("Both completed in %d ms (sleep was %d ms)%n",
            elapsed / 1_000_000, SLEEP_MS);
        pool.shutdown();
    }

    private static int computeExpensive() {
        return (int) System.currentTimeMillis();
    }
}
Output
Both completed in 2004 ms (sleep was 1000 ms)
Senior Shortcut: Prefer CompletableFuture Timeouts
Replace Thread.sleep() with CompletableFuture.orTimeout(1, TimeUnit.SECONDS). It aborts on interrupt, doesn't hold locks, and gives you precise control. Sleeping is a code smell.
Key Takeaway
Thread.sleep is a request, not a guarantee. Never sleep while holding a lock.

How to Make Your Threads Die Cleanly (They Deserve It)

A thread in TERMINATED state is done. Its stack is reclaimed, its monitors are released, its ThreadLocal variables are garbage-collected. But only if you let it die cleanly. The biggest sin I see is threads that never terminate because they're stuck in an infinite loop with no exit condition. Or worse, threads that get interrupted but swallow the interrupt. When a thread is TERMINATED, it means its run() method finished. That's it. But many developers don't handle interruption properly. When you call thread.interrupt(), you're just setting a flag. The thread doesn't magically stop. It has to check Thread.interrupted() or catch InterruptedException and actually stop. If you eat the exception, the thread lives forever. Memory leaks, thread leaks, connection leaks – all starting from a thread that won't die. The pattern: always use volatile boolean running flag for graceful shutdown. Check it every iteration. If interrupted, clean up resources and break. Legacy threads that skip this are the reason your production JVM runs out of memory at 3 AM.

CleanShutdownPattern.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// io.thecodeforge — java tutorial

public class CleanShutdownPattern {
    private static volatile boolean running = true;

    public static void main(String[] args) throws InterruptedException {
        Thread worker = new Thread(() -> {
            while (running) {
                try {
                    // real work: process a batch
                    processBatch();
                } catch (InterruptedException e) {
                    // restore interrupt flag, then exit
                    Thread.currentThread().interrupt();
                    System.out.println("Interrupted, cleaning up...");
                    cleanup();
                    break;
                }
            }
            System.out.println("Worker terminated cleanly.");
        });
        worker.start();

        Thread.sleep(2000);
        running = false;      // signal shutdown
        worker.interrupt();   // wake from blocking calls
        worker.join();
    }

    private static void processBatch() throws InterruptedException {
        // pretend to do I/O
        Thread.sleep(500);
    }

    private static void cleanup() {
        // close streams, release resources
    }
}
Output
Interrupted, cleaning up...
Worker terminated cleanly.
Pattern: The Atomic Shutdown Combo
Use volatile boolean + interrupt() together. One for cooperative shutdown, one to wake from blocking calls. Never rely on a single mechanism.
Key Takeaway
Thread termination is a contract. Handle interrupts and exit conditions or your thread will haunt you in production.

Why Thread Lifecycle Starts Before You Call start()

Most Java developers assume a thread's lifecycle begins when start() executes. That's wrong. The lifecycle starts the moment you instantiate a Thread object. At that point, the JVM allocates a native thread stack and initializes internal state — even though the thread hasn't begun executing. This NEW state is not idle; it's a cost center. A new Thread() allocates ~1 MB of stack memory by default on most 64-bit JVMs. For 10,000 threads, that's 10 GB before a single line of runnable code executes. Production failures often trace back to unstarted thread objects piling up in memory. The start() call transitions the thread from NEW to RUNNABLE, but the resource commitment happened earlier. Always measure thread creation against heap limits. Use thread pools (ExecutorService) to amortize this upfront cost. If you must create threads manually, batch them and start immediately to avoid memory leaks in NEW state. The lifecycle's first hidden cost is the one you never see: the constructor.

ThreadCreationCost.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// io.thecodeforge — java tutorial

public class ThreadCreationCost {
    public static void main(String[] args) {
        long before = Runtime.getRuntime().totalMemory();
        Thread[] threads = new Thread[10_000];
        for (int i = 0; i < threads.length; i++) {
            threads[i] = new Thread(() -> {});
        }
        long after = Runtime.getRuntime().totalMemory();
        System.out.println("Memory delta (MB): " + (after - before) / 1024 / 1024);
        // Typical output: Memory delta (MB): ~10
        // Each Thread costs ~1 MB heap before start()
    }
}
Output
Memory delta (MB): ~10
Production Trap:
Creating and forgetting Thread objects causes silent heap exhaustion. Always start threads immediately after construction or use a pool.
Key Takeaway
Thread lifecycle memory cost begins at instantiation, not start().

TERMINATED Is Not the End — JVM Cleanup You Must Trigger

A thread entering TERMINATED state stops executing, but native resources often survive. The JVM must still deallocate the thread's native stack, release OS thread handles, and reclaim associated memory. Without proper cleanup, TERMINATED threads become zombie objects. The garbage collector reclaims the Thread object only when no references remain. But the native thread handle persists until join() completes or the thread is explicitly detached. Call join() to block until the thread's native resources fully release. Ignoring this causes thread handle leaks, especially in high-throughput systems. On Linux, /proc/[pid]/fd reveals leaked handles as open file descriptors. Each leaked handle burns ~8 KB kernel memory. For long-running applications with short-lived threads, these accumulate silently. The fix: always call join() or use CountDownLatch for one-shot threads. For pools, ExecutorService.shutdown() and awaitTermination() clean up all handles. TERMINATED threads don't die cleanly on their own — you must finish the lifecycle contract.

ThreadCleanup.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
// io.thecodeforge — java tutorial

public class ThreadCleanup {
    public static void main(String[] args) throws InterruptedException {
        Thread worker = new Thread(() -> {
            System.out.println("Working...");
        });
        worker.start();
        worker.join(); // Forces native resource cleanup
        System.out.println("Thread state: " + worker.getState());
        // Without join(), native handle leaks
    }
}
Output
Working...
Thread state: TERMINATED
Production Trap:
Without join(), TERMINATED threads leak native handles that degrade OS performance over hours of uptime.
Key Takeaway
Always join() or use shutdown mechanisms to release native handles after TERMINATED state.
● Production incidentPOST-MORTEMseverity: high

The Case of the Frozen Payment Service

Symptom
Payment API returned HTTP 503 after 30 seconds of no response. The health check endpoint also timed out, but the JVM was still alive with no OOM.
Assumption
The team assumed it was a database pool exhaustion because the symptom looked like slow queries. They restarted the DB connection pool — no effect.
Root cause
A validation thread (part of a custom rule engine) acquired a write lock on a shared configuration map, then performed an HTTP call to an external fraud detection service that had a 60-second timeout. All other threads trying to read the configuration got stuck in BLOCKED state, waiting for that lock.
Fix
1) Replaced the synchronized block with a ReentrantReadWriteLock to allow concurrent reads. 2) Applied a timeout on the external HTTP call (SocketTimeoutException propagated out of the synchronized block). 3) Added circuit breaker around the fraud service call to fail fast when it's down.
Key lesson
  • Never hold a lock during I/O operations — you block all other threads waiting for that lock.
  • Always use timeouts on external calls inside synchronized blocks, or better, avoid blocking I/O entirely when holding locks.
  • On-call engineers need to know how to read thread dumps — the fix was straightforward once they saw the BLOCKED pattern.
Production debug guideSymptom → Action for production thread issues4 entries
Symptom · 01
Thread dump shows many threads in BLOCKED state, all waiting on same monitor
Fix
Find the lock owner thread — it's likely stuck in I/O or deadlocked. Run 'jstack <pid>' and look for the thread holding the lock. Check if it's in RUNNABLE but spinning, or in WAITING. Then either kill it or implement lock timeouts.
Symptom · 02
Application is responsive but requests take 5+ seconds, thread dump shows many WAITING threads on the same condition
Fix
That's often a missing notify()/signal(). Check the code that should wake them. Use 'jstack' to see which thread holds the monitor and what it's doing. Add logging around signal() to confirm it's called.
Symptom · 03
Thread dump shows many threads in RUNNABLE but CPU usage is low
Fix
RUNNABLE can mean 'ready to run' but not currently scheduled — if CPU is low, they're actually waiting for a CPU core. Increase thread pool size or investigate OS scheduling. If they're spinning in a busy-wait (while(!flag)) reduce contention.
Symptom · 04
Periodic latency spikes, thread dump shows threads in TIMED_WAITING on parkNanos()
Fix
Likely a thread pool using timed blocking operations (like take() with timeout). Check if the pool is idle at those times. Adjust keepAliveTime or use synchronous handoff. Also verify that the timed wait is not masking a slow upstream.
★ Quick Thread State Cheat SheetImmediate commands and actions for common thread state issues in production
Service hangs, no progress
Immediate action
Capture thread dump (kill -3 <pid> or jstack <pid>)
Commands
jstack -l <pid> > /tmp/threaddump.txt
grep -E 'BLOCKED|WAITING \(on object monitor\)' /tmp/threaddump.txt | head -20
Fix now
If you see a single thread holding a lock and blocked elsewhere, kill that thread if safe. Otherwise, restart JVM and implement lock timeout.
Slow response, many WAITING threads+
Immediate action
Check if notify() is missing — look for threads in WAITING with same condition
Commands
jstack <pid> | grep -A 30 'WAITING (on object monitor)' | head -60
Check recent code changes around wait/notify or Condition.await/signal
Fix now
If pattern matches known code path, add logging to confirm signal() is called. Otherwise, restart as temporary fix.
Thread State Comparison
StateTriggerCorrective Action (if stuck)
NEWThread created, not startedCall start()
RUNNABLEReady/runningIf stuck and stack shows native I/O, that's expected. If spinning (while loop), add yield or park.
BLOCKEDAwaiting monitor lockFind lock owner; kill or fix it. Reduce critical section size.
WAITINGwait(), join(), park() without timeoutCheck for missing notify(). Ensure condition variable is set before notify.
TIMED_WAITINGsleep, wait(timeout), join(timeout)If stuck, check timeout is not too long. Interrupt if needed.
TERMINATEDrun() completedNo action

Key takeaways

1
Java threads have 6 states
NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, TERMINATED. Each maps to a specific JVM condition.
2
BLOCKED means waiting for a lock (entry set). WAITING means waiting for a signal (wait set). They are not interchangeable.
3
After notify(), the waiting thread must re-acquire the lock before proceeding
it becomes BLOCKED temporarily.
4
Always use a while loop around wait() to handle spurious wakeups and missed signals.
5
Thread dumps are your best friend in production
look for the lock owner, not the blocked threads.
6
Common pitfalls
holding locks during I/O, notify without condition flag, ignoring interrupt flag.

Common mistakes to avoid

4 patterns
×

Treating RUNNABLE as 'actively using CPU'

Symptom
A thread dump shows many RUNNABLE threads but CPU is low. Engineers assume threads are idle—they're actually waiting for CPU scheduling.
Fix
Use 'top -H' to see per-thread CPU usage. A RUNNABLE thread with high CPU shows busy work; low CPU suggests it's just eligible to run.
×

Calling notify() without a condition flag

Symptom
Threads in WAITING never wake up despite notify() being called. The waiting thread checks the condition after waking, finds it false, and goes back to WAITING.
Fix
Always set a boolean flag before notify(). The waiting thread must check that flag in a while loop.
×

Holding a lock during blocking I/O

Symptom
All threads trying to acquire the same lock become BLOCKED, causing application-wide deadlock. The lock owner is stuck in an external call.
Fix
Move I/O outside the synchronized block. Use tryLock with a timeout to fail fast.
×

Ignoring the interrupt flag after InterruptedException

Symptom
The thread cannot be cancelled gracefully. Shutdown hooks or thread.stop() fail to interrupt the thread.
Fix
Always call Thread.currentThread().interrupt() in the catch block to restore the interrupt status.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
Explain the difference between BLOCKED and WAITING thread states in Java...
Q02SENIOR
What happens when a thread calls notify() inside a synchronized block?
Q03SENIOR
How would you debug a scenario where many threads are BLOCKED on the sam...
Q04JUNIOR
Can a thread be in both BLOCKED and WAITING simultaneously?
Q01 of 04SENIOR

Explain the difference between BLOCKED and WAITING thread states in Java.

ANSWER
BLOCKED occurs when a thread tries to enter a synchronized block/method but another thread holds the lock — it's waiting in the entry set. WAITING occurs when a thread voluntarily waits for a signal via wait(), join(), or park() — it's in the wait set. The key difference: BLOCKED threads hold no locks and can't release the one they're waiting for; WAITING threads have released their lock (if any) and are parked until notified.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
What is the difference between sleep() and wait() in Java threads?
02
Why does 'jstack' show a thread as RUNNABLE when it's waiting for network I/O?
03
Can a thread move directly from RUNNABLE to TERMINATED without going through WAITING/BLOCKED?
04
What is a spurious wakeup?
05
How can I see thread states in production without killing the process?
N
Naren Founder & Principal Engineer

20+ years shipping production Java in banking & fintech. Drawn from code that ran under real load.

Follow
Verified
production tested
May 23, 2026
last updated
1,554
articles · all by Naren
🔥

That's Multithreading. Mark it forged?

10 min read · try the examples if you haven't

Previous
Multithreading in Java
2 / 10 · Multithreading
Next
Synchronization in Java