☕ Java

Thread Lifecycle

The Java thread lifecycle is the complete sequence of states a thread passes through from the moment a Thread object is constructed to the moment its execution ends. Java defines six states in the Thread.State enum — NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, and TERMINATED — and the JVM transitions threads between these states in response to specific method calls, lock acquisitions, monitor notifications, timeouts, and exceptions. Each state has a precise meaning, a defined set of entry conditions, and a defined set of exit conditions. Understanding the lifecycle in full is prerequisite knowledge for diagnosing deadlocks, thread leaks, performance bottlenecks in thread dumps, and incorrect synchronization — all of which manifest as threads stuck in specific states. This entry covers every state in the lifecycle with its entry and exit conditions, all legal and illegal state transitions, how thread dumps represent each state, the interaction between lifecycle states and interruption, the effect of uncaught exceptions on lifecycle, and how to observe lifecycle transitions programmatically.

NEW and RUNNABLE — Birth of a Thread

A Thread object enters the NEW state the instant its constructor completes. At this point, a Java object exists on the heap with all its fields initialized — name, priority, daemon flag, target Runnable — but no operating system thread has been allocated, no stack has been reserved, and no scheduling has occurred. The thread object is an inert Java object. It can be queried (getName(), getPriority(), isDaemon()), have properties changed (setName(), setUncaughtExceptionHandler()), and be stored in data structures. Calling run() directly on a NEW thread executes the run method synchronously on the calling thread with no concurrency — a common mistake that produces apparently working but entirely single-threaded code. Calling start() on a NEW thread triggers the transition to RUNNABLE and is the only legal way to initiate concurrent execution. start() does several things in sequence: it validates that the thread has not already been started (throwing IllegalThreadStateException if Thread.State is not NEW), it registers the thread with the JVM's thread subsystem, it asks the OS to create a native thread and allocate a stack, and it makes the thread eligible for CPU scheduling. The return of start() does not mean the thread has begun executing run() — it means the thread is now schedulable. The OS may not schedule it for some time. RUNNABLE is the broadest state in the lifecycle and covers two distinct OS-level sub-states that the JVM deliberately conflates. A thread is RUNNABLE if it is currently executing on a CPU core, or if it is ready to execute and waiting for the OS scheduler to assign it a core, or if it is blocked on an OS-level I/O call such as a socket read or file write inside the kernel. The JVM has no visibility into which of these sub-states a RUNNABLE thread is actually in, because that information is owned by the OS scheduler. This means a thread consuming 100% of a CPU core and a thread blocked waiting for a disk read both appear as RUNNABLE in getState() and in thread dumps. Distinguishing them requires OS-level tools (perf, async-profiler, jstack with CPU sampling) not pure JVM inspection. Yielding — Thread.yield() — is a hint from the currently executing thread to the OS scheduler that it is willing to give up its remaining time slice. The scheduler may honor the hint (by moving the thread to the back of the run queue for its priority level) or ignore it entirely. yield() never changes the thread's state from RUNNABLE; it produces no state transition observable through getState(). Its use in production code is almost never correct and is primarily a tool for microbenchmark warm-up and specific lock-free algorithm tuning.
Java
// ── NEW state: Thread constructed, not yet started ───────────────────
Thread t = new Thread(() -> System.out.println("running"), "demo-thread");
System.out.println(t.getState());   // NEW
System.out.println(t.getName());    // demo-thread  — properties readable in NEW
System.out.println(t.getPriority()); // 5

// Properties can be modified while in NEW:
t.setName("renamed-thread");
t.setPriority(Thread.MAX_PRIORITY);
t.setDaemon(true);

// WRONG: calling run() directly — no new thread, runs on calling thread
t.run();   // executes synchronously, thread stays NEW after this returns
System.out.println(t.getState());   // still NEW — run() doesn't change state

