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

Які основні переваги CompletableFuture перед Future

Інтерфейс Future, що з'явився в Java 5, був величезним кроком уперед порівняно з прямим керуванням Thread, проте мав серйозне фундаментальне обмеження: він був пасивним конте...


🟢 Junior Level

Інтерфейс Future<T>, що з’явився в Java 5, був величезним кроком уперед порівняно з прямим керуванням Thread, проте мав серйозне фундаментальне обмеження: він був пасивним контейнером. Щоб дізнатися статус або отримати результат, доводилося блокувати потік через get() або опитувати isDone() у циклі.

CompletableFuture<T> (Java 8+) надав повноцінну неблокуючу реактивну модель роботи з майбутніми результатами.

5 ключових переваг CompletableFuture

  1. Неблокуюча модель (Non-blocking): виконання дій після завершення обчислення через функції зворотного виклику (колбеки thenApply, thenAccept, thenRun) без заморожування викликаючого потоку.
  2. Монадична компонованість (Composability): можливість пов’язувати залежні завдання в конвеєри (пайплайни), де результат одного обчислення автоматично передається на вхід наступному (thenCompose).
  3. Паралельне комбінування (Fan-out / Fan-in): можливість дочекатися завершення кількох паралельних незалежних завдань (allOf, anyOf, thenCombine).
  4. Декларативна обробка винятків: обробка помилок прямо в ланцюжку викликів (exceptionally, handle), аналогічно блокам catch у синхронному коді.
  5. Явне ручне завершення (Manual Completion / Promise): можливість завершити обчислення вручну з будь-якого потоку (complete(), completeExceptionally()), що робить його ідеальним містком для асинхронних подій, вебсокетів або мережевих колбеків.

Наочне порівняння в коді

// ❌ З використанням класичного Future:
Future<String> userFuture = executor.submit(() -> fetchUser(userId));
String user = userFuture.get(); // БЛОКУВАННЯ ПОТОКУ

Future<List<Order>> ordersFuture = executor.submit(() -> fetchOrders(user));
List<Order> orders = ordersFuture.get(); // ЩЕ ОДНЕ БЛОКУВАННЯ ПОТОКУ

// ✅ З використанням CompletableFuture:
CompletableFuture.supplyAsync(() -> fetchUser(userId), ioExecutor)
    .thenCompose(user -> CompletableFuture.supplyAsync(() -> fetchOrders(user), ioExecutor))
    .thenAccept(orders -> renderOrders(orders));
// Жоден потік не блокується в очікуванні відповіді мережею!

🟡 Middle Level

1. Композиція залежних завдань (thenCompose) vs Незалежних (thenCombine)

У класичному Future неможливо зв’язати два завдання так, щоб друге стартувало автоматично лише після завершення першого з його результатом, не блокуючи при цьому проміжний потік.

  • thenCompose (аналог flatMap): використовується, коли наступна функція сама повертає CompletableFuture:
    CompletableFuture<Double> priceFuture = fetchProduct(productId)
        .thenCompose(product -> fetchDiscount(product)); // Уникає вкладеності CompletableFuture<CompletableFuture<Double>>
    
  • thenCombine (аналог zip): використовується, коли два незалежні завдання запускаються паралельно, а їхні результати об’єднуються третьою функцією:
    CompletableFuture<String> weatherFuture = CompletableFuture.supplyAsync(() -> getWeather());
    CompletableFuture<String> trafficFuture = CompletableFuture.supplyAsync(() -> getTraffic());
    
    CompletableFuture<String> reportFuture = weatherFuture.thenCombine(
        trafficFuture, 
        (weather, traffic) -> "Погода: " + weather + ", Затори: " + traffic
    );
    

2. Декларативна обробка помилок та відновлення

У Future будь-яка помилка загортається в ExecutionException під час виклику get(). Це змушує огортати весь клієнтський код у громіздкі блоки try-catch.

У CompletableFuture помилка переводить увесь подальший конвеєр у стан збою (усі звичайні трансформації пропускаються), доки потік управління не зустріне обробник:

  • exceptionally(Function<Throwable, T> fallback): перехоплює виняток і повертає дефолтне безпечне значення.
  • handle(BiFunction<T, Throwable, R> fn): викликається завжди (і при успіху, і при помилці), дозволяючи трансформувати обидва результати.
