You are currently viewing Thread Lifecycle in Java

Thread Lifecycle in Java

This entry is part 2 of 2 in the series Concurrency (Threads)

Introduction

Every Java thread follows a well-defined path from the moment it is created to the moment it finishes. Understanding that path — the states a thread moves through and the events that trigger each transition — is essential for writing correct concurrent programs and for diagnosing problems when things go wrong.

Java models this path through the Thread.State enum, which defines six distinct states. Knowing what each state means, and what causes a thread to enter or leave it, gives you a clear mental model for reasoning about any concurrent code you read or write.

This article covers each state in turn, shows how to observe them in practice, and closes with a set of best practices for working with thread state effectively. If you are new to creating threads, the article on how to create a thread in Java is a good starting point before reading this one.

1. The Thread Lifecycle at a Glance

A Java thread is never simply “running” or “not running.” Instead, at any given moment, a thread occupies exactly one of the six states defined by java.lang.Thread.State:

StateWhen a thread enters it
NEWThe Thread object has been created, but start() has not been called yet
RUNNABLEThe thread is executing, or is ready to execute and waiting for CPU time
BLOCKEDThe thread is waiting to acquire a monitor lock held by another thread
WAITINGThe thread is waiting indefinitely for another thread to perform a specific action
TIMED_WAITINGThe thread is waiting for a specified period of time
TERMINATEDThe thread has finished execution

You can read a thread’s current state at any time by calling thread.getState(), which returns one of these enum values.

An important point to remember about Java Concurrency:

The JVM has its own thread scheduler that maps Java threads onto OS threads and decides which thread to run and for how long.

This scheduling layer sits between your code and the CPU. Consequently, several of these states — particularly RUNNABLE — reflect the JVM’s view of a thread, not necessarily what the underlying OS is doing at any given instant. The following sections examine each state in detail.

2. The NEW State

A thread enters the NEW state the moment you create a Thread object, whether by instantiating it directly or through a subclass. At this point, the JVM has allocated the object, but no actual OS thread exists yet. The thread stays in NEW until you call start().

This is worth emphasizing: creating a Thread object is a cheap, purely in-memory operation. The real work — allocating OS resources and scheduling the thread for execution — only happens when you call start().

// NEW state: thread created but start() not yet called
Thread thread = new Thread(() -> {
    // Task body is irrelevant for this state observation
}, "new-state-demo");

// Before start() is called, the thread sits in the NEW state
System.out.println("State: " + thread.getState()); // NEW

One important consequence is that a thread in NEW — or indeed in any state — cannot be restarted once it has been started and terminated. Calling start() a second time on any thread that has already been started always throws IllegalThreadStateException. Therefore, if you need a task to run again, you must create a new Thread instance rather than reusing the old one.

3. The RUNNABLE State

Once you call start(), the thread transitions to RUNNABLE. This state covers two situations that the JVM treats identically: the thread is currently executing on a CPU core, or it is ready to execute but is waiting for the OS scheduler to assign it a core.

This distinction matters in practice. If you call thread.getState() immediately after thread.start() and observe RUNNABLE, you cannot determine from that fact alone whether the thread is actually executing at that instant or simply queued for a turn.

// RUNNABLE state: thread is executing or ready to execute
Thread thread = new Thread(() -> {
    // CPU-bound loop keeps the thread RUNNABLE long enough to observe
    long sum = 0;
    for (long i = 0; i < 100_000_000L; i++) {
        sum += i;
    }
    System.out.println("Sum: " + sum); // prevents JIT from eliminating the loop
}, "runnable-state-demo");

thread.start();
// Immediately after start(), the thread is eligible for CPU time
System.out.println("State after start: " + thread.getState()); // RUNNABLE
thread.join();

Additionally, a thread that is blocked on I/O — for example, waiting for a network response — is still reported as RUNNABLE by the JVM, even though it is not consuming any CPU time. This is a common source of confusion when profiling Java applications, so it is worth keeping in mind.

4. The BLOCKED State

A thread enters the BLOCKED state when it tries to acquire a monitor lock that another thread already holds. This happens when a thread reaches a synchronized block or method while another thread is executing inside that same synchronized region.

The key distinction between BLOCKED and the waiting states covered in the next section lies in the cause: BLOCKED always means the thread is competing for a lock. It is not idle by choice — it is actively trying to proceed, but a resource it needs is temporarily unavailable.

