When you block on a CompletableFuture that failed, how do join() and get() differ in the exception they throw? Why does that distinction exist?
answer
- get() -> checked ExecutionException (+ InterruptedException)
- join() -> unchecked CompletionException
- Both wrap: use getCause()
- join() is lambda/stream friendly
- Cancellation: CancellationException, unwrapped, from both
basics
~10 sget() throws the checked ExecutionException (and you must catch it), while join() throws the unchecked CompletionException. Both wrap the real cause, so you call getCause() to reach it. join() is friendlier in lambdas.
solid answer
~40 sBoth join() and get() block until the future completes and re-throw its failure, but they differ in checked-ness. get() comes from the Future interface, so it throws checked ExecutionException (plus checked InterruptedException) — the compiler forces a try/catch. join() is CompletableFuture-specific and throws unchecked CompletionException — no try/catch required, which is why it composes cleanly inside streams and lambdas where checked exceptions are awkward. In both cases the original failure is the wrapped cause: ExecutionException.getCause() and CompletionException.getCause() return your real exception. A second difference: get() also declares InterruptedException (blocking interruptible), whereas join() does not throw a checked interruption type. Choose get() when you need interruptibility or a timeout overload and are fine handling checked exceptions; choose join() in functional pipelines where unchecked propagation is cleaner.
code
java · 19 linesCompletableFuture<String> cf = CompletableFuture.supplyAsync(() -> {
throw new RuntimeException("oops");
});
// get(): checked exceptions
try {
cf.get();
} catch (ExecutionException e) {
Throwable real = e.getCause(); // RuntimeException("oops")
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
// join(): unchecked
try {
cf.join();
} catch (CompletionException e) {
Throwable real = e.getCause(); // RuntimeException("oops")
}go deeper
Knows both block and can throw, but may not name the two exception types or the checked/unchecked split.
States get() throws checked ExecutionException and join() throws unchecked CompletionException, and that both wrap the cause.
Explains the historical/functional rationale (Future vs CompletableFuture, lambda-friendliness), interruptibility of get(), and the cancellation nuance.
Advises team conventions on blocking accessors, steers toward non-blocking timeouts (orTimeout/completeOnTimeout), and reasons about interruption/cancellation semantics across services.
## Two ways to block A `CompletableFuture<T>` is asynchronous, but sometimes you must **block** and get the value synchronously. Two methods do this: - `T get()` (and `T get(long timeout, TimeUnit unit)`) — inherited from the `Future` interface. - `T join()` — added by `CompletableFuture`. Both wait until the future completes, then either **return the value** (normal completion) or **throw** (exceptional completion). The difference is *which* exception they throw and whether it is **checked**. ## Checked vs unchecked — the core distinction **Checked** exceptions must be declared (`throws`) or caught — the compiler enforces it. **Unchecked** exceptions (subclasses of `RuntimeException`) don't. - `get()` throws **`ExecutionException`** — a **checked** exception — when the future failed. It *also* declares **`InterruptedException`** (checked), because the wait can be interrupted. So calling `get()` forces a `try/catch` (or `throws`) for *two* checked exceptions. - `join()` throws **`CompletionException`** — an **unchecked** (`RuntimeException`) — when the future failed. No compiler-mandated handling, and it does not throw a checked `InterruptedException`. ## Why two? `get()` predates `CompletableFuture`: it's the `Future` contract from Java 5, designed before lambdas, where checked exceptions on blocking calls were the norm. `join()` was added with `CompletableFuture` (Java 8) precisely so the type would compose well in **functional** contexts — streams, method references, lambdas — where a checked `ExecutionException` would be a syntactic nuisance (lambdas can't propagate checked exceptions through standard functional interfaces). `join()`'s unchecked `CompletionException` flows through such code without ceremony. ## Both wrap the cause Neither throws your raw exception. Both **wrap** it: ```java try { cf.get(); } catch (ExecutionException e) { Throwable real = e.getCause(); // your actual exception } catch (InterruptedException e) { Thread.currentThread().interrupt(); } try { cf.join(); } catch (CompletionException e) { Throwable real = e.getCause(); // your actual exception } ``` So regardless of which you pick, **call `getCause()`** to reach the underlying failure. The wrapper type differs (`ExecutionException` vs `CompletionException`) but the unwrap idiom is identical. ## Cancellation nuance If the future was **cancelled**, both methods throw `CancellationException` (unchecked) directly — it is *not* wrapped in `ExecutionException`/`CompletionException`. ## Choosing | Need | Prefer | |---|---| | Use inside a lambda / stream | `join()` (no checked exceptions) | | Interruptible blocking | `get()` (declares InterruptedException) | | Timeout overload | `get(timeout, unit)` (CompletableFuture also has `orTimeout`/`completeOnTimeout` as non-blocking options) | | Minimal boilerplate, you're OK with unchecked | `join()` | ## Mental model `get()` is the old, ceremony-heavy `Future` door (checked exceptions, interruptible). `join()` is the new lambda-friendly door (unchecked). Both lead to the same room — your failure is behind `getCause()` either way.
- Which method is more convenient inside a Stream's map and why?join(), because it throws an unchecked CompletionException; standard functional interfaces can't declare the checked ExecutionException that get() throws, so get() would require wrapping inside the lambda.
- If the future was cancelled, what does join() throw?CancellationException, thrown directly (not wrapped in CompletionException).
saying these in an interview costs you the question
- Saying join() throws ExecutionException (it throws CompletionException)
- Claiming get() returns the raw exception's value without wrapping
- Forgetting get() also declares the checked InterruptedException
- Thinking the wrapper type means the underlying cause differs — both expose it via getCause()