Java Thread Lifecycle: States, start, yield, sleep, interrupt, join

Understanding Java thread lifecycle management: thread states, start, yield, sleep, interrupt, join, daemon threads and common pitfalls.

published: reading time: 21 min read author: GeekWorkBench

Thread Lifecycle: States, start, yield, sleep, interrupt, and join

Every Java developer knows you create a thread with new Thread() and call .start(). But what happens between those two calls? And what do yield(), sleep(), interrupt(), and join() actually do? Spoiler: most developers get at least one of these wrong, and the bugs are nastier than you think.

Introduction

Understanding Java thread lifecycle management is fundamental to writing correct concurrent programs. Every thread transitions through a defined set of states — NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, and TERMINATED — and understanding what triggers each transition is essential for debugging deadlocks, diagnosing thread starvation, and reasoning about correctness in multi-threaded code. Getting any of these transitions wrong produces bugs that are notoriously difficult to reproduce and diagnose: a thread that silently fails to start, a deadlock that only manifests under specific timing conditions, or an interrupt that gets swallowed by a catch clause.

This post covers the complete thread lifecycle in detail: what each state means in terms of JVM and OS behavior, exactly what start(), yield(), sleep(), interrupt(), and join() do to thread state, and the practical pitfalls developers hit with daemon threads, interrupted flags, and spurious wakeups. By the end you will be able to predict exactly how your threads will behave under any sequence of API calls and avoid the most common concurrency mistakes.

Thread States: The State Machine

A Java thread exists in one of six states at any given time. This is defined in the Thread.State enum. For more on threads, see Threads and Lightweight Processes.

public enum State {
    NEW,        // Thread created but not started
    RUNNABLE,   // Running or ready to run (in JVM)
    BLOCKED,    // Waiting for a monitor lock
    WAITING,    // Waiting indefinitely (wait(), join(), park())
    TIMED_WAITING, // Waiting with a timeout (sleep(n), wait(n), join(n), parkNanos(), parkUntil())
    TERMINATED  // Thread has completed execution
}

State Transitions Diagram

graph TD
    NEW -->|"start()"| RUNNABLE
    RUNNABLE -->|"exit synchronized"| RUNNABLE
    RUNNABLE -->|"enter synchronized"| BLOCKED
    RUNNABLE -->|"wait()"| WAITING
    WAITING -->|"notify()/notifyAll()"| RUNNABLE
    RUNNABLE -->|"sleep()"| TIMED_WAITING
    TIMED_WAITING -->|"timeout expires"| RUNNABLE
    RUNNABLE -->|"join()"| WAITING
    RUNNABLE -->|"run() completes"| TERMINATED
    BLOCKED -->|"lock acquired"| RUNNABLE

What Each State Means

NEW: Thread object created but start() hasn’t been called. No OS resources yet.

RUNNABLE: Misleading name. The thread is either actually running or ready to run. Multiple RUNNABLE threads can exist on multi-core systems.

BLOCKED: Waiting for a monitor lock. Happens when trying to enter a synchronized block held by another thread.

WAITING: Waiting indefinitely for another thread to do something. Object.wait(), Thread.join() (no timeout), LockSupport.park().

TIMED_WAITING: Same as WAITING but with a timeout. Thread.sleep(), Object.wait(n), etc.

TERMINATED: run() completed. Dead thread walking - cannot be restarted.

Starting a Thread

Thread thread = new Thread(() -> {
    // This code runs in the new thread
    System.out.println("Hello from " + Thread.currentThread().getName());
});

thread.start(); // Start the thread, NOT run()!
System.out.println("This runs immediately in the main thread");

Key points:

  • Call start(), not run(). Calling run() executes in the current thread.
  • start() can only be called once. Twice throws IllegalThreadStateException.
  • The new thread starts executing immediately after start() returns.

Thread Naming

Thread thread = new Thread(() -> {
    // work
}, "worker-pool-1"); // Name the thread

Thread namedThread = new Thread();
namedThread.setName("dedicated-worker");

