Чи можна повторно використовувати один 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:
- Однократне обчислення: фонова операція обчислюється рівно один раз.
- Ідемпотентність: усі зареєстровані гілки отримають посилання на один і той самий результат.
- Будь-який момент підписки: можна навісити новий колбек (
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, що використовується в кількох гілках, зобов’язаний бути незмінним (immutable) (наприклад, Javarecord, 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 (запобігання дублюючим запитам у БД).
- Критична вимога: результат має бути незмінним (immutable) для уникнення стану гонки при модифікації з різних гілок.
- Порядок виклику паралельних колбеків у JDK — LIFO, але специфікацією не гарантується.
- Збій або скасування в одній гілці повністю ізольовані і не зачіпають інші гілки.
Типові помилки на інтерв’ю
- Вважати, що кожен підписник запускає виконання
supplyAsyncзаново (завдання виконується рівно один раз). - Мутувати об’єкт результату всередині одного з колбеків, ламаючи дані в паралельних ланцюжках.
- Думати, що скасування однієї гілки скасовує батьківський ф’ючер.