Что произойдёт, если в цепочке CompletableFuture возникнет исключение
Если на любом этапе цепочки CompletableFuture возникает исключение (checked или unchecked), происходит следующее:
🟢 Junior Level
Если на любом этапе цепочки CompletableFuture возникает исключение (checked или unchecked), происходит следующее:
- Текущий этап завершается аварийно: исключение перехватывается внутренним кодом
CompletableFutureи оборачивается вCompletionException. - Короткое замыкание (Short-Circuiting): все последующие стандартные этапы обработки (
thenApply,thenAccept,thenRun,thenCompose) пропускаются (их лямбда-выражения не вызываются). - Каскадирование ошибки: состояние сбоя транслируется вниз по конвейеру до тех пор, пока цепочка не встретит восстанавливающий обработчик (
exceptionallyилиhandle). - Блокирующий доступ выбросит ошибку: если вызвать
.join()или.get()на любом звене цепочки после ошибки, будет выброшено исключение (CompletionExceptionилиExecutionException).
CompletableFuture.supplyAsync(() -> {
System.out.println("Шаг 1: генерация ID");
return "order_123";
})
.thenApply(orderId -> {
System.out.println("Шаг 2: ошибка валидации");
throw new IllegalArgumentException("Некорректный заказ: " + orderId);
})
.thenApply(orderId -> {
// ЭТОТ КОД НЕ ВЫПОЛНИТСЯ!
System.out.println("Шаг 3: обработка платежа");
return "payment_success";
})
.thenAccept(status -> {
// ЭТОТ КОД ТОЖЕ НЕ ВЫПОЛНИТСЯ!
System.out.println("Шаг 4: отправка чека: " + status);
})
.exceptionally(ex -> {
// ПЕРЕХВАТ ОШИБКИ И ВОССТАНОВЛЕНИЕ:
System.err.println("Цепочка спасена от падения! Причина: " + ex.getMessage());
return null;
});
🟡 Middle Level
Изоляция ошибки: родительские этапы не страдают
В графе зависимостей CompletableFuture ошибки распространяются строго вниз (Downstream). Сбой на дочернем этапе никак не влияет на родительский этап, который уже успешно завершился:
CompletableFuture<String> step1 = CompletableFuture.supplyAsync(() -> "Данные из БД");
CompletableFuture<String> step2 = step1.thenApply(data -> {
throw new RuntimeException("Сбой на шаге 2");
});
// Проверяем статусы:
System.out.println(step1.isCompletedExceptionally()); // false (успешно завершён!)
System.out.println(step1.join()); // "Данные из БД"
System.out.println(step2.isCompletedExceptionally()); // true (упал с ошибкой!)
Восстановление конвейера (Pipeline Recovery)
Если ошибка перехвачена с помощью exceptionally() или handle(), последующие этапы снова начинают выполняться в штатном режиме:
CompletableFuture.supplyAsync(() -> { throw new RuntimeException("Service down"); })
.exceptionally(ex -> "Default Cache Data") // Возврат резервного значения
.thenApply(String::toUpperCase) // ВЫПОЛНЯЕТСЯ: "DEFAULT CACHE DATA"
.thenAccept(System.out::println); // ВЫПОЛНЯЕТСЯ: печатает в консоль
🔴 Senior Level
Внутренний механизм: AltResult и обход зависимостей
Внутри CompletableFuture исключение не приводит к раскрутке стека потока (Stack Unwinding) через инструкции JVM athrow:
- Внутри рабочей задачи
AsyncSupplyилиUniApplyблокtry-catch(Throwable)ловит любую ошибку:catch (Throwable ex) { d.completeThrowable(ex); } - Метод
completeThrowableупаковывает ошибку в специальную внутреннюю обёртку:boolean completeThrowable(Throwable x) { return RESULT.compareAndSet(this, null, encodeThrowable(x)); } static AltResult encodeThrowable(Throwable x) { return new AltResult((x instanceof CompletionException) ? x : new CompletionException(x)); } - Метод
postComplete()обходит стек зависимостейstack. - Для стандартных этапов (
UniApply,UniAccept,UniCompose) методtryFire()содержит явную проверку:if ((r = src.result) instanceof AltResult) { // Если источник содержит ошибку — проталкиваем AltResult дальше в dep.result, // НЕ вызывая пользовательскую функцию! dep.result = encodeRelay(r); return true; } - Только у восстанавливающих узлов
UniExceptionallyиUniHandleлогика распаковываетAltResultи вызывает обработчик.
Опасность “Тихих сбоев” (Silent Failures)
Поскольку CompletableFuture перехватывает любые Throwable, исключение не вылетает в консоль и не попадает в Thread.UncaughtExceptionHandler!
Если разработчик написал:
// ❌ СКРЫТАЯ АВАРИЯ:
CompletableFuture.supplyAsync(() -> {
throw new OutOfMemoryError("Direct buffer overflow");
});
Никакого лога в System.err не появится. Поток пула просто закончит задачу и пойдёт разгребать следующую. Ошибка останется “запертой” внутри поля result объекта CompletableFuture. Если ссылка на объект потеряется без вызова join(), get() или exceptionally(), сборщик мусора просто удалит объект, а критический сбой останется незамеченным.
4 Tricky Questions
1. Что произойдёт, если задача в supplyAsync() выбросит java.lang.Error (например, OutOfMemoryError или AssertionError)? Упадёт ли поток пула?
Ответ:
Поток пула не упадёт.
Внутри CompletableFuture ловится не Exception, а базовый Throwable (catch (Throwable ex)). Ошибка любого рода (включая Error) будет упакована в AltResult(new CompletionException(error)) и сохранена в поле result. Поток воркера продолжит свою работу в пуле. Это предотвращает гибель воркеров в ForkJoinPool, но маскирует критические сбои JVM, если фьючерсы не инспектируются.
2. Что произойдёт с последующими этапами, если исключение возникло не в теле supplyAsync, а внутри самого восстанавливающего метода .exceptionally(fn)?
Ответ:
Если обработчик exceptionally сам выбрасывает исключение $E_2$:
- Исходная ошибка $E_1$ замещается новой ошибкой $E_2$.
- Результирующий
CompletableFutureпереходит в состояние ошибки с $E_2$. - Все последующие звенья конвейера снова пропускаются вплоть до следующего обработчика
exceptionally/handle, расположенного ниже по цепочке.
3. Если один фьючерс участвует в двух независимых ветках, и в одной из веток выброшено исключение, повлияет ли это на вторую ветку?
Ответ:
CompletableFuture<String> root = CompletableFuture.supplyAsync(() -> "OK");
CompletableFuture<String> branchA = root.thenApply(s -> { throw new RuntimeException("A failed"); });
CompletableFuture<String> branchB = root.thenApply(s -> s + " -> B success");
Нет, на вторую ветку это никак не повлияет.
Ветка branchA перейдёт в аварийное состояние, а ветка branchB успешно завершится со значением "OK -> B success". Каждое ветвление порождает независимый узел в графе зависимостей, и состояние ошибки изолировано рамками своей ветки.
4. В каком случае метод .join() выбросит CancellationException, а в каком — CompletionException?
Ответ:
- Если этап завершился из-за явного вызова
future.cancel(), метод.join()выброситCancellationExceptionнапрямую (без промежуточной обёртки). - Если этап завершился из-за любого исключения внутри бизнес-логики (
throw new RuntimeException()) или черезcompleteExceptionally(ex), метод.join()выброситCompletionException, внутри которого вcauseбудет упаковано исходное исключение.
🎯 Шпаргалка для интервью
Главное за 60 секунд
- Любое исключение оборачивается в
CompletionExceptionи сохраняется вresultкакAltResult. - Все обычные шаги (
thenApply,thenAccept,thenCompose) пропускаются вниз до первогоexceptionallyилиhandle. - Ошибки текут только вниз (downstream): родительские этапы и соседние ветки остаются успешными.
- Если цепочку бросить без обработчика и без вызова
join(), ошибка произойдёт тихо (Silent Failure) и не попадёт в логи. join()при падении задачи бросаетCompletionException, а при отмене задачи —CancellationException.
Типичные ошибки на интервью
- Полагать, что исключение в
CompletableFutureприводит к краху потока пула или выводу вSystem.err. - Считать, что сбой в дочернем звене ломает родительский фьючерс.
- Забывать о возможности возобновления конвейера после блока
exceptionally.