⏱️ Розділ 19 · Питання #18

Що станеться, якщо в ланцюжку CompletableFuture виникне виняток

Якщо на будь-якому етапі ланцюжка CompletableFuture виникає виняток (checked або unchecked), відбувається наступне:


🟢 Junior Level

Якщо на будь-якому етапі ланцюжка CompletableFuture виникає виняток (checked або unchecked), відбувається наступне:

  1. Поточний етап завершується аварійно: виняток перехоплюється внутрішнім кодом CompletableFuture і загортається в CompletionException.
  2. Коротке замикання (Short-Circuiting): усі наступні стандартні етапи обробки (thenApply, thenAccept, thenRun, thenCompose) пропускаються (їхні лямбда-вирази не викликаються).
  3. Каскадування помилки: стан збою транслюється вниз конвеєром доти, доки ланцюжок не зустріне відновлювальний обробник (exceptionally або handle).
  4. Блокуючий доступ викине помилку: якщо викликати .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:

  1. Усередині робочого завдання AsyncSupply або UniApply блок try-catch(Throwable) ловить будь-яку помилку:
    catch (Throwable ex) {
        d.completeThrowable(ex);
    }
    
  2. Метод 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));
    }
    
  3. Метод postComplete() обходить стек залежностей stack.
  4. Для стандартних етапів (UniApply, UniAccept, UniCompose) метод tryFire() містить явну перевірку:
    if ((r = src.result) instanceof AltResult) {
        // Якщо джерело містить помилку — проштовхуємо AltResult далі в dep.result,
        // НЕ викликаючи користувацьку функцію!
        dep.result = encodeRelay(r);
        return true;
    }
    
  5. Тільки у відновлювальних вузлів 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$:

  1. Вихідна помилка $E_1$ заміщується новою помилкою $E_2$.
  2. Результуючий CompletableFuture переходить у стан помилки з $E_2$.
  3. Усі наступні ланки конвеєра знову пропускаються аж до наступного обробника 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.

Пов’язані теми