Как обрабатывать исключения в цепочке CompletableFuture
В синхронном коде ошибки обрабатываются через блоки try-catch-finally. В CompletableFuture конвейер выполняется асинхронно в разных потоках, поэтому обычный try-catch перехватит...
🟢 Junior Level
В синхронном коде ошибки обрабатываются через блоки try-catch-finally. В CompletableFuture конвейер выполняется асинхронно в разных потоках, поэтому обычный try-catch перехватить ошибку не может.
Для декларативной обработки исключений в CompletableFuture есть три базовых метода:
exceptionally(Function<Throwable, T> fallback)(аналог блокаcatch):- Вызывается только при возникновении ошибки.
- Перехватывает исключение и возвращает дефолтное (запасное) значение типа
T, возвращая конвейер в нормальное русло.
handle(BiFunction<T, Throwable, R> fn)(универсальный обработчик):- Вызывается всегда — как при успехе, так и при аварии.
- Позволяет проанализировать оба аргумента (
resultиexception) и преобразовать результат в новый типR.
whenComplete(BiConsumer<T, Throwable> action)(аналог блокаfinally):- Вызывается всегда для выполнения побочных эффектов (логирование, закрытие ресурсов, сбор метрик).
- Не изменяет результат или ошибку конвейера (прозрачный наблюдатель).
Простой пример
CompletableFuture.supplyAsync(() -> {
if (new Random().nextBoolean()) {
throw new IllegalStateException("Сбой внешнего сервиса");
}
return "Успешный ответ";
})
// Перехват ошибки и возврат запасного значения:
.exceptionally(ex -> {
System.err.println("Произошла ошибка: " + ex.getMessage());
return "Резервное значение (кэш)";
})
// Следующий этап получает либо результат, либо fallback:
.thenAccept(data -> System.out.println("Получено: " + data));
🟡 Middle Level
Оборачивание в CompletionException
Любое исключение (как проверяемое checked, так и непроверяемое runtime), возникшее внутри лямбды любого этапа, автоматически оборачивается средой исполнения в java.util.concurrent.CompletionException:
CompletableFuture.supplyAsync(() -> {
throw new IllegalArgumentException("Неверный ID");
}).exceptionally(ex -> {
// ex имеет тип Throwable, но фактический экземпляр — CompletionException!
Throwable rootCause = (ex instanceof CompletionException) ? ex.getCause() : ex;
System.out.println("Исходная причина: " + rootCause.getClass().getSimpleName()); // IllegalArgumentException
return "Default";
});
Короткое замыкание (Short-Circuiting)
Если на промежуточном этапе конвейера возникает исключение:
- Все последующие стандартные этапы трансформации (
thenApply,thenAccept,thenCompose) немедленно пропускаются. - Конвейер находится в аварийном состоянии, пока в цепочке не встретится восстанавливающий узел (
exceptionallyилиhandle). - После восстановления с помощью
exceptionallyилиhandleвсе последующие этапы возобновляют нормальное выполнение.
CompletableFuture.supplyAsync(() -> "Step1")
.thenApply(s -> {
throw new RuntimeException("Crash"); // Шаг 2 упал
})
.thenApply(s -> s + " -> Step3") // ПРОПУСКАЕТСЯ!
.thenApply(s -> s + " -> Step4") // ПРОПУСКАЕТСЯ!
.exceptionally(ex -> "Recovered") // ПЕРЕХВАТ ОШИБКИ И ВОССТАНОВЛЕНИЕ
.thenApply(s -> s + " -> Step5") // ВЫПОЛНЯЕТСЯ: "Recovered -> Step5"
.thenAccept(System.out::println);
Восстановление через асинхронный Fallback: exceptionallyCompose (Java 12+)
До Java 12 метод exceptionally позволял возвращать только готовое синхронное значение T. Если же для восстановления требовалось обратиться к резервному микросервису (асинхронно), приходилось писать громоздкие обходные пути.
В Java 12 появился метод exceptionallyCompose (аналог thenCompose для ошибок):
// Java 12+: асинхронный fallback
primaryService.fetchDataAsync(id)
.exceptionallyCompose(ex -> {
log.warn("Основной сервис недоступен, идем в резервный...", ex);
return backupService.fetchDataAsync(id); // Возвращает CompletableFuture<Data>
});
🔴 Senior Level
Внутренний механизм распространения: AltResult и postComplete
Под капотом CompletableFuture не использует механизм раскрутки стека JVM (stack unwinding):
- Когда задача в потоке пула выбрасывает
Throwable t, метод перехватывает его черезcatch (Throwable ex). - Создаётся объект-маркер
AltResult(ex instanceof CompletionException ? ex : new CompletionException(ex)). - С помощью атомарной операции CAS поле
resultустанавливается в этотAltResult. - Метод
postComplete()обходит зависимый стек подписчиков (Treiber Stack). Для узлов типаUniApply,UniAcceptиUniComposeметодtryFire()проверяет: еслиresult instanceof AltResult, вызов пользовательской лямбды не производится, а ссылка наAltResultпросто передаётся дальше в выходной фьючер. - И только у узлов
UniExceptionallyиUniHandleметодtryFire()распознаёт ошибку, извлекаетexи передаёт его в пользовательский обработчик.
Конфликт исключений в whenComplete: подавленные исключения (Suppressed)
Метод whenComplete принимает BiConsumer<T, Throwable>. Возникает фундаментальный вопрос: что произойдёт, если и родительский фьючерс завершился с ошибкой $E_1$, и сама лямбда внутри whenComplete выбросила исключение $E_2$?
В спецификации CompletableFuture реализована строгая логика сохранения обеих ошибок:
CompletableFuture<String> cf = CompletableFuture.failedFuture(new IOException("Disk error"));
CompletableFuture<String> result = cf.whenComplete((val, ex) -> {
throw new IllegalStateException("Telemetry logger crashed");
});
try {
result.join();
} catch (CompletionException e) {
Throwable cause = e.getCause(); // IllegalStateException (ошибка из whenComplete)
Throwable[] suppressed = cause.getSuppressed(); // Содержит IOException ("Disk error")!
}
Ошибка из whenComplete замещает исходное исключение как главное, но исходное исключение обязательно прикрепляется к нему через механизм addSuppressed().
Правильная распаковка цепочки причин (Root Cause Unwrapping)
В высоконагруженных распределённых системах исключения могут многократно заворачиваться в CompletionException и ExecutionException на каждом сетевом барьере:
public static Throwable unwrap(Throwable ex) {
Throwable current = ex;
while (current instanceof CompletionException || current instanceof ExecutionException) {
if (current.getCause() == null) break;
current = current.getCause();
}
return current;
}
4 Tricky Questions
1. Что произойдёт, если внутри лямбды .exceptionally(fn) возникнет новое непроверяемое исключение?
Ответ:
Если обработчик exceptionally сам выбрасывает исключение, возвращённый им CompletableFuture переходит в состояние ошибки с этим новым исключением (обёрнутым в CompletionException). Исходная ошибка теряется, а новая начинает каскадно распространяться дальше вниз по цепочке. Перехватить её сможет только следующий exceptionally или handle, расположенный ниже по конвейеру.
2. В чём разница между поведением исключений при вызове cf.join() и cf.get()?
Ответ:
get()объявлен в интерфейсеFuture<T>: при ошибке бросает проверяемое исключениеjava.util.concurrent.ExecutionException, требующее обязательного блокаtry-catch (ExecutionException | InterruptedException e). При прерывании потока ожидания бросаетInterruptedException.join()объявлен в классеCompletableFuture<T>: при ошибке бросает непроверяемое (unchecked) исключениеjava.util.concurrent.CompletionException. Если поток был прерван, прерывание восстанавливается черезThread.currentThread().interrupt(), но наружу пробрасываетсяCompletionException(new InterruptedException()). Это делаетjoin()пригодным для использования в лямбдах Stream API.
3. Вызовется ли метод .whenComplete(action), если CompletableFuture был отменён через cf.cancel(true)?
Ответ:
Да, обязательно вызовется!
Отмена фьючера в CompletableFuture трактуется как аварийное завершение с исключением java.util.concurrent.CancellationException. В метод whenComplete((res, ex) -> ...) параметр res придёт равным null, а параметр ex будет содержать экземпляр CancellationException. Это позволяет корректно освободить дескрипторы, сокеты или файлы даже при отмене задачи.
4. Как организовать асинхронный Fallback (вызов другого сервиса) при ошибке в Java 8, где ещё не было exceptionallyCompose?
Ответ:
В Java 8 для этого использовали связку handle и ручного уплощения:
// Java 8 паттерн асинхронного fallback:
primaryService.fetchAsync()
.handle((res, ex) -> {
if (ex != null) {
return backupService.fetchAsync(); // возвращает CompletableFuture<Data>
}
return CompletableFuture.completedFuture(res);
})
.thenCompose(Function.identity()); // "Раскрываем" CompletableFuture<CompletableFuture<Data>>
В Java 12 этот двухэтапный костыль заменили нативным методом exceptionallyCompose.
🎯 Шпаргалка для интервью
Главное за 60 секунд
- Ошибки в
CompletableFutureне летят в стек вызовов, а оборачиваются вCompletionExceptionи текут вниз по цепочке, минуя обычные этапыthenApply/thenAccept. exceptionally(fn)— аналогcatch: вызывается только при сбое, возвращает fallback-значение.handle(fn)— универсальный обработчик: вызывается всегда, получает(result, throwable), может трансформировать результат и восстанавливать цепочку.whenComplete(action)— аналогfinally: вызывается всегда, не может изменить результат (side effect only). Если сам выбросит ошибку — она станет главной, а исходная попадёт вsuppressed.- Начиная с Java 12, для асинхронного восстановления используется
exceptionallyCompose.
Типичные ошибки на интервью
- Полагать, что
whenCompleteможет вернуть дефолтное значение при ошибке (он принимаетBiConsumerи ничего не возвращает). - Забывать о разнице между
ExecutionException(вget()) иCompletionException(вjoin()). - Не знать, что
cf.cancel()вызывает срабатываниеwhenCompleteиhandleсCancellationException.