Default naming pattern is “Thread-” + nextSequenceNumber. Named threads help debugging immensely.

The yield() Method

Thread.yield() is a hint to the scheduler that the current thread is willing to give up its CPU time. The scheduler can ignore this hint.

Reality check: yield() is essentially a no-op on modern JVMs. Don’t rely on it for correctness. See Deadlock and Starvation for more on thread scheduling issues.

public void processBatch(List<Item> items) {
    for (Item item : items) {
        process(item);
        // Hint to scheduler: other threads may want CPU time
        Thread.yield();
    }
}

Reality check: yield() is essentially no-op on modern JVMs and OS schedulers. Don’t rely on it for correctness. It might cause the scheduler to pick another RUNNABLE thread, or it might not. The behavior is platform-dependent.

Use yield() only for:

  • Debugging thread scheduling issues
  • Cooperative multitasking in tight loops (rare)

Do NOT use yield() for:

  • Production correctness requirements
  • Replacing sleep() for delays
  • Lockfree algorithm hints

The sleep() Method

sleep() pauses the current thread for a specified duration:

// Sleep for 100 milliseconds
Thread.sleep(100);

// Sleep for 1 second and 500 milliseconds
Thread.sleep(1500);

// Interruptible sleep - preferred in loops
try {
    Thread.sleep(1000);
} catch (InterruptedException e) {
    // Thread was interrupted during sleep
    Thread.currentThread().interrupt(); // Restore interrupt flag
    return;
}

Important behavior:

  • sleep() does NOT release locks (synchronized blocks).
  • The thread is guaranteed not to run for at least the specified time.
  • The actual wake-up time depends on system timers and schedulers.
  • InterruptedException is thrown if another thread interrupts this thread while sleeping.

Sleeping Patterns

// BAD - catches and swallows interrupt
public void badSleepLoop() {
    while (true) {
        try {
            Thread.sleep(1000);
            doWork();
        } catch (InterruptedException e) {
            // Swallowed! No handling!
        }
    }
}

// GOOD - restores interrupt status
public void goodSleepLoop() {
    while (!Thread.currentThread().isInterrupted()) {
        try {
            Thread.sleep(1000);
            doWork();
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt(); // Restore flag
            break; // Exit gracefully
        }
    }
}

The interrupt() Method

Interruption is a cooperative mechanism, not a coercive mechanism. Calling interrupt() doesn’t stop a thread; it sets the interrupt flag and may cause blocking operations to throw InterruptedException.

Thread worker = new Thread(() -> {
    while (!Thread.currentThread().isInterrupted()) {
        try {
            // Blocking call - will throw if interrupted
            workQueue.take();
        } catch (InterruptedException e) {
            // Interrupt flag was set AND this exception thrown
            Thread.currentThread().interrupt(); // Restore flag!
            break;
        }
    }
});

worker.interrupt(); // Signal the thread to stop
worker.join(); // Wait for graceful termination

Checking Interrupt Status

// Option 1: Manual check
if (Thread.currentThread().isInterrupted()) {
    // Clean up and return
}

// Option 2: Static check (clears interrupt flag!)
if (Thread.interrupted()) {
    // This ALSO clears the interrupt flag
    // Use only when you want to consume the interrupt
}

The difference matters:

  • isInterrupted(): Checks flag without clearing it
  • Thread.interrupted(): Checks flag AND clears it

What interrupt() Actually Does

For a blocking thread:

  • If in Object.wait(), sleep(), join(): throws InterruptedException
  • If in LockSupport.park(): clears the permit and isInterrupted() returns true
  • If in blocking I/O on InterruptibleChannel: closes the channel, throws ClosedByInterruptException

For a running thread:

  • Only sets the interrupt flag
  • The thread must check and respond to it

The join() Method

join() blocks the calling thread until the target thread completes:

Thread worker = new Thread(() -> {
    // Long running task
    computeResult();
});

worker.start();

// Wait for worker to finish
try {
    worker.join(); // Blocks until worker terminates
    System.out.println("Worker done, result: " + result);
} catch (InterruptedException e) {
    Thread.currentThread().interrupt();
}

