⏱️ Раздел 19 · Вопрос #19

Можно ли повторно использовать один CompletableFuture в нескольких цепочках

В высоконагруженных системах повторное использование CompletableFuture спасает от эффекта "набегающего стада" (Thundering Herd / Cache Stampede), когда тысячи одновременных запр...


🟢 Junior Level

Да, абсолютно можно. Один и тот же экземпляр CompletableFuture может выступать источником данных для сколь угодно большого числа независимых цепочек и подписчиков. В архитектуре этот паттерн называется Fan-out (один производитель — много потребителей).

// Один асинхронный источник данных:
CompletableFuture<User> userFuture = userService.fetchUserAsync(userId);

// Ветка 1: отправка приветственного письма
userFuture.thenAccept(user -> emailService.sendWelcome(user.getEmail()));

// Ветка 2: инициализация персонального кэша
userFuture.thenAccept(user -> userCache.put(user.getId(), user));

// Ветка 3: сбор метрик аналитики
userFuture.thenRun(() -> metrics.increment("users.loaded"));

Ключевые свойства Fan-out:

  1. Однократное вычисление: фоновая операция вычисляется ровно один раз.
  2. Идемпотентность: все зарегистрированные ветки получат ссылку на один и тот же результат.
  3. Любой момент подписки: можно навесить новый колбэк (thenApply, thenAccept) как до завершения фьючера, так и спустя долгое время после того, как он уже завершился (в этом случае колбэк выполнится немедленно).

🟡 Middle Level

Паттерн “Request Collapsing” (Схлопывание дублирующих запросов)

В высоконагруженных системах повторное использование CompletableFuture спасает от эффекта “набегающего стада” (Thundering Herd / Cache Stampede), когда тысячи одновременных запросов за одним популярным ключом бьют в базу данных:

@Service
public class ProductService {
    // Хранилище запросов, находящихся в процессе исполнения (In-Flight Requests)
    private final ConcurrentMap<String, CompletableFuture<Product>> inFlight = new ConcurrentHashMap<>();

    public CompletableFuture<Product> getProduct(String sku) {
        return inFlight.computeIfAbsent(sku, key -> {
            // Первый поток инициирует реальный поход в базу данных:
            return databaseClient.fetchProductAsync(key)
                // По завершении (успех или ошибка) удаляем фьючер из карты активных:
                .whenComplete((res, ex) -> inFlight.remove(key));
        });
    }
}

Эффект: Если 100 HTTP-запросов одновременно запросят товар sku_123, выполнится ровно 1 запрос в базу данных. Все 100 запросов подпишутся на один и тот же CompletableFuture и одновременно получат ответ, как только он придёт.

Опасность: Мутация общего состояния (Data Corruption)

Все ветки получают ссылку на один и тот же объект в памяти:

CompletableFuture<Order> orderFuture = fetchOrderAsync(id);

// Ветка А (модифицирует объект!):
orderFuture.thenAccept(order -> order.getDiscount().apply(0.5));

// Ветка Б (параллельно читает исходный дисконт):
orderFuture.thenAccept(order -> billingService.calculate(order.getDiscount())); 
// ❌ ГОНКА ДАННЫХ: Ветка Б может увидеть непредсказуемое мутированное значение!

[!CAUTION] Результат CompletableFuture, используемый в нескольких ветках, обязан быть иммутабельным (например, Java record, Unmodifiable List). Если объекту требуется модификация, ветка должна создавать защитную копию (Defensive Copy).


🔴 Senior Level

Порядок выполнения подписчиков: LIFO в Treiber Stack

Когда на один незавершённый CompletableFuture регистрируется несколько синхронных колбэков:

cf.thenRun(() -> System.out.println("First registered"));
cf.thenRun(() -> System.out.println("Second registered"));
cf.thenRun(() -> System.out.println("Third registered"));

Под капотом они добавляются в голову односвязного неблокирующего стека зависимостей (stack). При завершении cf.complete() метод postComplete() распаковывает стек. В текущей реализации JDK колбэки исполнятся в обратном порядке (LIFO): "Third registered" $\rightarrow$ "Second registered" $\rightarrow$ "First registered".