CompletableFuture.supplyAsync(() -> externalPaymentGateway.charge(orderId), ioExecutor)
    .thenApply(receipt -> processReceipt(receipt))
    .exceptionally(ex -> {
        log.warn("Платіж не вдався, вмикаємо резервний сценарій: {}", ex.getMessage());
        return createPendingReceipt(orderId);
    });

3. Механізм таймаутів (Java 9+)

У Java 5 Future.get(long timeout, TimeUnit unit) викидав TimeoutException, але блокував потік до закінчення терміну. У Java 9 у CompletableFuture з’явилися елегантні неблокуючі методи:

  • orTimeout(timeout, unit): якщо операція не завершилася за відведений час, ф’ючер завершується з TimeoutException.
  • completeOnTimeout(fallbackValue, timeout, unit): якщо обчислення прострочено, ф’ючер плавно завершується з переданим значенням за замовчуванням замість помилки.

🔴 Senior Level

Економіка потоків та споживання системних ресурсів

Головна архітектурна перевага CompletableFuture перед Future криється не в зручності синтаксису, а в кардинально іншій моделі утилізації апаратних ресурсів.

  1. Проблема блокуючого очікування (Future.get()):
    • Кожен заблокований потік ОС утримує ~1 МБ стекової пам’яті (-Xss).
    • 10 000 паралельних зовнішніх I/O викликів через класичний Future вимагатимуть 10 000 потоків ОС (~10 ГБ лише на стеки потоків), що неминуче призведе до java.lang.OutOfMemoryError: unable to create new native thread та колосальних затримок на перемикання контексту процесора (Context Switching).
  2. Асинхронний конвеєр (CompletableFuture):
    • Завдання перебувають у стані очікування (наприклад, зареєстровані в селекторі NIO або Netty EventLoop) у вигляді легковажних вузлів у купі (heap).
    • Десятки тисяч таких завдань можуть обслуговуватися пулом усього з кількох десятків потоків. Потоки не простоюють, а виконують корисну роботу.

Патерн Promise та інтеграція із зовнішніми подіями

CompletableFuture реалізує патерн Promise — можливість відв’язати завершення обчислення від потоку-виробника:

@Service
public class AsyncGatewayClient {
    private final ConcurrentMap<String, CompletableFuture<Response>> inFlight = new ConcurrentHashMap<>();

    public CompletableFuture<Response> sendRequest(Request request) {
        CompletableFuture<Response> promise = new CompletableFuture<>();
        inFlight.put(request.correlationId(), promise);

        // Відправка по сокету/Kafka без очікування відповіді
        socketClient.send(request); 
        return promise;
    }

    // Викликається потоком-слухачем вхідних мережевих пакетів:
    public void onMessageReceived(Response response) {
        CompletableFuture<Response> promise = inFlight.remove(response.correlationId());
        if (promise != null) {
            promise.complete(response); // Завершуємо ф'ючер асинхронно!
        }
    }
}

З класичним FutureTask реалізувати таку зв’язку неможливо, оскільки FutureTask вимагає передачі Callable у момент створення і закритий для зовнішнього завершення.

CompletableFuture проти Virtual Threads (Java 21+)

З появою Virtual Threads (Project Loom) писати синхронний блокуючий код знову стало дешево:

// У Java 21+ віртуальний потік паркується без утримання потоку ОС:
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    Future<String> f1 = executor.submit(() -> fetchA());
    Future<String> f2 = executor.submit(() -> fetchB());
    String res = f1.get() + f2.get(); // Блокування віртуального потоку майже безкоштовне
}

Чому CompletableFuture залишається актуальним:

  1. Реактивні інтерфейси та інтеграція: сторонні асинхронні SDK (AWS SDK v2, Netty, gRPC, Spring WebClient) повертають CompletableFuture або Publisher.
  2. Складна комбінаторна логіка: патерни “перегонів” (anyOf), конкурентне розгалуження, кешування промісів із повторним читанням реалізуються через CompletableFuture декларативно в один рядок.

