В чём разница между handle(), exceptionally() и whenComplete()
Все три метода используются для реакции на завершение асинхронной стадии и обработки возможных ошибок в CompletableFuture, однако они имеют разное назначение, сигнатуры и влияни...
🟢 Junior Level
Все три метода используются для реакции на завершение асинхронной стадии и обработки возможных ошибок в CompletableFuture, однако они имеют разное назначение, сигнатуры и влияние на конвейер:
exceptionally(Function<Throwable, T> fallback)(аналог блокаcatch):- Вызывается только при ошибке.
- Перехватывает исключение и возвращает значение по умолчанию того же типа
T. - Если ошибки не было — метод просто пропускается.
handle(BiFunction<T, Throwable, U> fn)(полноценный обработчикtry-catch-finally):- Вызывается всегда (и при успехе, и при ошибке).
- Принимает два аргумента: результат и исключение.
- Может изменить тип возвращаемого значения с
TнаU, а также поглотить ошибку или выбросить новую.
whenComplete(BiConsumer<T, Throwable> action)(аналог блокаfinally/ шпион):- Вызывается всегда.
- Предназначен только для побочных эффектов (логирование, закрытие дескрипторов, сбор метрик).
- Не может изменить результат и не поглощает ошибку: конвейер продолжает нести исходный результат или ошибку дальше.
Быстрое визуальное сравнение
CompletableFuture<String> source = fetchDataAsync();
// 1. exceptionally: вызывается ТОЛЬКО при сбое, тип String не меняется
source.exceptionally(ex -> "default_value");
// 2. handle: вызывается ВСЕГДА, может превратить String в Integer
source.handle((val, ex) -> ex != null ? -1 : val.length());
// 3. whenComplete: вызывается ВСЕГДА, ничего не возвращает, сохраняет исходный String
source.whenComplete((val, ex) -> {
if (ex != null) log.error("Сбой", ex);
else log.info("Успех: {}", val);
});
🟡 Middle Level
Сравнительная матрица функциональности
| Критерий | exceptionally |
handle |
whenComplete |
|---|---|---|---|
| Функциональный интерфейс | Function<Throwable, ? extends T> |
BiFunction<? super T, Throwable, ? extends U> |
BiConsumer<? super T, ? super Throwable> |
| Когда вызывается? | Только при ошибке | Всегда (успех и ошибка) | Всегда (успех и ошибка) |
| Может изменить значение? | Да (возвращает fallback) | Да (возвращает результат функции) | ❌ Нет |
Может изменить тип (T $\rightarrow$ U)? |
❌ Нет (только T) |
✅ Да (возвращает CompletableFuture<U>) |
❌ Нет (сохраняет CompletableFuture<T>) |
| Поглощает ли ошибку? | ✅ Да (если вернул значение) | ✅ Да (если вернул значение) | ❌ Нет (ошибка летит дальше) |
| Основное назначение | Fallback при сбое | Трансформация исхода (Map & Recover) | Мониторинг / Clean-up (Spy) |
Критическая ловушка: проверка на null в handle
В метод handle((result, ex) -> ...) всегда передаются два аргумента, но их состояние зависит от исхода:
- При успешном завершении:
ex == null, аresultсодержит значение (которое само по себе может бытьnull!). - При аварийном завершении:
ex != null, аresult == null.
// ❌ ОШИБКА: предположение, что если result == null, то это ошибка
cf.handle((result, ex) -> {
if (result == null) { // ЛОЖНОЕ СРАБАТЫВАНИЕ: задача могла законно вернуть null!
return fallback;
}
return result;
});
// ✅ ПРАВИЛЬНО: проверять ТОЛЬКО ex != null
cf.handle((result, ex) -> {
if (ex != null) {
log.warn("Операция завершилась сбоем: {}", ex.getMessage());
return fallback;
}
return result;
});
🔴 Senior Level
Внутреннее устройство и поведение при выбросе исключений из колбэка
Все три метода реализуются через соответствующие узлы UniCompletion:
UniExceptionallyUniHandleUniWhenComplete
Поведение whenComplete при двойной ошибке
Что произойдёт, если исходный этап упал с исключением $E_1$, и сама лямбда whenComplete((r, ex) -> { throw new E2(); }) выбросила исключение $E_2$?
CompletableFuture<String> failed = CompletableFuture.failedFuture(new IOException("E1: Network down"));
CompletableFuture<String> monitored = failed.whenComplete((r, ex) -> {
throw new IllegalStateException("E2: Metric service failed");
});
- Главным исключением этапа
monitoredстанет $E_2$ (IllegalStateException). - Исходное исключение $E_1$ (
IOException) не теряется: рантайм вызываетE2.addSuppressed(E1)! - Если же исходный этап был успешен, но
whenCompleteвыбросил исключение, этап переходит в статус аварийного с ошибкой изwhenComplete.
Замена exceptionally на exceptionallyCompose (Java 12+)
Метод exceptionally имеет существенный архитектурный недостаток: его лямбда синхронна. Если для восстановления после сбоя требуется сделать ещё один асинхронный сетевой вызов (например, сходить в запасной дата-центр), exceptionally заставит заблокировать поток или породит вложенный фьючер CompletableFuture<CompletableFuture<T>>.
В Java 12 добавлен метод exceptionallyCompose:
// Асинхронное восстановление (Monadic catch / flatMap на ошибку)
primaryCluster.queryAsync(data)
.exceptionallyCompose(ex -> {
log.error("Основной кластер недоступен, переключаемся на резервный...", ex);
return secondaryCluster.queryAsync(data); // Возвращает CompletableFuture<Result>
});
4 Tricky Questions
1. Может ли метод .exceptionally() изменить возвращаемый тип с CompletableFuture<String> на CompletableFuture<Integer>? Как решить эту задачу, если тип изменить необходимо?
Ответ:
Нет, метод exceptionally() не может изменить тип.
Его сигнатура: exceptionally(Function<Throwable, ? extends T> fn). Функция обязана вернуть значение исходного типа T или его подтипа.
Если при ошибке требуется изменить возвращаемый тип конвейера (например, вернуть числовой HTTP-код или обёртку DTO), необходимо использовать метод handle(), сигнатура которого принимает BiFunction<? super T, Throwable, ? extends U> и возвращает CompletableFuture<U>.
2. Что придёт на вход лямбде в .handle((result, ex) -> ...), если исходный CompletableFuture<Void> успешно завершился?
Ответ:
Оба аргумента — и result, и ex — придут равными null!
ex == null, так как операция завершилась успешно без исключения.result == null, так как типVoidимеет единственное значение в Java — литералnull. Именно поэтому единственным надёжным индикатором успеха или сбоя вhandleиwhenCompleteявляется проверкаex != null.
3. Почему для сбора метрик и аудита в production-коде рекомендуется использовать whenComplete, а не handle?
Ответ:
- Сохранение сигнатуры типа:
whenCompleteгарантирует, что тип данныхTи само значение не изменятся случайно из-за ошибки в коде обработчика. - Невозможность случайного поглощения ошибки:
handleпо умолчанию поглощает ошибку, если не выбросить новое исключение из лямбды вручную (throw new CompletionException(ex)). Разработчик может забыть это сделать и вернутьnull, превратив критическую аварию в незаметныйSuccess(null).whenCompleteфизически не может поглотить ошибку — она гарантированно полетит дальше.
4. В каком потоке будет выполнен колбэк в handle, exceptionally и whenComplete?
Ответ:
Аналогично всем синхронным методам CompletionStage:
- Если на момент регистрации колбэка предыдущий этап уже был завершён, колбэк выполнится синхронно в вызывающем потоке.
- Если предыдущий этап ещё вычисляется, колбэк выполнится в том потоке, который завершит этот этап (поток из пула воркеров).
Если логика обработки ошибки или логирования блокирующая или тяжелая, необходимо явно использовать их асинхронные модификации:
whenCompleteAsync(action, executor),handleAsync(fn, executor)илиexceptionallyAsync(fn, executor)(Java 12+).
🎯 Шпаргалка для интервью
Главное за 60 секунд
exceptionally: вызывается только при ошибке, типTне меняется, возвращает запасное значение (fallback).handle: вызывается всегда, может менять тип (T$\rightarrow$U), может поглотить ошибку или выбросить новую.whenComplete: вызывается всегда, типTи результат не меняются (side-effect spy). Ошибку не поглощает.- При двойном сбое в
whenCompleteошибка из обработчика становится основной, а исходная ошибка сохраняется вsuppressed. - Начиная с Java 12, для неблокирующего восстановления доступен
exceptionallyCompose.
Типичные ошибки на интервью
- Полагать, что
whenCompleteперехватывает и гасит ошибку (ошибка обязательно передаётся дальше). - Проверять
result == nullвместоex != nullвнутриhandle. - Пытаться вернуть другой тип данных из
exceptionally.