Вопрос 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]]