beginner~2h

Thread States, join(), sleep() & Daemon Threads

Creating a thread is the easy part — the harder skill is controlling one once it's running. This topic covers join(), sleep(), cooperative interruption, daemon threads, and uncaught exception handling: five small APIs that drive nearly all thread coordination, each with a sharp edge that trips up developers who only skim the method signature.

Learning objectives

  • Use join() correctly to wait for a thread's result without accidentally serializing parallel work
  • Explain why sleep() must never be called while holding a lock other threads need
  • Implement the standard two-part pattern for cooperative thread cancellation via interrupt()
  • Decide when a background thread should be marked daemon and understand the risk of forgetting to
  • Install an UncaughtExceptionHandler so a background thread's crash is never silently lost

◆ The problem

You send a courier out to fetch something you need before you can move forward. If you immediately try to use whatever they were supposed to bring back, before they've actually returned, you'll grab an empty hand instead of the package. Somewhere between sending the courier and needing the result, you have to actually wait — and how you wait, and how the courier can be asked to stop early, turns out to matter a lot.

A thread's run() method returns void — there is no built-in way for one thread to simply "call" another and get a return value back the normal way a method call would. If a main thread starts a background thread to compute something and then immediately tries to read the result, there is no guarantee the background thread has finished; the read might land on a default, uncomputed value. join() is the fix: it tells the calling thread "pause here, and do not continue until that other thread has completely finished."

Beyond just waiting, real coordination needs more tools: pausing a thread on purpose without holding anything hostage (sleep()), asking a thread to stop cooperatively since Java gives no safe way to force-kill one (interrupt()), deciding whether a background helper should block the JVM from exiting (the daemon flag), and finding out when a thread has silently died from an exception nobody caught (UncaughtExceptionHandler). Each of these looks like a small, obvious API on the surface. Each one has exactly one detail that, if missed, turns into a production incident that looks nothing like the code that caused it.

join() puts the calling thread into the WAITING state until the target thread reaches TERMINATED. Internally it is built on wait() — when the target thread dies, the JVM calls notifyAll() on it, waking every thread that had called join(). Crucially, join() also carries a happens-before guarantee: everything the joined thread did before terminating is visible to the thread that called join() after it returns, so results can be read safely afterward with no additional synchronization needed.

There are two shapes worth knowing. The simple case: start one background computation, join() it, then read its result. The fan-out/fan-in case: start several parallel workers first, collecting their handles, and only then loop back over that collection calling join() on each. The order matters enormously — calling join() immediately after each start(), inside the same loop, forces each worker to finish before the next one even begins, turning what should be parallel work into work that runs one after another. The total time becomes the sum of every worker's duration instead of roughly the duration of the slowest one.

Thread.sleep() pauses the current thread for approximately a given duration without burning CPU — the thread enters TIMED_WAITING, and the OS scheduler simply doesn't give it CPU time until the timer expires or it is interrupted. That sounds harmless, but sleep() has zero relationship to any lock the sleeping thread happens to be holding — a detail with real consequences covered next.

💻 Code example

