Що робить метод allOf() і коли його використовувати
Статичний метод CompletableFuture.allOf(CompletableFuture>... cfs) використовується, коли необхідно паралельно запустити довільну кількість незалежних завдань ($N$ штук) і доч...
🟢 Junior Level
Статичний метод CompletableFuture.allOf(CompletableFuture<?>... cfs) використовується, коли необхідно паралельно запустити довільну кількість незалежних завдань ($N$ штук) і дочекатися завершення всіх переданих обчислень.
Метод повертає новий CompletableFuture<Void>, який переходить у завершений стан тільки тоді, коли всі вихідні ф’ючери закінчили свою роботу.
Базовий приклад
CompletableFuture<String> priceFuture = CompletableFuture.supplyAsync(() -> fetchPrice());
CompletableFuture<String> stockFuture = CompletableFuture.supplyAsync(() -> fetchStock());
CompletableFuture<String> reviewsFuture = CompletableFuture.supplyAsync(() -> fetchReviews());
// Об'єднуємо очікування всіх 3 завдань:
CompletableFuture<Void> allTasks = CompletableFuture.allOf(priceFuture, stockFuture, reviewsFuture);
// Спрацьовує тільки тоді, коли ВСІ 3 завдання готові:
allTasks.thenRun(() -> {
System.out.println("Усі дані успішно завантажено!");
// Виклик .join() тут безпечний і не блокує потік, оскільки всі завдання вже завершені:
String price = priceFuture.join();
String stock = stockFuture.join();
String reviews = reviewsFuture.join();
renderProductPage(price, stock, reviews);
});
[!IMPORTANT] Метод
allOf()повертаєCompletableFuture<Void>, а не масив чи колекцію результатів. Результати кожного окремого ф’ючера вилучаються викликом.join()на вихідних об’єктах.
🟡 Middle Level
Патерн безпечного збору результатів у List<T>
У реальних проєктах кількість завдань зазвичай динамічна (наприклад, обробка списку сутностей). Для збору типізованого списку результатів використовується ідіоматичний шаблон:
public <T> CompletableFuture<List<T>> fetchAll(List<Long> ids, Function<Long, CompletableFuture<T>> taskFactory) {
// 1. Створюємо список асинхронних завдань
List<CompletableFuture<T>> futures = ids.stream()
.map(taskFactory)
.toList();
// 2. Створюємо бар'єр очікування
return CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
.thenApply(ignoredVoid ->
futures.stream()
// join() тут не блокує потік: allOf гарантує, що кожен future вже isDone()
.map(CompletableFuture::join)
.toList()
);
}
Поведінка при виникненні помилок
За замовчуванням allOf має критичну особливість:
- Якщо хоча б один ф’ючер завершується з помилкою, результуючий ф’ючер
allOfтакож завершується з винятком. - При цьому
allOf.join()викинеCompletionException, причиною якого стане виняток першого ф’ючера, що зазнав невдачі. - Усі результати решти успішно виконаних завдань втрачаються, якщо ви не подбали про індивідуальну обробку помилок!
// ✅ Рішення: ізолювати збої на рівні кожного окремого ф'ючера
CompletableFuture<User> safeUserFuture = userService.getUserAsync(id)
.exceptionally(ex -> User.ANONYMOUS); // Fallback
CompletableFuture<Account> safeAccountFuture = accountService.getAccountAsync(id)
.exceptionally(ex -> Account.DEFAULT); // Fallback
// Тепер allOf гарантовано не викине виняток
CompletableFuture.allOf(safeUserFuture, safeAccountFuture)
.thenAccept(v -> showProfile(safeUserFuture.join(), safeAccountFuture.join()));
🔴 Senior Level
Внутрішня будова: бінарне дерево BiRelay
На відміну від наївних реалізацій із лічильниками (CountDownLatch), у CompletableFuture.allOf застосовано суто lock-free алгоритм без утримання потоків.
При передачі масиву з $N$ елементів метод рекурсивно будує збалансоване бінарне дерево залежностей із вузлів BiRelay:
// Спрощена концепція з java.util.concurrent.CompletableFuture:
public static CompletableFuture<Void> allOf(CompletableFuture<?>... cfs) {
return andTree(cfs, 0, cfs.length - 1);
}
static CompletableFuture<Void> andTree(CompletableFuture<?>[] cfs, int lo, int hi) {
CompletableFuture<Void> d = new CompletableFuture<>();
if (lo > hi) {
d.result = NIL;
} else {
int mid = (lo + hi) >>> 1;
CompletableFuture<?> a = (lo == mid ? cfs[lo] : andTree(cfs, lo, mid));
CompletableFuture<?> b = (lo == hi ? a : (hi == mid + 1 ? cfs[hi] : andTree(cfs, mid + 1, hi)));
// Зв'язуємо вершини через внутрішній вузол BiRelay
...
}
return d;
}
- Глибина дерева залежностей становить $O(\log N)$.
- При завершенні будь-якого листка сигнал про завершення поширюється вгору деревом.
- Потік, що завершив найостанніший дочірній вузол у дереві, і переводить кореневий ф’ючер у стан готовності.
Фатальний нюанс: Чи чекає allOf завершення всіх завдань при падінні одного?
Існує поширений міф, що allOf працює за принципом fail-fast: «щойно один упав, allOf негайно завершується помилкою».
У стандартній реалізації JDK це НЕ ТАК!
Дерево andTree чекає на фізичне завершення (успішне чи аварійне) абсолютно всіх підзавдань! Кореневий вузол зафіксує помилку, але він не буде позначений як завершений доти, доки решта $N - 1$ завдань не закінчать виконання у своїх потоках.
Якщо одне з паралельних завдань зависло намертво без таймауту, результуючий allOf зависне назавжди, навіть якщо інше завдання вже впало з фатальним винятком.
4 Tricky Questions
1. Що станеться, якщо передати в CompletableFuture.allOf() порожній масив або взагалі 0 аргументів?
Відповідь:
Метод завершиться негайно та коректно, повернувши екземпляр CompletableFuture<Void>, вже завершений зі значенням null (completedFuture(null)).
Жодних винятків (IllegalArgumentException чи IndexOutOfBoundsException) не викидається. Це робить метод абсолютно безпечним для динамічних колекцій нульового розміру: allOf(futures.toArray(new CompletableFuture[0])) поверне готовий ф’ючер.
2. Якщо з 10 переданих в allOf ф’ючерів 3 впали з різними винятками, як витягти всі три помилки, а не тільки першу?
Відповідь:
Метод allOf.join() або allOf.exceptionally() покаже лише перший за часом виняток. Інші два залишаться недоступними через корінь.
Щоб зібрати всі помилки, необхідно після завершення allOf (через .whenComplete або handle) вручну пройтися вихідною колекцією ф’ючерів:
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
.whenComplete((v, overallEx) -> {
List<Throwable> allErrors = futures.stream()
.filter(CompletableFuture::isCompletedExceptionally)
.map(f -> {
try { f.join(); return null; }
catch (CompletionException ce) { return ce.getCause(); }
})
.filter(Objects::nonNull)
.toList();
log.error("Усього помилок у пачці: {}", allErrors.size());
});
3. Чому всередині .thenApply(...), навішаного на allOf, виклик .join() на дочірніх ф’ючерах не вважається поганою практикою (code smell)?
Відповідь:
Виклик .join() вважається антипатерном у реактивному/асинхронному програмуванні, коли він викликається на незавершеному ф’ючері, блокуючи потік виконання.
Проте всередині колбека, зареєстрованого на allOf, метод join() викликається в момент, коли allOf вже перейшов у завершений стан. Це гарантує, що кожен дочірній ф’ючер вже має isDone() == true. У цьому випадку .join() не переводить потік у режим очікування (не паркує тред), а миттєво зчитує значення з volatile Object result за 1–2 наносекунди.
4. Припустимо, ми викликали allDone = CompletableFuture.allOf(f1, f2) і потім allDone.cancel(true). Чи скасуються вихідні завдання f1 та f2?
Відповідь:
Ні, завдання f1 та f2 не скасуються!
У CompletableFuture скасування ніколи не поширюється вгору деревом залежностей (upstream). Виклик allDone.cancel(true) лише переведе сам об’єкт allDone у статус CancellationException. Дочірні завдання f1 та f2 продовжать виконуватися у своїх пулах до завершення. Для каскадного скасування розробник зобов’язаний явно викликати cancel() на кожному дочірньому ф’ючері.
🎯 Шпаргалка для інтерв’ю
Головне за 60 секунд
CompletableFuture.allOf()очікує на завершення всіх переданих завдань ($N$ штук).- Повертає
CompletableFuture<Void>. Результати вихідних завдань вилучаються індивідуально черезcf.join(). - Загальна тривалість виконання дорівнює часу найповільнішого завдання: $\max(t_1, t_2, \dots, t_N)$.
- Під капотом будує бінарне дерево
BiRelayглибиною $O(\log N)$. - Важливий нюанс:
allOfне скасовує дочірні завдання при скасуванні самогоallOfі чекає на завершення всіх завдань навіть при падінні одного з них.
Типові помилки на інтерв’ю
- Вважати, що
allOfповертає масив або список значень (CompletableFuture<List<T>>). - Думати, що при помилці одного завдання
allOfнегайно перериває виконання решти. - Забувати загортати виклики в таймаути (
orTimeout), через що зависання одного завдання підвішує весьallOf.