Timed Join

// Wait up to 5 seconds for worker to complete
boolean completed = false;
try {
    worker.join(5000); // 5000 milliseconds max
    completed = !worker.isAlive(); // or just check if join returned
} catch (InterruptedException e) {
    Thread.currentThread().interrupt();
}

if (!completed) {
    // Worker didn't finish in time
    handleTimeout();
}

Join and happens-before

join() establishes a happens-before relationship: when thread A calls threadB.join() and the call returns normally, all memory operations in thread B are visible in thread A. This is guaranteed by the Thread Termination Rule.

Daemon Threads

Daemon threads don’t prevent the JVM from shutting down. When all non-daemon threads finish, the JVM terminates without waiting for daemon threads.

Thread daemon = new Thread(() -> {
    while (true) {
        // Background monitoring
        checkHealth();
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            break;
        }
    }
});
daemon.setDaemon(true);
daemon.start();

Common daemon threads:

  • Garbage Collector (the JVM creates this)
  • Finalizer daemon
  • Signal dispatcher
  • Reference handler

Warning: Daemon threads should not perform I/O or acquire resources because the JVM might terminate them abruptly without cleanup.

When NOT to Use Manual Thread Management

Raw thread management via new Thread() and .start() works for learning or trivial scripts, but it falls apart in anything serious. If you are creating threads dynamically for each task, you will eventually exhaust resources — every thread reserves stack space (typically 256KB-1MB on JVMs), and OS schedulers seize up when you throw thousands of them at it. That is before your code does any real work.

Low-level thread lifecycle management is also more error-prone than it looks. Calling join() without a timeout means your thread waits forever if the target hangs. Calling interrupt() only works if the target is actually checking the interrupt flag — if it is stuck in a blocking call that does not throw InterruptedException, your interrupt is a no-op. Thread pools handle all of this for you, so you can focus on what your task actually does instead of managing its lifecycle.

For production code, use ExecutorService. It reuses threads, manages queues, and shuts down gracefully. The only good reasons to manage threads manually are: understanding how the JVM works, debugging scheduling problems, or working in environments where you cannot use java.util.concurrent. See Java Synchronization Primitives for how to coordinate threads safely once they are running.

Common Pitfalls / Anti-Patterns

  1. Calling run() instead of start(): Creates no new thread, runs in current thread.

  2. Swallowing InterruptedException: Always either handle the interrupt or re-interrupt.

  3. Assuming sleep() releases locks: It doesn’t. The synchronized lock is still held.

  4. Using yield() for correctness: It’s a hint, not a guarantee.

  5. Forgetting isAlive() check after join(): The thread might have already finished.

  6. Not setting daemon status before start(): After start(), daemon status is locked in.

  7. Interrupting a thread that doesn’t check: Setting the flag does nothing if the thread isn’t checking it.

Production Failure Scenarios

Scenario 1: Lost Interrupt

// BROKEN - losing the interrupt
public String fetchData() {
    try {
        return blockingFetch();
    } catch (InterruptedException e) {
        // Someone cares that we were interrupted
        // But we just swallow it!
        return null;
    }
}

// FIXED - propagate the interrupt
public String fetchData() throws InterruptedException {
    try {
        return blockingFetch();
    } catch (InterruptedException e) {
        throw e; // Let caller handle
    }
}

// Or restore if you must handle
} catch (InterruptedException e) {
    Thread.currentThread().interrupt();
    return null;
}

Scenario 2: Infinite Join

// BROKEN - potential infinite block
public void processWithWorker() {
    Thread worker = new Thread(() -> {
        // If this throws, thread never terminates
        doWork();
    });
    worker.start();
    worker.join(); // Waits forever if worker hangs
}

Fix: Always use timed joins or check interrupt status.

Trade-off Table

MethodReleases LocksGuaranteedUse Case
sleep()NoMinimum timeBounded delays, polling loops
yield()N/ANothingDebugging only
join()NoCompletionWaiting for task finish
wait()Yes (monitor)NotificationCondition-based coordination
park()NoUnpark/callbackLockfree algorithms

