What does join() method do and how does it differ from get()
Both join() and get() serve the same primary purpose: they transition the calling thread into a waiting state (blocking it) until the asynchronous computation inside the Complet...
🟢 Junior Level
Both join() and get() serve the same primary purpose: they transition the calling thread into a waiting state (blocking it) until the asynchronous computation inside the CompletableFuture completes, then return the resulting value or throw an exception.
The Primary Difference: Checked vs. Unchecked Exceptions
| Method | Exception Signature | Purpose & Design Context |
|---|---|---|
get() |
Checked (InterruptedException, ExecutionException) |
Inherited from Java 5 java.util.concurrent.Future. Requires explicit try-catch blocks or method throws clauses. |
join() |
Unchecked (CompletionException) |
Introduced in Java 8 CompletableFuture specifically for ergonomic use inside lambda expressions and Stream API without boilerplate exception handling. |
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> "Hello, World!");
// 1. Using get() — Classic Java 5 style requiring checked exception handling
try {
String res = future.get();
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // Restore interrupted status
} catch (ExecutionException e) {
log.error("Task failed: ", e.getCause());
}
// 2. Using join() — Modern functional style
String res = future.join(); // Throws unchecked CompletionException on failure
Essential Fit for the Stream API
join() is indispensable when waiting on batches of asynchronous computations in Java streams:
List<CompletableFuture<User>> futures = ids.stream()
.map(userService::fetchUserAsync)
.toList();
// Because CompletionException is unchecked, join() fits cleanly into a method reference:
List<User> users = futures.stream()
.map(CompletableFuture::join) // get() would require an ugly try-catch lambda wrapper
.toList();
🟡 Middle Level
1. Timeout Support: get(timeout) vs. join()
The legacy Future.get interface provides a built-in overloaded method accepting a timeout:
// Blocks calling thread for at most 2 seconds; throws TimeoutException if deadline expires
String result = future.get(2, TimeUnit.SECONDS);
In contrast, join() has no overloaded timeout variant in the standard API. Calling join() on an unresponsive future will block the calling thread indefinitely.
Starting with Java 9, this is solved by fluently chaining .orTimeout() prior to .join():
// Idiomatic modern approach without checked exceptions:
String result = future.orTimeout(2, TimeUnit.SECONDS).join();
// Throws CompletionException wrapping TimeoutException if deadline expires
2. Exception Unwrapping Nuances
When an asynchronous task throws an unhandled exception (e.g., IllegalArgumentException), get() and join() package the root cause differently:
CompletableFuture<String> failing = CompletableFuture.failedFuture(new IllegalArgumentException("Invalid ID"));
// When invoking get():
try {
failing.get();
} catch (ExecutionException e) {
Throwable original = e.getCause(); // IllegalArgumentException
}
// When invoking join():
try {
failing.join();
} catch (CompletionException e) {
Throwable original = e.getCause(); // IllegalArgumentException
}
[!IMPORTANT] The
CancellationExceptionSpecial Case: If the future was cancelled viafuture.cancel(true), bothget()andjoin()throwCancellationExceptiondirectly as an unchecked runtime exception, bypassing wrapper encapsulation inExecutionExceptionorCompletionException!
🔴 Senior Level
JDK Source Mechanics: Interruptible vs. Uninterruptible Waiting
The fundamental difference between get() and join() extends beyond exception types to how they interact with Thread Interruption:
// OpenJDK CompletableFuture Source Code:
public T get() throws InterruptedException, ExecutionException {
Object r;
if ((r = result) == null)
r = waitingGet(true); // true -> INTERRUPTIBLE
return (T) reportGet(r);
}
public T join() {
Object r;
if ((r = result) == null)
r = waitingGet(false); // false -> UNINTERRUPTIBLE
return (T) reportJoin(r);
}
The Internal waitingGet(boolean interruptible) Loop
get()invokeswaitingGet(true)(Interruptible Mode): If the thread blocked inget()receives athread.interrupt()signal, it aborts waiting immediately, unlinks itsSignallernode from the waiting queue, and throwsInterruptedException.join()invokeswaitingGet(false)(Uninterruptible Mode): If the thread waiting injoin()is interrupted viathread.interrupt(), it continues sleeping and waiting for task completion! It captures the interruption event in an internal boolean flag (interrupted = true). Only when theCompletableFutureis finished by another thread doeswaitingGetrestore the thread’s interrupt status flag:if (interrupted) Thread.currentThread().interrupt(); // Restore interrupt status upon exitAfter restoring the flag,
reportJoin(r)returns the result or throwsCompletionException.
JDK Unwrapping: reportGet vs. reportJoin
Completed results and errors are stored in a volatile Object result field wrapped in an AltResult holder:
private static Object reportGet(Object r) throws ExecutionException {
if (r instanceof AltResult) {
Throwable x = ((AltResult)r).ex;
if (x == null) return null; // NIL result
if (x instanceof CancellationException) throw (CancellationException)x;
Throwable cause;
// Unwraps nested CompletionException if present!
if ((x instanceof CompletionException) && (cause = x.getCause()) != null)
x = cause;
throw new ExecutionException(x); // Wraps in ExecutionException
}
return r;
}
private static Object reportJoin(Object r) {
if (r instanceof AltResult) {
Throwable x = ((AltResult)r).ex;
if (x == null) return null;
if (x instanceof CancellationException) throw (CancellationException)x;
if (x instanceof CompletionException) throw (CompletionException)x;
throw new CompletionException(x); // Wraps any other Throwable
}
return r;
}
Notice the implementation detail: if the stage was already completed with a CompletionException, reportJoin rethrows it directly without creating artificial nested layers (new CompletionException(new CompletionException(...))). Meanwhile, reportGet unwraps any intermediate CompletionException so its nested cause is placed directly into ExecutionException.
Lock-Free Fast-Path
If result != null when entering get() or join(), no thread parking occurs. The method reads the volatile result field and returns in nanoseconds. Hence, invoking .join() inside callbacks after CompletableFuture.allOf(...) is practically free and does not block operating system threads.
4 Tricky Questions
1. What happens if you call thread.interrupt() on a thread blocked inside cf.join() vs cf.get()?
Answer:
- A thread waiting inside
get()wakes up immediately, clears its interrupt status, and throwsInterruptedException. - A thread waiting inside
join()does not abort its wait. It remains parked/waiting untilcfis completed by a producer thread. Only after the future completes doesjoin()restore the thread’s interrupt flag viaThread.currentThread().interrupt()before returning the result (or throwingCompletionException).
2. Does get() always throw ExecutionException on failure while join() always throws CompletionException? What happens on cancel()?
Answer:
No, not always. If the CompletableFuture was cancelled via cancel(true) or cancel(false), the internal result is set to AltResult(CancellationException).
In this situation, both get() and join() throw CancellationException directly as an unchecked runtime exception, without wrapping it in ExecutionException or CompletionException.
3. If a pipeline stage throws MyCustomBusinessException, how do get() and join() surface it?
Answer:
- Calling
join(): ThrowsCompletionException, wheree.getCause()is the instance ofMyCustomBusinessException. - Calling
get(): The JDK’s internalreportGetmethod explicitly unwraps any intermediateCompletionExceptionand wraps its root cause intoExecutionException. Therefore,e.getCause()onExecutionExceptionyieldsMyCustomBusinessExceptiondirectly, skipping intermediate completion wrappers.
4. Is it safe to call .join() inside a callback attached to allOf(...).thenApply(...)? Does it block the worker thread?
Answer:
Yes, it is completely safe and an idiomatic Java pattern.
CompletableFuture.allOf(f1, f2) only transitions to the completed state after all individual constituent futures have resolved. Consequently, by the time the thenApply callback executes, f1.result and f2.result are guaranteed non-null. Calling f1.join() hits the lock-free fast-path: it reads the volatile field directly and returns immediately without thread parking or context switching.
🎯 Interview Cheat Sheet
Summary in 60 Seconds
- Both
get()andjoin()block the calling thread until the future completes. get()throws checkedInterruptedExceptionandExecutionException(Java 5).join()throws uncheckedCompletionException(Java 8), making it ideal for Stream API pipelines (map(CompletableFuture::join)).- On thread interruption:
get()aborts immediately withInterruptedException;join()waits until completion and re-asserts the interrupt flag. - Neither method wraps
CancellationException: both throw it directly as an unchecked exception. - In production, never invoke bare
join()without timeouts; use.orTimeout(timeout, unit).join().
Common Interview Traps
- Believing
join()is non-blocking (it blocks the calling thread just likeget()). - Trying to abort a stuck
join()viathread.interrupt()(the thread will ignore the interrupt until the future resolves). - Calling blocking
join()on reactive event-loop threads (e.g., Netty event loop).