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.
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(), notrun(). Callingrun()executes in the current thread. start()can only be called once. Twice throwsIllegalThreadStateException.- 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.
InterruptedExceptionis 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 itThread.interrupted(): Checks flag AND clears it
What interrupt() Actually Does
For a blocking thread:
- If in
Object.wait(),sleep(),join(): throwsInterruptedException - If in
LockSupport.park(): clears the permit andisInterrupted()returns true - If in blocking I/O on
InterruptibleChannel: closes the channel, throwsClosedByInterruptException
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
-
Calling run() instead of start(): Creates no new thread, runs in current thread.
-
Swallowing InterruptedException: Always either handle the interrupt or re-interrupt.
-
Assuming sleep() releases locks: It doesn’t. The synchronized lock is still held.
-
Using yield() for correctness: It’s a hint, not a guarantee.
-
Forgetting isAlive() check after join(): The thread might have already finished.
-
Not setting daemon status before start(): After
start(), daemon status is locked in. -
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
| Method | Releases Locks | Guaranteed | Use Case |
|---|---|---|---|
sleep() | No | Minimum time | Bounded delays, polling loops |
yield() | N/A | Nothing | Debugging only |
join() | No | Completion | Waiting for task finish |
wait() | Yes (monitor) | Notification | Condition-based coordination |
park() | No | Unpark/callback | Lockfree 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
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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.).
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.
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.
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.
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.
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.
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.
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.
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.
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
- Thread Class Documentation — Official Java Thread API documentation
- Java Thread Primitive API — Thread.State enum values and meanings
- Inside the Java Virtual Machine: Thread Architecture — Deep dive into JVM thread management
- ExecutorService Best Practices — Thread pool anti-patterns and how to avoid them
- Daemon Threads in Java — When and how to use daemon threads properly
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 Atomics and VarHandle: Low-Level Concurrency
Understanding Java atomic operations: AtomicInteger, AtomicReference, VarHandle, compareAndSet, atomics vs locks, and lock-free programming patterns.
Java Concurrent Collections: ConcurrentHashMap, BlockingQueue
Java concurrent collections deep dive: ConcurrentHashMap, BlockingQueue, CopyOnWriteArrayList, ConcurrentLinkedQueue, and choosing the right structure.