// BLOCKED state: one thread holds the lock; another tries to acquire it
Thread holder = new Thread(() -> {
    synchronized (LOCK) {
        try {
            Thread.sleep(500); // Holds the lock for 500 ms
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}, "lock-holder");

Thread waiter = new Thread(() -> {
    synchronized (LOCK) { // Will block until holder releases LOCK
        System.out.println("Waiter acquired lock");
    }
}, "lock-waiter");

holder.start();
Thread.sleep(50);  // Give holder enough time to acquire LOCK
waiter.start();
Thread.sleep(50);  // Give waiter enough time to reach the synchronized block

System.out.println("Waiter state: " + waiter.getState()); // BLOCKED

holder.join();
waiter.join();

In production systems, a large number of threads in BLOCKED is a reliable signal of lock contention. When you encounter this pattern in a thread dump, it typically means one thread is monopolizing a shared resource while others pile up behind it. Understanding this state is therefore the first step toward resolving contention — for example, by narrowing the synchronized region, switching to a ReadWriteLock, or restructuring shared state to reduce the need for locking altogether.

5. The WAITING and TIMED_WAITING States

Whereas BLOCKED is an involuntary wait for a lock, the WAITING and TIMED_WAITING states represent deliberate pauses. A thread enters these states by voluntarily suspending itself — typically to wait for a condition to become true or to yield CPU time for a known duration.

5.1 The WAITING State

A thread enters WAITING when it calls one of the following methods:

  • Object.wait() — called inside a synchronized block; releases the monitor lock and waits until another thread calls notify() or notifyAll() on the same object
  • Thread.join() — the calling thread waits indefinitely until the target thread terminates
  • LockSupport.park() — a lower-level primitive used internally by java.util.concurrent

The thread stays in WAITING until another thread explicitly wakes it. Without that signal, it waits forever.

// WAITING state: thread calls wait() and suspends indefinitely
Object monitor = new Object();

Thread waiting = new Thread(() -> {
    synchronized (monitor) {
        try {
            monitor.wait(); // Releases lock; waits indefinitely for notify()
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}, "waiting-state-demo");

waiting.start();
Thread.sleep(50); // Give the thread time to enter wait()

System.out.println("State: " + waiting.getState()); // WAITING

// Wake the waiting thread so it can finish cleanly
synchronized (monitor) {
    monitor.notify();
}
waiting.join();

5.2 The TIMED_WAITING State

TIMED_WAITING works similarly, but the thread specifies a maximum wait duration. If no wake signal arrives before the timeout expires, the thread wakes up on its own. The methods that produce this state include Thread.sleep(long millis), Object.wait(long millis), Thread.join(long millis), and LockSupport.parkNanos().

// TIMED_WAITING state: thread calls sleep() with a fixed timeout
Thread sleeping = new Thread(() -> {
    try {
        Thread.sleep(500); // Suspends for up to 500 ms
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }
}, "timed-waiting-demo");

sleeping.start();
Thread.sleep(50); // Give it time to enter sleep

System.out.println("State: " + sleeping.getState()); // TIMED_WAITING
sleeping.join();

Take-away:

The fact that a method can block is a core part of its contract, and should be documented.

This advice applies directly to both waiting states. Whenever you introduce a wait() or sleep() call, make sure the surrounding code makes clear what the thread is waiting for and what event will wake it — otherwise the intent becomes opaque to the next developer reading the code.

6. The TERMINATED State

A thread enters TERMINATED when its run() method returns normally, or when an uncaught exception causes it to exit abruptly. At this point, the thread has completed all its work, and the JVM releases any OS resources associated with it.

// TERMINATED state: thread has completed its run() method
Thread thread = new Thread(() -> {
    System.out.println("Thread work done"); // run() returns normally
}, "terminated-demo");

thread.start();
thread.join(); // Block until run() returns

System.out.println("State: " + thread.getState()); // TERMINATED

Once a thread reaches TERMINATED, it cannot be restarted. Furthermore, calling join() on a TERMINATED thread returns immediately, since there is nothing left to wait for. If you need the same task to run again, you must create a new Thread instance. This is one of the core reasons the ExecutorService framework is preferred in production: it reuses a fixed pool of worker threads across many tasks, avoiding the overhead of repeatedly creating and destroying threads.

7. Observing State Transitions in Practice

The previous sections examined each state individually. In practice, however, a single thread moves through several of these states during one run. The following snippet captures the most common transition sequence — NEWRUNNABLETIMED_WAITINGTERMINATED — in a single, observable example:

// Full lifecycle: NEW → RUNNABLE → TIMED_WAITING → TERMINATED
Thread thread = new Thread(() -> {
    try {
        Thread.sleep(100); // Produces TIMED_WAITING during execution
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }
}, "full-lifecycle-demo");

System.out.println("1. Before start: " + thread.getState()); // NEW
thread.start();
System.out.println("2. After start:  " + thread.getState()); // RUNNABLE or TIMED_WAITING
thread.join();
System.out.println("3. After join:   " + thread.getState()); // TERMINATED

Notice that the state at step 2 may be either RUNNABLE or TIMED_WAITING, depending on how quickly the OS scheduler runs the new thread before the main thread reaches the getState() call. This non-determinism is a fundamental property of concurrent systems. Consequently, you should never use state polling to coordinate threads — a lesson that leads directly into the best practices below.

8. Best Practices for Working with Thread State

Having examined all six states and their transitions, it is helpful to consolidate the guidelines that apply most directly to working with thread state in real code.

8.1 Never Poll getState() to Coordinate Threads

Checking a thread’s state in a loop — while (thread.getState() != State.TERMINATED) — is fragile, wastes CPU time, and introduces race conditions. By the time you read the state, it may already have changed. Use join() to wait for completion, or reach for higher-level synchronization primitives such as CountDownLatch or CompletableFuture.

8.2 Use join() to Detect Completion

thread.join() is the correct, blocking way to wait for a thread to reach TERMINATED. It suspends the calling thread cleanly and resumes it the moment the target thread finishes — no polling, no busy-waiting. If you need a timeout, thread.join(millis) provides one.

8.3 Handle InterruptedException Correctly

Both WAITING and TIMED_WAITING are interruptible. When a thread in either state receives an interrupt signal, it wakes immediately and throws InterruptedException. Always handle this exception by either re-throwing it or restoring the interrupt flag with Thread.currentThread().interrupt(). Swallowing it silently permanently discards the cancellation signal, making the thread un-cancellable.

8.4 Use Thread Dumps to Diagnose Production Issues

In production, a thread dump captures the state and full stack trace of every thread at a single point in time. When you see many threads in BLOCKED, look for lock contention. When you see many threads in WAITING, check for a missing notify() or a potential deadlock. Understanding the six lifecycle states makes reading thread dumps straightforward and, consequently, dramatically speeds up incident diagnosis.

The following snippet brings together practices 8.2 and 8.3 in a single example:

// Best practices: join() for completion + correct InterruptedException handling
Thread worker = new Thread(() -> {
    try {
        Thread.sleep(50); // Work that may be interrupted
        System.out.println("Running: " + Thread.currentThread().getName());
    } catch (InterruptedException e) {
        // Restore interrupt status — never swallow this exception
        Thread.currentThread().interrupt();
        System.out.println("Interrupted: " + Thread.currentThread().getName());
    }
}, "worker-thread-1");

worker.start();
worker.join(); // Use join() to wait for completion — never poll getState()
System.out.println("Final state: " + worker.getState()); // TERMINATED

Conclusion

The six thread states — NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, and TERMINATED — together describe the complete lifecycle of any Java thread from creation to completion. Each state reflects a specific, well-defined condition: whether the thread is competing for a lock, pausing voluntarily, actively running, or finished.

Understanding these states gives you a practical diagnostic tool. When something goes wrong in a concurrent application, a thread dump immediately tells you where each thread is stuck and why. Combined with the best practices covered in Section 8 — avoiding state polling, using join() for completion, and handling InterruptedException correctly — you are well-equipped to prevent the most common concurrency bugs and to track them down efficiently when they do appear.

You can find the complete code of this article on GitHub

Concurrency (Threads)

How to Create a Thread in Java

Noel Kamphoa

Senior Software Engineer and Tech Lead with 14+ years of professional experience in backend development, system design, and enterprise software. Passionate about clean architecture, scalable Java applications, and helping developers grow through structured learning and real-world practice.