// ── start() — transitions NEW → RUNNABLE ─────────────────────────────
Thread t2 = new Thread(() -> {
    System.out.println("Executing on: " + Thread.currentThread().getName());
    System.out.println("State from inside: " + Thread.currentThread().getState()); // RUNNABLE
}, "started-thread");

System.out.println("Before start: " + t2.getState());  // NEW
t2.start();
// After start() returns, thread is RUNNABLE — may or may not be executing yet:
System.out.println("After start:  " + t2.getState());  // RUNNABLE or TERMINATED (very fast tasks)
t2.join();
System.out.println("After join:   " + t2.getState());  // TERMINATED

// ── IllegalThreadStateException — restarting a thread ────────────────
Thread once = new Thread(() -> {}, "one-shot");
once.start();
once.join();
try {
    once.start();   // COMPILE: fine. RUNTIME: IllegalThreadStateException
} catch (IllegalThreadStateException e) {
    System.out.println("Cannot restart: " + e.getClass().getSimpleName());
}

// ── Observing RUNNABLE sub-states from outside the JVM ───────────────
// All of these appear as RUNNABLE to getState():
// 1. Actually running on CPU:
Thread cpuBound = new Thread(() -> {
    long x = 0;
    while (!Thread.currentThread().isInterrupted()) x++;  // 100% CPU — RUNNABLE
}, "cpu-spinner");

// 2. Blocked on OS-level I/O (inside kernel — JVM doesn't know):
Thread ioWaiter = new Thread(() -> {
    try {
        new java.net.Socket("example.com", 80);  // kernel I/O — RUNNABLE from JVM view
    } catch (Exception e) {}
}, "io-waiter");

// 3. Ready but not yet scheduled:
Thread ready = new Thread(() -> {}, "not-yet-scheduled");
ready.start();   // may be in ready queue — RUNNABLE but no CPU assigned

// Thread dump shows all three as RUNNABLE with no distinction.
// OS-level tools (async-profiler, perf) can differentiate.

// ── Thread.yield() — a hint, not a guarantee ─────────────────────────
Thread yielder = new Thread(() -> {
    for (int i = 0; i < 5; i++) {
        System.out.println("Iteration " + i);
        Thread.yield();   // hints to scheduler: others may run now
        // State does NOT change — still RUNNABLE throughout
    }
}, "yielding-thread");
yielder.start();
yielder.join();

BLOCKED, WAITING, and TIMED_WAITING — Suspension States

BLOCKED, WAITING, and TIMED_WAITING are the three states in which a thread has given up its CPU time and is suspended, waiting for something to happen before it can continue. They are frequently confused because they look similar — the thread is not executing — but they have different causes, different exit conditions, and different implications for system health when observed in thread dumps. BLOCKED has exactly one cause: a thread is trying to enter a synchronized block or method and another thread currently holds the monitor. The thread calls synchronized(obj) or enters a synchronized method, discovers the monitor is held, and transitions to BLOCKED. It waits in the OS's monitor entry set for that specific object. When the holding thread exits the synchronized region and releases the monitor, the JVM selects one waiting thread from the entry set (selection is implementation-defined and not guaranteed to be FIFO) and transitions it to RUNNABLE to compete for the monitor. BLOCKED is caused by lock contention and nothing else; no other operation produces the BLOCKED state. WAITING is caused by three operations: Object.wait() called while holding the object's monitor, Thread.join() with no timeout called on another thread, and LockSupport.park() with no timeout. All three release any monitors held (for wait()) or hold no monitor (for join() and park()) and suspend the thread indefinitely. A WAITING thread will not transition to RUNNABLE on its own — it requires an explicit wake-up: Object.notify() or Object.notifyAll() for wait(), the target thread terminating for join(), or LockSupport.unpark() for park(). Interrupting a WAITING thread causes it to throw InterruptedException (for wait() and join()) or return from park() with the interrupt flag set. TIMED_WAITING is structurally identical to WAITING except that the thread supplies a timeout and will transition back to RUNNABLE automatically when the timeout expires, regardless of whether the awaited event occurred. The operations that produce TIMED_WAITING: Thread.sleep(millis), Object.wait(millis), Thread.join(millis), LockSupport.parkNanos(nanos), LockSupport.parkUntil(deadline). After the timeout, the thread transitions to RUNNABLE and can compete for CPU and locks again. TIMED_WAITING threads respond to interruption the same way WAITING threads do. The distinction between BLOCKED and WAITING matters enormously in production diagnosis. A thread dump with many threads in BLOCKED state indicates lock contention — threads piling up waiting for a single synchronized region. This points to a bottleneck in locking strategy: the synchronized region may be too coarse, the held work may be too slow, or the lock should be replaced with a concurrent data structure. A thread dump with many threads in WAITING points to a producer-consumer breakdown or thread pool starvation — threads parked waiting for work that isn't arriving. TIMED_WAITING threads in large numbers are usually healthy (thread pools waiting for tasks with a keepAlive timeout) unless the timeouts are shorter than expected, indicating excessive wake-up overhead.
Java
// ── BLOCKED: waiting to acquire a monitor ────────────────────────────
Object sharedLock = new Object();
Thread[] blockedThreads = new Thread[3];

