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

Як обробляти винятки в ланцюжку CompletableFuture

У синхронному коді помилки обробляються через блоки try-catch-finally. У CompletableFuture конвеєр виконується асинхронно в різних потоках, тому звичайний try-catch перехопити п...


🟢 Junior Level

У синхронному коді помилки обробляються через блоки try-catch-finally. У CompletableFuture конвеєр виконується асинхронно в різних потоках, тому звичайний try-catch перехопити помилку не може.

Для декларативної обробки винятків у CompletableFuture є три базові методи:

  1. exceptionally(Function<Throwable, T> fallback) (аналог блоку catch):
    • Викликається тільки при виникненні помилки.
    • Перехоплює виняток і повертає дефолтне (резервне) значення типу T, повертаючи конвеєр у нормальне русло.
  2. handle(BiFunction<T, Throwable, R> fn) (універсальний обробник):
    • Викликається завжди — як при успіху, так і при аварії.
    • Дозволяє проаналізувати обидва аргументи (result та exception) і перетворити результат на новий тип R.
  3. 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)

Якщо на проміжному етапі конвеєра виникає виняток:

  1. Усі наступні стандартні етапи трансформації (thenApply, thenAccept, thenCompose) негайно пропускаються.
  2. Конвеєр перебуває в аварійному стані, доки в ланцюжку не зустрінеться відновлювальний вузол (exceptionally або handle).
  3. Після відновлення за допомогою 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):

  1. Коли завдання в потоці пулу викидає Throwable t, метод перехоплює його через catch (Throwable ex).
  2. Створюється об’єкт-маркер AltResult(ex instanceof CompletionException ? ex : new CompletionException(ex)).
  3. За допомогою атомарної операції CAS поле result встановлюється в цей AltResult.
  4. Метод postComplete() обходить залежний стек підписників (Treiber Stack). Для вузлів типу UniApply, UniAccept та UniCompose метод tryFire() перевіряє: якщо result instanceof AltResult, виклик користувацької лямбди не здійснюється, а посилання на AltResult просто передається далі у вихідний ф’ючер.
  5. І тільки у вузлів 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.

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