Skip to content
Java multithreading 6 min read

Joining Threads

When you fire off multiple threads, you sometimes need to wait for one (or more) of them to finish before moving on. That is exactly what Thread.join() does — it pauses the calling thread until the target thread completes.

Why You Need join()

Imagine downloading three files in parallel. After the downloads finish, you want to merge the results. Without join(), your merge code might run before the downloads complete, giving you empty or partial data. join() is the clean, built-in way to express “wait for that thread, then continue.”

public class WithoutJoin {
    public static void main(String[] args) throws InterruptedException {
        Thread downloader = new Thread(() -> {
            System.out.println("Downloading...");
            try { Thread.sleep(1000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
            System.out.println("Download complete.");
        });

        downloader.start();
        // Without join(), "Merging" may print before download finishes
        System.out.println("Merging results...");
    }
}

Output (likely order without join):

Merging results...
Downloading...
Download complete.

The merge fires too early. Let’s fix that.

Basic Usage of join()

Call join() on the thread object you want to wait for. The current thread (the one calling join()) blocks until the target thread dies.

public class BasicJoin {
    public static void main(String[] args) throws InterruptedException {
        Thread downloader = new Thread(() -> {
            System.out.println("Downloading...");
            try { Thread.sleep(1000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
            System.out.println("Download complete.");
        });

        downloader.start();
        downloader.join(); // main thread waits here
        System.out.println("Merging results..."); // runs only after download finishes
    }
}

Output:

Downloading...
Download complete.
Merging results...

Note: join() throws InterruptedException, so you must handle it. If the waiting thread is interrupted while blocked in join(), the exception is thrown immediately.

The Three join() Overloads

Thread provides three versions:

MethodBehaviour
join()Wait indefinitely until the thread finishes
join(long millis)Wait at most millis milliseconds
join(long millis, int nanos)Wait at most millis ms + nanos nanoseconds

The timed variants are important in production code — you usually do not want to block forever.

public class TimedJoin {
    public static void main(String[] args) throws InterruptedException {
        Thread worker = new Thread(() -> {
            try { Thread.sleep(3000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
            System.out.println("Worker done.");
        });

        worker.start();
        worker.join(1500); // wait up to 1.5 seconds

        if (worker.isAlive()) {
            System.out.println("Worker is still running; moving on anyway.");
        } else {
            System.out.println("Worker finished within the timeout.");
        }
    }
}

Output:

Worker is still running; moving on anyway.

Tip: Always check isAlive() after a timed join() to know whether the thread actually finished or the timeout expired.

Joining Multiple Threads

A common pattern is to start several threads, collect them in an array or list, then join them all in a loop.

public class MultiJoin {
    public static void main(String[] args) throws InterruptedException {
        int count = 4;
        Thread[] threads = new Thread[count];

        for (int i = 0; i < count; i++) {
            final int id = i;
            threads[i] = new Thread(() -> {
                System.out.println("Thread " + id + " working...");
                try { Thread.sleep(500); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
                System.out.println("Thread " + id + " done.");
            });
            threads[i].start();
        }

        // Wait for all threads to complete
        for (Thread t : threads) {
            t.join();
        }

        System.out.println("All threads finished. Aggregating results.");
    }
}

Output (order of “working” lines may vary):

Thread 0 working...
Thread 1 working...
Thread 2 working...
Thread 3 working...
Thread 0 done.
Thread 1 done.
Thread 2 done.
Thread 3 done.
All threads finished. Aggregating results.

Tip: For larger-scale coordination, look at CountDownLatch or CompletableFuture from java.util.concurrent — they compose better when you have many tasks. join() is perfect for simple, small sets of threads.

join() and the Thread Life Cycle

Understanding where join() fits in the thread life cycle helps avoid confusion. When you call join():

  1. The calling thread moves from Runnable to Waiting (or Timed Waiting with a timeout).
  2. It stays there until the target thread reaches the Terminated state.
  3. The JVM then wakes the waiting thread, which returns to Runnable.

If the target thread is already terminated when you call join(), the method returns immediately — no blocking at all.

public class JoinAlreadyDone {
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(() -> System.out.println("Quick task"));
        t.start();
        t.join();          // wait until terminated
        t.join();          // called again — returns immediately, thread is already dead
        System.out.println("State: " + t.getState()); // TERMINATED
    }
}

Output:

Quick task
State: TERMINATED

Handling InterruptedException Correctly

Do not silently swallow the InterruptedException. The right pattern is to restore the interrupted flag so that callers further up the stack can react:

public class SafeJoin {
    public static void waitFor(Thread t) {
        try {
            t.join();
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt(); // restore flag
            System.out.println("Interrupted while waiting for thread: " + t.getName());
        }
    }

    public static void main(String[] args) {
        Thread worker = new Thread(() -> {
            try { Thread.sleep(2000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
        }, "worker-1");

        worker.start();
        waitFor(worker);
        System.out.println("Finished waiting.");
    }
}

Warning: Catching InterruptedException and doing nothing (empty catch block) hides the signal. Always re-interrupt or rethrow.

Under the Hood

Internally, Thread.join() is implemented using Object.wait() on the Thread object itself. Here is a simplified view of the JDK source:

// Simplified — actual JDK source is in java.lang.Thread
public final synchronized void join(long millis) throws InterruptedException {
    long base = System.currentTimeMillis();
    long now = 0;

    while (isAlive()) {
        if (millis == 0) {
            wait(0);           // wait indefinitely on this Thread's monitor
        } else {
            long delay = millis - now;
            if (delay <= 0) break;
            wait(delay);
            now = System.currentTimeMillis() - base;
        }
    }
}

When a thread terminates, the JVM calls notifyAll() on the thread object’s monitor — that is what wakes up any threads blocked in join(). This is why you should never call wait(), notify(), or notifyAll() directly on a Thread object in your own code — you could interfere with this mechanism and break join().

The method is synchronized, which means only one thread can be in the join() method on a given Thread instance at a time. Multiple threads can all join() the same target thread, though — the while (isAlive()) loop handles spurious wakeups and ensures all waiters see the termination.

join() vs sleep() vs yield()

MethodWho it affectsTypical use
join()Caller waits for a specific thread to dieCoordinate results between threads
sleep(ms)Caller pauses for a fixed timeRate limiting, retries, simulations
yield()Hints the scheduler to switch awayRarely useful in practice

See Thread.sleep() for a deeper look at pausing execution without waiting on another thread.

Quick Reference

Thread t = new Thread(task);
t.start();

t.join();                  // wait forever
t.join(2000);              // wait up to 2 seconds
t.join(2000, 500_000);     // wait up to 2 seconds + 500,000 nanoseconds

boolean stillRunning = t.isAlive(); // check after timed join
  • Thread Life Cycle — understand the states a thread moves through, including WAITING and TERMINATED
  • Creating a Thread — how to define and start threads before you join them
  • Thread.sleep() — pause a thread for a fixed duration instead of waiting on another thread
  • Callable & Future — a higher-level alternative that returns results from threads and supports timeout-based waiting
  • Synchronization — coordinate shared data access across threads running concurrently
  • Inter-Thread Communicationwait() / notify() patterns for threads to signal each other
Last updated June 13, 2026
Was this helpful?