The 30 second answer
The exception was captured, not lost. With submit, the executor stores it in the returned Future and only get rethrows it. With execute, it reaches the thread's uncaught exception handler. CompletableFuture keeps it until someone calls join or get, and join wraps it in CompletionException. I always observe the result, or attach a handler, and log the cause at one place.
What happens. It depends on how the task was started.
- **
submit()** wraps the task in aFutureTask, which catches the exception and stores it. Nothing is printed. OnlyFuture.get()rethrows it, asExecutionExceptionwith the original as the cause. If no one callsget, the failure is invisible.ThreadPoolExecutor.afterExecutealso receives anullthrowable for such tasks; its Javadoc shows how to unwrap the Future to find it. - **
execute()** lets the exception escape the task, so the worker thread dies and the thread'sUncaughtExceptionHandlerruns, the default one prints the stack trace to standard error. The pool replaces the thread. - **
CompletableFuture** completes exceptionally.join()throwsCompletionException,get()throwsExecutionException, and in both the real error isgetCause(). Logging only the wrapper's message hides it. - Scheduled tasks. A periodic task from
scheduleAtFixedRatethat throws is not run again, which is why "the job just stops".
import java.util.concurrent.*;
public class SilentFailure {
public static void main(String[] args) throws Exception {
ExecutorService pool = Executors.newFixedThreadPool(2);
// 1. submit(): the exception is stored in the Future; nothing is printed
Future<?> f = pool.submit(() -> { throw new IllegalStateException("lost in submit"); });
Thread.sleep(100);
try { f.get(); } catch (ExecutionException e) {
System.out.println("get() -> " + e.getCause());
}
// 2. execute(): the exception reaches the thread's UncaughtExceptionHandler
ExecutorService logged = Executors.newSingleThreadExecutor(r -> {
Thread t = new Thread(r);
t.setUncaughtExceptionHandler((th, ex) -> System.out.println("handler -> " + ex));
return t;
});
logged.execute(() -> { throw new IllegalStateException("seen in execute"); });
Thread.sleep(100);
// 3. CompletableFuture: join() wraps the cause in CompletionException
CompletableFuture<Integer> cf = CompletableFuture.supplyAsync(() -> 1 / 0, pool);
try { cf.join(); } catch (CompletionException e) {
System.out.println("join() -> " + e.getCause());
}
pool.shutdown(); logged.shutdown();
}
}Verified with OpenJDK 21.0.13. It prints get() -> java.lang.IllegalStateException: lost in submit, handler -> java.lang.IllegalStateException: seen in execute and join() -> java.lang.ArithmeticException: / by zero.
Fixes.
- Always consume the Future, or chain
whenCompleteorexceptionallyon aCompletableFutureand log the unwrapped cause there. - Catch and log inside periodic tasks so one failure does not cancel the schedule.
- Set a thread factory with an
UncaughtExceptionHandlerforexecute-style pools, and overrideafterExecuteif the pool must log both kinds. - In streams, a lambda that throws aborts the whole pipeline; wrapping checked exceptions is fine, but catching and returning
nullsilently drops data. - In Spring,
@Asyncmethods returningvoidsend errors to anAsyncUncaughtExceptionHandler; methods returning a future put them in the future.
Alternate interview scenarios
"A parallel stream or CompletableFuture chain fails, and the log only says CompletionException." Unwrap: log e.getCause(), or use exceptionally(ex -> ...) where ex may itself be a CompletionException. Decide per call whether one failed element should fail the whole result or be recorded and skipped.
Sources (5)
- ExecutorService Javadoc (Java 21): submit returns a Future
- ThreadPoolExecutor Javadoc (Java 21): afterExecute and FutureTask
- Thread.UncaughtExceptionHandler Javadoc (Java 21)
- CompletableFuture Javadoc (Java 21): join and CompletionException
- ScheduledExecutorService Javadoc (Java 21): a failing periodic task is suppressed