package concurrency.threadstates; import java.util.ArrayList; import java.util.List; /** * join() used two ways: a single wait on one background computation, and a * fan-out/fan-in pattern that starts several workers before joining any. */ public class JoinAndSleepPatterns { static int computeSlowly() { try { Thread.sleep(200); } catch (InterruptedException ignored) {} return 42; } static void download(String url) { System.out.println("Downloading from: " + url); try { Thread.sleep(100); } catch (InterruptedException ignored) {} } public static void main(String[] args) throws InterruptedException { // Single wait: start a background computation, join it, then read the result. final int[] result = new int[1]; Thread computeThread = new Thread(() -> result[0] = computeSlowly()); computeThread.start(); computeThread.join(); System.out.println("Result: " + result[0]); // Fan-out/fan-in: start ALL workers first, THEN join all of them. List<String> urls = List.of("url1", "url2", "url3"); List<Thread> threads = new ArrayList<>(); for (String url : urls) { Thread t = Thread.ofPlatform().start(() -> download(url)); threads.add(t); // note: joining inside THIS loop would serialize the downloads } for (Thread t : threads) { t.join(); } System.out.println("All parallel downloads complete!"); } }

Java deliberately gives no safe way to forcibly kill a running thread from the outside — the old Thread.stop() exists but is deprecated, because killing a thread mid-operation can leave shared objects half-updated and corrupted. Instead, cancellation in Java is cooperative: you ask a thread to stop, and the thread itself has to notice and choose to exit. interrupt() sets a boolean flag on the target thread; it is entirely up to that thread's own code to check the flag and react.

If the target thread is currently inside sleep(), wait(), or join() when interrupt() is called, those methods throw InterruptedException immediately and clear the flag as part of doing so — which is exactly why a caught InterruptedException must either restore the flag or propagate the exception, as covered previously.

The standard, robust template for a cancellable worker combines two things: a loop condition that checks isInterrupted(), and a catch block around any blocking call inside that loop that restores the flag before the loop can naturally re-check it. Miss either half and interrupt() can silently do nothing at all — the single most common cause of "I called interrupt() but the thread refuses to stop" bugs. A finally block guaranteeing cleanup runs regardless of how the loop exits rounds out the pattern.

💻 Code example

package concurrency.threadstates; /** * The standard cancellable-worker template: loop condition checks * isInterrupted(), a blocking call's InterruptedException restores the * flag, and a finally block guarantees cleanup runs either way. */ public class CooperativeCancellationPattern { static class LongRunningTask implements Runnable { @Override public void run() { try { while (!Thread.currentThread().isInterrupted()) { processNextChunk(); Thread.sleep(100); // throws + clears the flag if interrupted here } } catch (InterruptedException e) { Thread.currentThread().interrupt(); // restore the flag } finally { cleanup(); // always runs, whether cancelled or finished normally } } private void processNextChunk() { System.out.println("Processing data chunk..."); } private void cleanup() { System.out.println("Cleaning up resources..."); } } public static void main(String[] args) throws InterruptedException { Thread worker = new Thread(new LongRunningTask()); worker.start(); Thread.sleep(300); // let it process a few chunks worker.interrupt(); // request cooperative cancellation worker.join(); // wait for it to actually finish cleanup } }

▲ Common mistake

Calling sleep() while holding a synchronized lock. Unlike wait(), sleep() does not release the monitor it currently holds — it idles while still keeping the door shut. Every other thread that needs the same lock is forced to sit BLOCKED for the entire sleep duration, even though the sleeping thread isn't doing anything with the lock. In a real multi-threaded system this looks like a mysterious full-system freeze: unrelated requests all stall because they're waiting on a lock held by a thread that is simply asleep.

▲ Common mistake

A tight loop with no blocking call inside it ignores interrupt() completely. If a worker's loop condition is while (true) instead of checking isInterrupted(), and the loop body never calls an interruptible method, then interrupt() sets the flag but nothing ever looks at it — join() on that thread hangs forever. The two-pronged check (loop condition and catching InterruptedException from a blocking call inside the loop) is what makes cancellation actually work.

▲ Common mistake

Forgetting to mark an infinite-loop background thread as a daemon. If a heartbeat sender or log flusher is left as a normal (non-daemon, "user") thread, the JVM will wait forever for it to finish before exiting — and an infinite while (true) loop never finishes on its own. A forgotten daemon flag on a background poller is a genuinely common reason some Java processes "never exit" even after their real work is done. setDaemon(true) (or .daemon() on the builder) must be called before start(); calling it afterward throws IllegalThreadStateException.

▲ Edge case

An uncaught exception in a background thread does not crash the JVM, does not propagate to whoever called start(), and is not rethrown by join(). By default, the JVM prints a stack trace to stderr and lets that one thread quietly die — every other thread, including main, keeps running with no idea anything went wrong. Without an installed UncaughtExceptionHandler, a crashed worker can be nearly invisible in production.

The JVM's exit condition is precise: it terminates once every user (non-daemon) thread has finished, regardless of how many daemon threads are still running. Daemon threads are killed abruptly at that point — they get no chance to run cleanup code, no finally block guarantee, nothing. The JVM's own internal housekeeping threads (garbage collection, JIT compilation) are all daemons for exactly this reason: they exist purely to serve the application, not to represent work the application must finish before exiting. A thread inherits its parent's daemon status by default, so any thread spawned from the main thread is a user thread unless explicitly marked otherwise.

Thread.yield() and thread priority (1 to 10, default 5) both look like control mechanisms and are, in practice, just polite, non-binding suggestions to the OS scheduler. yield() hints that the current thread is willing to give up its CPU slice; the scheduler is free to ignore it entirely. Priority is similarly just a hint — on Linux's default CFS scheduler it has minimal effect, on Windows somewhat more, but neither should ever be relied on for correctness. Racing a MAX_PRIORITY thread against a MIN_PRIORITY thread doing identical work does not reliably produce a consistent winner, and that unpredictability is the entire point: don't write correctness-critical code that assumes one will win.

Thread.UncaughtExceptionHandler is the fix for silent background-thread crashes: a hook that runs whenever a thread dies from an exception nobody caught, receiving both the dead Thread and the Throwable that killed it. It can be set per-thread (Thread.Builder.uncaughtExceptionHandler(...)) or as a JVM-wide default via Thread.setDefaultUncaughtExceptionHandler(...), and installing one — directly, or automatically via a custom ThreadFactory — is considered mandatory for any long-lived worker or consumer thread in production.

💻 Code example

package concurrency.threadstates; /** * Demonstrates that an uncaught exception in a background thread is * silently lost without an installed UncaughtExceptionHandler. */ public class UncaughtExceptionHandling { static void doWork() { System.out.println("Performing payment work..."); int result = 10 / 0; // throws ArithmeticException, uncaught here } public static void main(String[] args) throws InterruptedException { Thread.UncaughtExceptionHandler handler = (thread, ex) -> { System.err.printf("Thread %s crashed with error: %s%n", thread.getName(), ex.getMessage()); alertOpsTeam(ex); }; Thread worker = Thread.ofPlatform() .name("payment-worker") .uncaughtExceptionHandler(handler) // attach BEFORE the thread can crash .start(UncaughtExceptionHandling::doWork); // join() waits for termination but never rethrows what killed the thread. worker.join(); System.out.println("main() completes normally -- join() does not surface the crash."); } static void alertOpsTeam(Throwable ex) { System.out.println("OPS ALERT: " + ex); } }

The fan-out/fan-in join() pattern is the backbone of any manual parallel batch job: splitting a large dataset into chunks, processing each chunk on its own thread, and joining all of them before combining results. It's also exactly the shape ExecutorService.invokeAll() automates internally, which is why understanding the manual version first makes the higher-level API far less mysterious later.

Daemon threads back nearly every "background service" a real application runs: connection pool eviction sweepers, cache expiry checkers, metrics reporters, health-check pingers. Getting the daemon flag right is a recurring, genuinely consequential decision — Kubernetes-style deployments that expect a container to exit cleanly after its main work finishes have been broken more than once by a forgotten non-daemon background thread quietly keeping the JVM alive.

Cooperative cancellation via interrupt() underlies ExecutorService.shutdownNow(), Future.cancel(true), and virtually every timeout-and-cancel feature built on top of the executor framework — a task that ignores interruption is a task none of those higher-level cancellation mechanisms can actually stop.

UncaughtExceptionHandler shows up constantly in observability tooling: application frameworks and monitoring agents install a default handler at startup specifically to route background-thread crashes into structured logs, error-tracking services (Sentry, Bugsnag-style tools), and paging systems — because the JVM's own default behavior (printing to stderr and moving on) is functionally invisible in a production system where nobody is watching raw console output.

Q: Why does joining inside the same loop that starts several threads kill parallelism?

A: Each join() call blocks the loop from starting the next thread until the current one finishes, turning parallel work into sequential work. The fix is to start all threads first (fan-out), collect their handles, then join every one of them in a second loop (fan-in).

Q: What is the critical difference between sleep() and wait() regarding locks?

A: sleep() does not release any monitor lock the sleeping thread holds -- it idles while still blocking every other thread that needs that same lock. wait() does release the lock, which is why it must be called inside a synchronized block.

Q: Why can interrupt() sometimes have no effect at all on a running thread?

A: If the thread's loop condition never checks isInterrupted() and its body never calls an interruptible blocking method (like sleep() or wait()), the interrupt flag gets set but nothing in the code ever inspects it or reacts to it.

Q: What happens if you forget to mark a background thread with an infinite loop as a daemon?

A: The JVM will not exit until every non-daemon (user) thread finishes. An infinite while(true) loop never finishes on its own, so the JVM hangs forever even after the application's real work is done.

Q: What happens when a background thread throws an exception nobody catches, and what fixes it?

A: By default the JVM prints a stack trace to stderr and lets that one thread die silently -- it does not crash the JVM, propagate to the thread that called start(), or get rethrown by join(). Installing a Thread.UncaughtExceptionHandler (per-thread or as a JVM-wide default) is the fix, letting you log, alert, or track the crash instead of losing it.

Want a visual for this concept?

Generate a diagram tailored to “Thread States, join(), sleep() & Daemon Threads” — the AI picks whichever visual (flowchart, comparison, sequence, etc.) best fits.

Sign in to generate a visual →

Practice quiz

Next Step

Continue to Synchronization & Intrinsic Locks← Back to all Java Concurrency & Multithreading chapters