Як обробляти винятки в ланцюжку 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.