t.join() makes the calling thread wait (state WAITING) until thread t finishes — i.e. t becomes TERMINATED. It's the simplest "wait for this to be done" primitive. To wait for several threads, you join() each of them, or use a higher-level synchronizer.
What join does
Internally join() is implemented with wait() guarded by isAlive() — when t's run() returns, the JVM calls notifyAll() on the Thread object, waking the joiners. The timed overload join(millis) returns after the deadline whether or not t finished — so you must re-check t.isAlive() to know which happened. join() throws InterruptedException, so the waiter itself can be interrupted.
Thread t = new Thread(() -> doWork());
t.start();
t.join(2000); // wait up to 2s
if (t.isAlive()) {
t.interrupt(); // it didn't finish in time
}
Waiting for several threads
The naive approach is to join them in sequence — correct, because the total wait equals the longest thread, not the sum:
List<Thread> workers = IntStream.range(0, 4)
.mapToObj(i -> new Thread(() -> process(i)))
.peek(Thread::start)
.toList();
for (Thread w : workers) {
w.join(); // returns when ALL are done
}
Better: use the right tool
join() only works on raw Thread objects. With an ExecutorService you'd instead use invokeAll() (blocks until every task completes), awaitTermination() after shutdown(), or CompletableFuture.allOf(...).join(). A CountDownLatch initialized to N, with each worker calling countDown(), lets the coordinator await() once — handy when you don't hold the Thread references. Since Java 21, StructuredTaskScope.join() (JEP) waits for a whole scope of subtasks with proper cancellation.