// Holder acquires and keeps the lock:
Thread holder = new Thread(() -> {
    synchronized (sharedLock) {
        System.out.println("Holder acquired lock");
        try { Thread.sleep(3000); } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}, "lock-holder");

// Three threads try to acquire the same lock simultaneously:
for (int i = 0; i < 3; i++) {
    int idx = i;
    blockedThreads[i] = new Thread(() -> {
        synchronized (sharedLock) {    // will BLOCK until holder releases
            System.out.println(Thread.currentThread().getName() + " acquired lock");
        }
    }, "blocked-thread-" + i);
}

holder.start();
Thread.sleep(100);  // ensure holder has the lock
for (Thread bt : blockedThreads) bt.start();
Thread.sleep(100);  // ensure blocked threads have tried to acquire

for (Thread bt : blockedThreads) {
    System.out.println(bt.getName() + ": " + bt.getState());  // BLOCKED
}
holder.interrupt();
for (Thread bt : blockedThreads) bt.join();

// ── WAITING: Object.wait(), join(), LockSupport.park() ───────────────
Object monitor = new Object();

Thread waiter = new Thread(() -> {
    synchronized (monitor) {
        try {
            System.out.println("About to wait...");
            monitor.wait();    // releases monitor, enters WAITING
            System.out.println("Woken up!");
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}, "waiting-thread");

waiter.start();
Thread.sleep(100);
System.out.println(waiter.getState());  // WAITING

synchronized (monitor) {
    monitor.notify();   // transitions waiter from WAITING to RUNNABLE
}
waiter.join();

// WAITING via Thread.join():
Thread longTask = new Thread(() -> {
    try { Thread.sleep(2000); } catch (InterruptedException e) {}
}, "long-task");

Thread joiner = new Thread(() -> {
    try {
        System.out.println("Joining long-task...");
        longTask.join();    // enters WAITING until longTask terminates
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }
}, "joiner-thread");

longTask.start();
joiner.start();
Thread.sleep(100);
System.out.println(joiner.getState());  // WAITING
longTask.interrupt();
joiner.join();

// ── TIMED_WAITING: sleep(), wait(t), join(t), parkNanos() ─────────────
Thread sleeper = new Thread(() -> {
    try { Thread.sleep(5000); } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }
}, "sleeper");

Thread timedWaiter = new Thread(() -> {
    synchronized (monitor) {
        try { monitor.wait(5000); } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}, "timed-waiter");

sleeper.start();
timedWaiter.start();
Thread.sleep(100);

System.out.println(sleeper.getState());      // TIMED_WAITING
System.out.println(timedWaiter.getState());  // TIMED_WAITING

sleeper.interrupt();
timedWaiter.interrupt();
sleeper.join();
timedWaiter.join();

// ── BLOCKED vs WAITING in a thread dump ──────────────────────────────
// BLOCKED appears as:
// "blocked-thread-0" #14 prio=5 os_prio=0
//    java.lang.Thread.State: BLOCKED (on object monitor)
//    - waiting to lock <0x000000076b3a2e90> (java.lang.Object)
//    - locked by "lock-holder" #11

// WAITING appears as:
// "waiting-thread" #15 prio=5 os_prio=0
//    java.lang.Thread.State: WAITING (on object monitor)
//    - waiting on <0x000000076b3a3120> (java.lang.Object)
//    - locked by "waiting-thread" #15  ← owns the monitor, then called wait()

// TIMED_WAITING appears as:
// "sleeper" #16 prio=5 os_prio=0
//    java.lang.Thread.State: TIMED_WAITING (sleeping)
//    at java.lang.Thread.sleep(Native Method)

TERMINATED — End of Life, Uncaught Exceptions, and Observing the Full Lifecycle

A thread enters the TERMINATED state when its run() method returns, whether normally or by throwing an uncaught exception. TERMINATED is a final state — no transitions out of it exist, and no operation on the Thread object can restart or reuse it. The Thread object itself persists on the heap as long as some reference to it exists, and its getState() will continue to return TERMINATED, but the OS-level thread and its stack have been deallocated. When a thread's run() method throws an exception that propagates out of run() without being caught, the JVM invokes the thread's uncaught exception handler before transitioning to TERMINATED. The handler receives the Thread object and the Throwable. If no thread-specific handler is set, the JVM checks the thread's ThreadGroup, and if that provides no handler, the JVM falls back to the default uncaught exception handler set via Thread.setDefaultUncaughtExceptionHandler(). If none of these handlers are set, the JVM prints the stack trace to System.err. After the handler returns, the thread transitions to TERMINATED regardless. Error subclasses — including OutOfMemoryError, StackOverflowError, and VirtualMachineError — follow the same path: they propagate out of run(), invoke the uncaught exception handler, and cause the thread to terminate. OutOfMemoryError and StackOverflowError caught inside run() (or inside the uncaught exception handler itself) may leave the JVM in an unstable state; this is one reason why catching Error is almost never correct. Thread.join() and the TERMINATED state have a precise relationship: join() returns as soon as the target thread reaches TERMINATED. The happens-before relationship that join() establishes means all writes performed by the terminated thread before it reached TERMINATED are visible to the joining thread immediately after join() returns. This is the correct mechanism for collecting results from a worker thread — wait for TERMINATED via join(), then read the shared state. Programmatically observing the full lifecycle — watching a thread move through all states — requires polling getState() from another thread. Thread state transitions happen asynchronously; there is no callback or event for state changes in the standard library. Observing every state reliably requires inserting coordination points (sleeps, latches, or synchronized handshakes) at the right moments. In production, lifecycle observation happens primarily through thread dumps (jstack, jcmd Thread.print) and JVM diagnostic tooling (JFR, async-profiler, VisualVM) rather than programmatic polling.
Java
// ── TERMINATED: normal completion ────────────────────────────────────
Thread finisher = new Thread(() -> {
    System.out.println("About to finish");
}, "finisher");

finisher.start();
finisher.join();
System.out.println(finisher.getState());  // TERMINATED
System.out.println(finisher.isAlive());   // false

// TERMINATED is final — cannot restart:
try {
    finisher.start();
} catch (IllegalThreadStateException e) {
    System.out.println("Cannot restart TERMINATED thread");
}

// ── TERMINATED via uncaught exception ────────────────────────────────
Thread thrower = new Thread(() -> {
    System.out.println("About to throw");
    throw new RuntimeException("Intentional failure");
}, "thrower");

thrower.setUncaughtExceptionHandler((thread, ex) -> {
    System.err.printf("[UncaughtHandler] Thread '%s' terminated with: %s%n",
        thread.getName(), ex.getMessage());
    // thread is still alive during handler execution:
    System.err.println("State during handler: " + thread.getState());  // RUNNABLE (transitioning)
});

thrower.start();
thrower.join();
System.out.println("After join: " + thrower.getState());  // TERMINATED

// ── join() and the TERMINATED happens-before guarantee ───────────────
int[] result = new int[1];

Thread calculator = new Thread(() -> {
    int sum = 0;
    for (int i = 1; i <= 1_000_000; i++) sum += i;
    result[0] = sum;   // write before TERMINATED
}, "calculator");

calculator.start();
calculator.join();    // wait for TERMINATED — happens-before established
System.out.println("Sum: " + result[0]);  // safe: join() guarantees visibility

// ── Observing the full lifecycle programmatically ─────────────────────
import java.util.concurrent.CountDownLatch;

Object lifecycleLock  = new Object();
CountDownLatch waitingLatch = new CountDownLatch(1);

Thread subject = new Thread(() -> {
    // Signal that we're inside run() (RUNNABLE):
    synchronized (lifecycleLock) {
        lifecycleLock.notifyAll();
    }
    // Enter WAITING:
    try {
        waitingLatch.await();   // WAITING via LockSupport.park() inside CountDownLatch
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }
    // Return to RUNNABLE, then TERMINATED
}, "lifecycle-subject");

System.out.println("1. State before start: " + subject.getState());  // NEW

subject.start();
synchronized (lifecycleLock) {
    lifecycleLock.wait();  // wait until subject is inside run()
}
System.out.println("2. State after start, inside run(): " + subject.getState());  // RUNNABLE

Thread.sleep(50);  // give subject time to reach waitingLatch.await()
System.out.println("3. State while waiting on latch: " + subject.getState());    // WAITING

waitingLatch.countDown();  // release subject
subject.join();
System.out.println("4. State after join: " + subject.getState());  // TERMINATED

// ── Error types and TERMINATED ────────────────────────────────────────
Thread soeThrower = new Thread(() -> {
    // StackOverflowError propagates out of run() → TERMINATED
    soeThrower_helper();   // will recurse until stack exhausted
}, "soe-thread");

// Uncaught handler fires even for Error:
soeThrower.setUncaughtExceptionHandler((t, ex) ->
    System.err.println("Thread terminated with Error: " + ex.getClass().getSimpleName())
);
soeThrower.start();
soeThrower.join();
System.out.println("SOE thread: " + soeThrower.getState());  // TERMINATED

static void soeThrower_helper() { soeThrower_helper(); }  // infinite recursion

// ── What a complete thread dump looks like for each state ─────────────
// Full jstack output for a thread in each state:

// NEW (rarely appears in dumps — usually starts fast):
// (not shown — threads in NEW are not yet OS-level threads)

// RUNNABLE:
// "cpu-worker" #12 prio=5 os_prio=0 cpu=1423.45ms elapsed=2.10s
//    java.lang.Thread.State: RUNNABLE
//    at com.example.Worker.compute(Worker.java:42)
//    at com.example.Worker.run(Worker.java:28)

// BLOCKED:
// "db-writer-2" #18 prio=5 os_prio=0 cpu=0.12ms elapsed=1.85s
//    java.lang.Thread.State: BLOCKED (on object monitor)
//    - waiting to lock <0x00000007163f2d10> (com.example.ConnectionPool)
//    - locked by "db-writer-1" #17

// WAITING:
// "event-consumer" #20 prio=5 os_prio=0 cpu=0.08ms elapsed=15.30s
//    java.lang.Thread.State: WAITING (parking)
//    at sun.misc.Unsafe.park(Native Method)
//    at java.util.concurrent.locks.LockSupport.park(LockSupport.java:175)
//    at java.util.concurrent.LinkedBlockingQueue.take(LinkedBlockingQueue.java:442)

// TIMED_WAITING:
// "pool-1-thread-3" #22 prio=5 os_prio=0 cpu=0.05ms elapsed=300.10s
//    java.lang.Thread.State: TIMED_WAITING (parking)
//    at sun.misc.Unsafe.parkNanos(Native Method)
//    at java.util.concurrent.locks.LockSupport.parkNanos(LockSupport.java:215)

// TERMINATED:
// (not shown in thread dumps — terminated threads are not OS threads)

Related Topics in Multithreading

Process vs Thread
A process is an independent program in execution with its own isolated memory space, file handles, and system resources, managed by the operating system and separated from all other processes by strict boundaries. A thread is a unit of execution that lives inside a process, sharing that process's memory, heap, and resources with every other thread in the same process. Java programs run inside a JVM process; the JVM itself creates and manages threads, and every Java application starts with at least one thread — the main thread — with additional threads created by the JVM for garbage collection, JIT compilation, signal handling, and other runtime tasks. Understanding the distinction between processes and threads is the foundation for all concurrent programming in Java: it determines what is shared and what is isolated, what is fast and what is expensive, what fails independently and what fails together. This entry covers the OS-level and JVM-level model of processes and threads, the memory model that follows from the shared-versus-isolated distinction, the cost model for creation and context switching, failure isolation and its consequences, inter-process and inter-thread communication mechanisms, and the practical decision of when to use multiple processes versus multiple threads.
Thread Basics
A Java thread is an instance of java.lang.Thread that represents an independent path of execution within the JVM process. Every thread has a lifecycle — from creation through runnable, running, blocked, waiting, timed-waiting, and terminated states — and a set of properties including its name, priority, daemon status, thread group, and uncaught exception handler. The Java memory model specifies what visibility guarantees exist between threads and when writes by one thread are guaranteed to be visible to another. Thread scheduling is controlled by the OS scheduler subject to hints from the JVM via thread priority; the JVM does not provide real-time scheduling guarantees. This entry covers the complete thread lifecycle and its state machine, thread properties and how they affect scheduling and JVM shutdown, the happens-before relationship and why it matters for visibility, daemon threads and their relationship to JVM shutdown, thread interruption as a cooperative cancellation mechanism, and the methods on Thread that every Java developer must understand.
Creating Threads
Java provides three primary abstractions for defining the work a thread will execute: the Thread class itself (subclassed to override run()), the Runnable interface (a task with no return value and no checked exception), and the Callable interface (a task with a return value and a declared checked exception). Each represents a different contract between the task and the infrastructure that runs it. Thread subclassing couples the task definition to the execution mechanism and is the oldest and least flexible approach. Runnable decouples the task from the thread, allowing the same Runnable to be submitted to thread pools, scheduled executors, or wrapped in Thread objects. Callable extends that decoupling to include a return value and exception propagation, returning a Future that allows the caller to retrieve the result or handle exceptions asynchronously. Understanding all three — their contracts, their limitations, and when to use each — is the foundation of concurrent programming in Java before reaching for higher-level constructs.
Thread Priority
Thread priority in Java is an integer hint to the OS scheduler indicating the relative importance of a thread compared to others. Java defines a scale from Thread.MIN_PRIORITY (1) to Thread.MAX_PRIORITY (10) with Thread.NORM_PRIORITY (5) as the default, and every thread inherits the priority of the thread that created it. The critical word in this definition is hint: thread priority is advisory, not mandatory. The JVM maps Java priorities to native OS thread priorities, and the OS scheduler uses those priorities according to its own scheduling policy, which varies by operating system, scheduler configuration, and system load. On some platforms, priority has a measurable effect on scheduling frequency; on others, it is almost entirely ignored. Priority must never be used as a correctness mechanism — any program that requires a thread to run before another for correctness, rather than merely preferring it, is broken and will fail on any platform where priorities are not honored. This entry covers the priority scale, inheritance, platform mapping, the correctness prohibition, starvation, priority inversion, and the narrow set of cases where priority hints are legitimately useful.