Питання 20 · Розділ 19

Чи можна повторно використовувати один CompletableFuture в декількох ланцюжках?

Зберігайте CompletableFuture в кеш замість самого значення:

Мовні версії: English Russian Ukrainian

🟢 Junior Level

Так, можна! Один CompletableFuture можна використовувати в декількох ланцюжках. Це називається fan-out — один результат, багато споживачів.

CompletableFuture<User> userCf = userService.fetchAsync(id);

// Три незалежні ланцюжки, один загальний CF
userCf.thenAccept(user -> loadOrders(user));
userCf.thenAccept(user -> checkBalance(user));
userCf.thenRun(() -> updateLastLogin());

Ключові моменти:

  • Результат обчислюється рівно один раз
  • Усі підписники отримують один і той самий об’єкт
  • Якщо CF вже завершений — новий колбек виконається миттєво

🟡 Middle Level

Патерн: Promise Caching

Зберігайте CompletableFuture в кеш замість самого значення:

private final Map<Long, CompletableFuture<User>> cache = new ConcurrentHashMap<>();

public CompletableFuture<User> getUserAsync(Long id) {
    return cache.computeIfAbsent(id, userId ->
        userService.fetchAsync(userId)
            .whenComplete((result, ex) -> {
                // Видаляємо з кешу після завершення (успіх або помилка)
                cache.remove(userId);
            })
    );
}

Перевага: Якщо 10 потоків запросять одні й ті самі дані одночасно, всі підпишуться на один CF. Тільки перший ініціює запит в БД, решта дочекаються результату.

Небезпека: мутація загального результату

Усі ланцюжки отримують посилання на один і той самий об’єкт.

CompletableFuture<User> userCf = userService.fetchAsync(id);

// Ланцюжок 1 — мутує об'єкт!
userCf.thenAccept(user -> user.setName("Modified"));

// Ланцюжок 2 — бачить змінені дані!
userCf.thenAccept(user -> System.out.println(user.getName())); // "Modified"

Правило: Результат, що передається в декілька ланцюжків, має бути імутабельним.

Порядок виконання колбеків

JDK не гарантує порядок виконання колбеків. У поточній реалізації (JDK 8-21) порядок залежить від внутрішнього устрою. Якщо порядок критичний — явно задавайте через thenCompose або thenCombine.

// ❌ Порядок не гарантований
cf.thenAccept(a -> step1(a));
cf.thenAccept(a -> step2(a));  // не обов'язково після step1

// ✅ Явний порядок
cf.thenCompose(a -> step1Async(a).thenAccept(r -> step2(r)));

🔴 Senior Level

Memory Leak механізм

Кожен залежний CF (результат thenApply, thenAccept і т.д.) зберігає посилання на свій upstream. Поки залежна цепочка не завершена і на неї є посилання, GC не може зібрати весь граф. У довгоживучих додатках це може призвести до витоку, якщо створювати нескінченні ланцюжки без завершення.

// Проблема: нескінченне нарощування ланцюжків
CompletableFuture<Void> cf = CompletableFuture.completedFuture(null);
for (int i = 0; i < 1_000_000; i++) {
    cf = cf.thenAccept(v -> doSomething());
    // Кожен новий CF зберігає посилання на попередній
    // Поки жив останній — GC не може зібрати весь граф
}

Cancellation propagation

Якщо скасувати кореневий CF, всі залежні ланцюжки завершаться з CancellationException. Відміна дочірньої цепочки не впливає на кореневий CF.

CompletableFuture<String> root = CompletableFuture.supplyAsync(() -> "data");
CompletableFuture<String> child1 = root.thenApply(s -> s + " -> 1");
CompletableFuture<String> child2 = root.thenApply(s -> s + " -> 2");

child1.cancel(false);
// root і child2 продовжують працювати

Архітектурний патерн: Request Collapsing

У highload-системах один CF використовується для запобігання дублюючим запитам:

public CompletableFuture<Data> getData(String key) {
    return inFlight.computeIfAbsent(key, k ->
        loadDataFromDb(k)
            .whenComplete((v, ex) -> inFlight.remove(k))
    );
}
// 100 одночасних запитів одного key → 1 реальний запит в БД

Best Practices

// ✅ Один CF → багато підписників (fan-out)
// ✅ Імутабельний результат
// ✅ Очищення кешу після завершення (whenComplete)
// ✅ Явний порядок через thenCompose/thenCombine

// ❌ Мутація загального результату
// ❌ Нескінченні ланцюжки без завершення
// ❌ Залежність від порядку колбеків
// ❌ Забуті посилання в кеші на завершені CF

🎯 Шпаргалка для співбесіди

Обов’язково знати:

  • Один CF можна використовувати в декількох ланцюжках — fan-out патерн
  • Результат обчислюється рівно один раз, усі підписники отримують один об’єкт
  • Promise Caching: Map<Key, CompletableFuture> для запобігання дублюючим запитам
  • Request Collapsing: 100 запитів одного key → 1 реальний запит в БД
  • Порядок колбеків НЕ гарантується JDK

Часті уточнюючі питання:

  • Порядок колбеків гарантований? — Ні. Якщо критичний — явно через thenCompose/thenCombine
  • Memory leak як виникає? — Нескінченні ланцюжки: кожен CF зберігає посилання на upstream, GC не збирає
  • Відміна дочірньої цепочки впливає на кореневий? — Ні. Відміна кореневого — впливає на всі дочірні
  • Мутація загального результату — проблема? — Так, усі ланцюжки бачать один об’єкт. Результат має бути імутабельним

Червоні прапорці (НЕ говорити):

  • «Кожен підписник отримує копію результату» — всі отримують один і той самий об’єкт
  • «Порядок thenAccept гарантований» — JDK не гарантує порядок
  • «Кеш на CF не потрібен — CF сам очиститься» — завершені CF потрібно видаляти з кешу

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

  • [[16. Як правильно виконати декілька паралельних запитів до мікросервісів]]
  • [[8. Як комбінувати результати декількох CompletableFuture]]
  • [[17. Як скасувати виконання CompletableFuture]]
  • [[23. Як тестувати код з CompletableFuture]]