Observability Checklist

  • Can you identify all thread states in a thread dump?
  • Are you checking interrupt status in long-running operations?
  • Are timed operations using appropriate timeout values?
  • Is interrupt handling consistent across your codebase?
  • Do daemon threads handle shutdown gracefully?
  • Are you calling start() and not run()?

Security Notes

Thread interruption can expose sensitive operations:

  • An interrupted thread might leave data in inconsistent state
  • Interrupt during security-sensitive operations (crypto, auth) requires careful cleanup
  • Timing attacks can observe interruption patterns

Quick Recap Checklist

  • Thread states: NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, TERMINATED
  • Call start(), not run()
  • sleep() does not release locks
  • interrupt() is cooperative - threads must check the flag
  • Always restore interrupt flag after catching InterruptedException
  • join() blocks until thread termination with happens-before guarantee
  • Daemon threads don’t prevent JVM shutdown
  • yield() is essentially a no-op - don’t rely on it

Interview Questions

1. What's the difference between wait() and sleep() in Java?

wait() releases the monitor lock and puts the thread into WAITING state, while sleep() does not release any locks and keeps the thread in TIMED_WAITING state.

wait() must be called within a synchronized block (since Java 9 you can use wait() without it on the implicit monitor, but the semantics still require ownership). sleep() can be called from anywhere. Additionally, wait() is woken by notify() or notifyAll(), while sleep() simply waits for a timeout to expire.

2. What happens when you call interrupt() on a thread?

Calling interrupt() sets the thread's interrupt flag to true. If the thread is currently blocked in sleep(), wait(), join(), or an interruptible I/O operation, it will receive an InterruptedException. If the thread is running normally, the flag is set but the thread must explicitly check it with isInterrupted() or Thread.interrupted() and respond appropriately.

Importantly, interrupt() is cooperative. It doesn't forcibly stop a thread. The target thread's code must handle interruption by checking the flag and exiting cleanly.

3. How do you properly handle InterruptedException?

There are three main strategies depending on your situation:

1. Propagate: Let the exception escape to the caller who may handle it better. Just add it to your method signature with throws InterruptedException.

2. Restore and break: If you can't propagate (e.g., in a Runnable), catch the exception, call Thread.currentThread().interrupt() to restore the interrupt flag, then exit the loop or method.

3. Log and continue: Sometimes you want to keep working. Catch, log, and clear the flag with Thread.interrupted(). But only do this if you're sure you shouldn't stop.

The worst thing you can do is swallow the exception without restoring the interrupt flag. This loses the signal that another thread sent.

4. What's the difference between isInterrupted() and Thread.interrupted()?

isInterrupted() is an instance method that checks the interrupt flag without modifying it. You call it on a specific Thread object.

Thread.interrupted() is a static method that checks the interrupt flag for the current thread AND clears the flag to false as a side effect. It's essentially "check and reset" in one operation.

Use isInterrupted() when you just want to check without consuming the interrupt. Use Thread.interrupted() when you want to check and explicitly indicate you're handling the interrupt (consuming it).

5. Can you restart a thread after it has terminated?

No. Once a thread reaches TERMINATED state, it cannot be restarted. Calling start() on a terminated thread throws IllegalThreadStateException.

If you need "restartable" behavior, use an executor service or create a new thread. Alternatively, use a loop inside the thread that checks a flag before starting work:

while (!Thread.currentThread().isInterrupted()) { doWork(); }

This pattern allows controlled reuse within a single thread lifetime.

6. What are the six states of a Java thread and what does each mean?

NEW: Thread created but start() not yet called. No OS resources allocated.

RUNNABLE: Thread is either actually running or ready to run in the JVM. On multi-core systems, multiple RUNNABLE threads can run truly concurrently.

BLOCKED: Waiting for a monitor lock. Occurs when trying to enter a synchronized block held by another thread.