4 Tricky Questions

1. Якщо при виклику cf1.thenCombine(cf2, (a, b) -> a + b) перший ф’ючер cf1 завершується аварійно (винятком), що станеться з другим ф’ючером cf2?

Відповідь: Ф’ючер cf2 продовжить своє виконання у фоні. Результуючий ф’ючер (повернутий thenCombine) негайно перейде у стан помилки (CompletionException), але виклик thenCombine не скасовує і не перериває другий ф’ючер автоматично. Якщо cf2 виконував важку операцію або запис у базу даних, вона виконається до кінця, якщо ви вручну не налаштуєте скасування cf2 через handle/whenComplete.


2. Як уникнути “пекла колбеків” (Callback Hell) при побудові довгих ланцюжків залежних CompletableFuture?

Відповідь: Пекло колбеків у CompletableFuture виникає при глибокій вкладеності лямбд (коли кожній наступній операції потрібні результати всіх попередніх):

// ❌ Глибока вкладеність (Pyramid of Doom):
fetchUser(id).thenCompose(user -> 
    fetchAccount(user).thenCompose(account -> 
        fetchHistory(account).thenApply(history -> buildReport(user, account, history))
    )
);

Рішення:

  1. Використовувати проміжні кортежі/DTO-об’єкти або record для прокидання контексту вниз ланцюжком.
  2. Використовувати паралельне групування через thenCombine, якщо залежності незалежні.
  3. У Java 21+ переписати конвеєр на синхронний імперативний стиль усередині Virtual Threads з використанням StructuredTaskScope.

3. У чому різниця між completeExceptionally(Throwable ex) та obtrudeException(Throwable ex)?

Відповідь:

  • completeExceptionally(ex) переводить ф’ючер у помилковий стан лише якщо він ще не був завершений (перше завершення виграє за правилом CAS). Якщо ф’ючер уже мав результат, метод поверне false і нічого не змінить.
  • obtrudeException(ex) (і аналогічно obtrudeValue(v)) примусово перезаписує результат ф’ючера, навіть якщо він уже давно успішно завершився іншим значенням! Цей метод призначений виключно для сценаріїв аварійного відновлення та пост-мортем очищення після збоїв. Використання obtrude* у бізнес-логіці категорично не рекомендується, оскільки це грубо порушує контракт незмінності завершених промісів і породжує важковловимі перегони даних.

4. Що станеться, якщо ланцюжок CompletableFuture завершився винятком, але ані join(), ані get(), ані exceptionally() не були викликані?

Відповідь: Виняток залишиться абсолютно непоміченим (silent failure). Усередині об’єкта CompletableFuture в полі result запишеться об’єкт AltResult(ex). Оскільки немає підписників на помилку і ніхто не зчитує результат через блокуючі методи, виняток не потрапить ані в System.err, ані в логер, ані в UncaughtExceptionHandler потоку. Завдання завершиться тихо, а об’єкт з часом утилізується збирачем сміття. Це одна з найпідступніших пасток асинхронного програмування.


🎯 Шпаргалка для інтерв’ю

Головне за 60 секунд

  • Основні переваги CompletableFuture перед Future: неблокуючі колбеки, монадична композиція (thenApply/thenCompose), комбінування кількох завдань (allOf/anyOf/thenCombine), декларативна обробка помилок (exceptionally/handle) та можливість ручного переведення в завершений стан (complete).
  • Дозволяє масштабувати систему до сотень тисяч паралельних операцій введення-виведення без виділення індивідуального потоку ОС під кожне завдання.
  • Починаючи з Java 9, додано вбудовані неблокуючі таймаути: orTimeout() та completeOnTimeout().

Типові помилки на інтерв’ю

  • Плутати thenApply (синхронна трансформація значення, map) та thenCompose (зв’язування з поверненням нового CompletableFuture, flatMap).
  • Вважати, що allOf повертає результати всіх ф’ючерів у вигляді списку (метод повертає CompletableFuture<Void>, збір результатів потрібно виконувати вручну).
  • Забувати про проковтування винятків за відсутності термінальних методів або обробників помилок.

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