⏱️ Раздел 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.

Связанные вопросы