WAITING: Waiting indefinitely for another thread to perform a specific action (Object.wait(), Thread.join() without timeout, LockSupport.park()).

TIMED_WAITING: Same as WAITING but with a timeout (Thread.sleep(n), Object.wait(n), Thread.join(n), LockSupport.parkNanos()).

TERMINATED: Thread run() method has completed. Dead thread cannot be restarted.

7. Why should you call start() instead of run() on a Thread?

Calling run() executes the code in the current thread, not a new thread. No new OS thread is created, no scheduling occurs, and you lose all the benefits of multithreading.

Calling start() creates a new native thread and triggers the JVM to call run() in that new thread. The new thread runs concurrently with the caller. start() can only be called once per thread—calling it twice throws IllegalThreadStateException.

8. What is the difference between Thread.yield() and Thread.sleep()?

Thread.yield() is a hint to the scheduler that the current thread is willing to give up its CPU time. The scheduler can ignore this hint entirely—it is essentially a no-op on modern JVMs and OS schedulers.

Thread.sleep() pauses the thread for at least the specified duration. The thread does not release any locks it holds. sleep() guarantees the thread will not run for the minimum time specified, subject to system timer resolution.

Use yield() only for debugging or cooperative multitasking in tight loops. Use sleep() for bounded delays where you actually need to wait.

9. What happens to daemon threads when the JVM shuts down?

Daemon threads do not prevent JVM shutdown. When all non-daemon (user) threads terminate, the JVM shuts down immediately without waiting for daemon threads to complete.

This means daemon threads should not perform I/O operations, acquire resources, or do any cleanup that must complete. The JVM can terminate them abruptly at any point. Common daemon threads include the Garbage Collector, Finalizer, Signal Dispatcher, and Reference Handler—all created by the JVM.

10. Why is interrupt() considered a cooperative mechanism?

Calling interrupt() does not forcibly stop a thread. It sets the interrupt flag to true. If the target thread is blocked in sleep(), wait(), join(), or interruptible I/O, it receives InterruptedException. If the thread is running normally, the flag is set but the thread must check it and respond.

The running thread must explicitly call isInterrupted() or Thread.interrupted() to check the flag and exit cleanly. If a thread does not check the flag or is stuck in a blocking operation that does not throw InterruptedException, the interrupt has no effect. This cooperative design allows threads to handle interruption gracefully.

11. What does the Thread.Start() method actually do under the hood?

When you call start(), the JVM creates a new native thread and allocates it from the OS scheduler pool. The new thread begins execution by calling the run() method of the Thread object. The start() method returns immediately to the caller—the new thread runs concurrently.

Internally, start() registers the thread with the thread scheduler, allocates stack space, and triggers the native thread creation. The exact mechanics depend on the JVM implementation and OS (pthreads on Linux, Windows threads on Windows, etc.).

12. What is the difference between a daemon thread and a user thread in Java?

The only practical difference is how the JVM handles shutdown. The JVM continues running until all non-daemon (user) threads have terminated. Daemon threads are terminated abruptly when all user threads finish, without waiting for cleanup or finally blocks to complete.

Daemon threads are suitable for background tasks like garbage collection, monitoring, or housekeeping that don't need to complete work on shutdown. User threads keep the JVM alive and are used for actual application logic.

13. How does Thread.join() establish a happens-before relationship?

The Thread Termination rule in the JMM states that a thread's termination action happens-before any code that detects termination via join(). When threadB.join() returns in thread A, all memory operations performed by thread B are guaranteed to be visible in thread A.

This means you can safely read shared variables written by the worker thread after join() returns, without additional synchronization. The JVM guarantees this ordering.

14. Why should you avoid calling Thread.yield() in production code?

Thread.yield() is only a hint to the OS scheduler—it makes no guarantees. On modern JVMs and OS schedulers, yield often does nothing at all, or yields for such a brief period that it provides no meaningful synchronization benefit.

Relying on yield for correctness is dangerous because the behavior is platform-dependent. On some systems, yield gives other threads a chance to run; on others, the yielding thread immediately reacquires the CPU. For production code, use proper synchronization like sleep() with a purpose, or use higher-level concurrency utilities.