[!WARNING] Никогда не закладывайте бизнес-логику на порядок вызова параллельных подписчиков! Спецификация CompletionStage не гарантирует детерминированного порядка исполнения. Если порядок критичен, используйте явную последовательную композицию через thenCompose().

Опасность утечек памяти (Memory Leak) при долгоживущем корне

Если создать глобальный долгоживущий CompletableFuture (например, событие жизненного цикла приложения или шина сообщений) и динамически навешивать на него слушателей:

  • Каждый вызов thenApply() / thenAccept() создаёт узел Completion в стеке stack родительского фьючера.
  • Родительский фьючерс удерживает прямые ссылки на эти узлы.
  • Если корневой фьючерс никогда не завершается, в куче (heap) образуется утечка памяти: миллионы узлов Completion и захваченных ими лямбдами объектов не могут быть собраны сборщиком мусора (GC Root retention).

4 Tricky Questions

1. В каком порядке выполнятся колбэки, зарегистрированные на один и тот же CompletableFuture до его завершения?

Ответ: В стандартной реализации JDK они выполнятся в порядке LIFO (Last-In, First-Out) — то есть последний зарегистрированный колбэк выполнится первым. Это происходит потому, что подписчики добавляются в голову неблокирующего стека Трейбера (volatile Completion stack) через CAS-операцию. При завершении задачи метод postComplete() снимает элементы с вершины стека. Однако спецификация интерфейса CompletionStage намеренно не фиксирует этот порядок как обязательный стандарт, поэтому полагаться на него в бизнес-логике категорически запрещено.

2. Если одна из веток-потребителей завершится аварийно или выбросит исключение, повлияет ли это на другие ветки, подписанные на тот же CompletableFuture?

Ответ: Нет, абсолютно не повлияет. Каждый вызов метода трансформации (thenApply, thenAccept) порождает новый, независимый экземпляр CompletableFuture. Если ветка А выбросит исключение, в состояние ошибки перейдёт только её собственный дочерний фьючерс. Исходный родительский фьючерс и параллельная ветка Б останутся успешными и изолированными.

3. Что произойдёт, если отменить одну из дочерних веток через childBranch.cancel(true)?

Ответ: Отменится только эта конкретная дочерняя ветка. Родительский CompletableFuture продолжит выполняться в своём потоке, а все остальные дочерние ветки успешно получат результат родителя. В CompletableFuture отмена не имеет обратной связи вверх по графу (Upstream isolation).

4. Допустим, мы использовали паттерн “Request Collapsing” с ConcurrentHashMap<Key, CompletableFuture<Data>>. Что произойдет, если фоновый запрос в базу данных упадёт с ошибкой, а мы забыли удалить ключ в блоке whenComplete?

Ответ: Произойдёт отравление кэша (Cache Poisoning). В карте inFlight навсегда останется ссылка на экземпляр CompletableFuture, находящийся в состоянии AltResult(CompletionException). Все последующие запросы с этим ключом будут мгновенно получать этот упавший фьючерс и сразу падать с ошибкой без попытки повторного обращения к базе данных, пока приложение не будет перезагружено. Именно поэтому очистка в whenComplete((res, ex) -> inFlight.remove(key)) является критически обязательной.


🎯 Шпаргалка для интервью

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

  • Один CompletableFuture можно безопасно использовать в нескольких цепочках (Fan-out).
  • Вычисление выполняется ровно один раз, все подписчики получают одинаковый результат.
  • Идеально для паттерна Request Collapsing / Single-Flight (предотвращение дублирующих запросов в БД).
  • Критическое требование: результат должен быть иммутабельным во избежание гонок данных при модификации из разных веток.
  • Порядок вызова параллельных колбэков в JDK — LIFO, но спецификацией не гарантируется.
  • Сбой или отмена в одной ветке полностью изолированы и не затрагивают другие ветки.

Типичные ошибки на интервью

  • Полагать, что каждый подписчик запускает выполнение supplyAsync заново (задача выполняется ровно один раз).
  • Мутировать объект результата внутри одного из колбэков, ломая данные в параллельных цепочках.
  • Считать, что отмена одной ветки отменяет родительский фьючерс.

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