Що станеться, якщо в ланцюжку 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.