15. What happens when a thread is interrupted while in a WAITING or TIMED_WAITING state?

If a thread is in wait(), sleep(), join(), or other blocking methods in the WAITING or TIMED_WAITING states, calling interrupt() causes InterruptedException to be thrown immediately.

The interrupt flag is also set before the exception is thrown (the JVM clears it when throwing, but calling Thread.currentThread().interrupt() restores it). The thread exits the blocking operation and can handle the interruption by cleaning up and exiting gracefully.

16. Can a thread enter the BLOCKED state without trying to enter a synchronized block?

No. A thread enters the BLOCKED state only when attempting to acquire a monitor lock that is held by another thread. The BLOCKED state is specifically for threads waiting on a synchronized monitor.

Other waiting states—WAITING and TIMED_WAITING—occur when threads call Object.wait(), Thread.join(), Thread.sleep(), or LockSupport.park(). These are distinct states with different entry conditions.

17. What is the difference between Thread.interrupted() and Thread.currentThread().isInterrupted()?

Thread.interrupted() is a static method that checks and clears the interrupt flag for the current thread—it atomically checks and resets the flag to false. Thread.currentThread().isInterrupted() is an instance method that checks the flag without modifying it.

Use Thread.interrupted() when you want to consume the interrupt signal and indicate you're handling it. Use isInterrupted() when you just want to observe without consuming.

18. Why is it problematic to start a thread inside a constructor?

Starting a thread in a constructor allows the this reference to escape before the object is fully constructed. The new thread might see the object in a partially initialized state—fields with default values instead of assigned values, or objects referenced before they are fully constructed.

This violates the happens-before guarantees because the thread might access fields before the constructor's writes are visible. Use factory methods or builder patterns to separate object creation from thread startup, ensuring construction completes before the reference is published.

19. How does Thread.sleep() interact with the thread scheduler on multi-core systems?

When a thread calls sleep(), it remains in TIMED_WAITING state but does not consume CPU. The OS scheduler removes it from the ready queue for the specified duration. On multi-core systems, other threads and processes continue to use available CPU cores normally.

The sleeping thread is guaranteed not to run for at least the specified time, but exact wake timing depends on OS timer resolution and scheduler behavior. The thread becomes RUNNABLE again when the sleep duration expires or the thread is interrupted.

20. What is the purpose of the Thread.interrupt() method in cooperative multi-tasking?

The interrupt() method provides a way for one thread to signal another to stop. Unlike forceful termination (which doesn't exist in Java), this is cooperative—the target thread must check the interrupt flag and respond by exiting cleanly.

This pattern allows threads to finish current work, release resources, and restore invariants before stopping. It prevents the data corruption and resource leaks that would occur if one thread could abruptly stop another. The target thread decides when and how to respond to the interrupt.

Further Reading

Conclusion

You now understand Java thread lifecycle management: thread states, how lifecycle methods behave, and how to handle interruption properly. Apply these patterns when building threaded applications — always use executors instead of raw threads for production code, handle InterruptedException gracefully by restoring the interrupt flag, and use daemon threads only for non-critical background work. Continue with Java Synchronization Primitives to learn how to coordinate threads safely beyond basic lifecycle management.

Category

Related Posts

Java Memory Model: Happens-Before, Volatile, and Final Fields

Understanding happens-before guarantees, volatile field semantics, and final field safety in the Java Memory Model for correct concurrent code.

#java #jvm #concurrency

Java Atomics and VarHandle: Low-Level Concurrency

Understanding Java atomic operations: AtomicInteger, AtomicReference, VarHandle, compareAndSet, atomics vs locks, and lock-free programming patterns.

#java #jvm #concurrency

Java Concurrent Collections: ConcurrentHashMap, BlockingQueue

Java concurrent collections deep dive: ConcurrentHashMap, BlockingQueue, CopyOnWriteArrayList, ConcurrentLinkedQueue, and choosing the right structure.

#java #jvm #concurrency