Java Thread Join: Waiting for Threads to Finish
java thread join: Learn how Thread.join() blocks the calling thread until a worker finishes, including timeout variants, interrupt handling, and common pitfalls.
The java thread join mechanism is built around a single blocking method on the Thread class. When thread A calls threadB.join(), thread A stops executing and waits until thread B's run() method completes, either normally or by throwing an exception. The method returns only after that happens.
The most common use is coordinating work between threads: you start several worker threads, then call join() on each one so the main thread can safely collect results or proceed only after the workers have finished.
Thread worker = new Thread(() -> { // perform work }); worker.start(); worker.join(); // safe to read results produced by worker
Without the join() call, the main thread would race ahead and potentially read data before the worker thread produced it.
The Three join() Overloads and Their Return Behavior
Thread provides three overloads:
| Overload | Behavior |
|---|---|
join() | Waits indefinitely until the thread terminates |
join(long millis) | Waits up to the specified number of milliseconds |
join(long millis, int nanos) | Waits up to millis plus nanos nanoseconds |
All three return void. There is no return value indicating whether the thread actually completed or whether the timeout expired. To distinguish those cases, you must check thread.isAlive() after a timed join returns:
worker.join(500); if (worker.isAlive()) { // worker did not finish within 500 ms }
The nanosecond overload is effectively millisecond precision in practice because most operating systems do not schedule threads with nanosecond granularity. The extra parameter exists for API completeness.
Handling InterruptedException Correctly
join() throws InterruptedException because it blocks. If another thread interrupts the thread that is waiting, the wait ends immediately and the exception is thrown.
The standard pattern is to catch the exception and restore the interrupt status:
try { worker.join(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); // handle partial work or propagate }
Restoring the interrupt flag matters because swallowing the exception leaves the interrupted status cleared, which can cause other blocking calls in the same thread to behave incorrectly. If the method signature allows it, propagating the exception is often cleaner than catching it.
Waiting for Multiple Worker Threads
A common pattern is starting several threads and joining each one in sequence:
List<Thread> workers = new ArrayList<>(); for (int i = 0; i < 4; i++) { Thread t = new Thread(() -> doWork()); t.start(); workers.add(t); } for (Thread t : workers) { t.join(); }
Joining in the order the threads were started is fine. The total wait time is bounded by the slowest thread, not the sum of all threads, because threads run concurrently. The second join() call returns almost immediately if that thread already finished while the first one was still running.
Common Mistakes: Calling join() From the Wrong Thread
A frequent error is calling join() on the current thread:
Thread.currentThread().join();
This causes the current thread to wait for itself, which never completes. The thread blocks forever, effectively deadlocking. The same problem occurs when a thread calls join() on a thread that is waiting for it to finish, creating a circular wait.
Another mistake is calling join() before start(). A thread that has not been started is not alive, so join() returns immediately. The code appears to work but the worker never ran:
Thread worker = new Thread(task); worker.join(); // returns immediately, worker never started worker.start();
Timeout-Based Joins and Their Runtime Behavior
A timed join is useful when you want to wait for a worker but not indefinitely. This is common in shutdown sequences or when a worker may hang.
worker.join(2000); if (worker.isAlive()) { worker.interrupt(); }
The wait time is a lower bound, not an exact deadline. The calling thread may resume slightly later than the timeout due to scheduling delays. If the worker finishes just before the timeout expires, join() returns early, which is the intended behavior.
Timed joins do not cancel the worker thread. The worker continues running independently. If you need to stop it, you must coordinate cancellation separately, typically through an interrupt or a shared flag.
join() vs. Modern Concurrency Utilities
For a single worker thread, join() is simple and readable. But for more complex coordination, the java.util.concurrent package offers better tools.
ExecutorService with Future.get() provides the same wait-for-completion behavior plus the ability to retrieve a result or throw an exception from the worker:
ExecutorService pool = Executors.newFixedThreadPool(2); Future<Integer> future = pool.submit(() -> compute()); int result = future.get();
CountDownLatch is useful when multiple threads must signal completion to a coordinator, especially when the coordinator is not the thread that started the workers. CompletableFuture supports chaining and composition without explicit blocking.
The choice depends on whether you need results, error propagation, or composition. join() remains appropriate when you manage threads directly and only need to wait for completion.
When join() Is the Wrong Choice
Using join() inside a thread pool worker is a common source of thread starvation. If a pooled thread blocks on join() waiting for another task that is queued behind it, the pool can deadlock when the pool size is smaller than the number of blocked threads.
join() also does not propagate exceptions from the worker thread. If the worker's run() throws an uncaught exception, the thread terminates but join() still returns normally. The caller never sees the failure unless an uncaught exception handler was installed.
For production code that submits work to a pool, prefer Future or CompletableFuture, which surface exceptions directly. Reserve join() for scenarios where you control thread lifecycle explicitly and the coordination is simple.