Чому важливо уникати блокуючих операцій в CompletableFuture
CompletableFuture зазвичай виконується у ForkJoinPool.commonPool(), який має дуже мало потоків (зазвичай = число ядер CPU - 1).
🟢 Junior Level
CompletableFuture зазвичай виконується у ForkJoinPool.commonPool(), який має дуже мало потоків (зазвичай = число ядер CPU - 1).
Якщо один із потоків заблокується, інші задачі не зможуть виконуватися — це називається thread pool starvation.
// ❌ Небезпечно — блокує потік ForkJoinPool
CompletableFuture.supplyAsync(() -> {
return httpClient.get(url); // блокуючий I/O
});
// ✅ Безпечно — свій Executor для I/O
ExecutorService ioExecutor = Executors.newFixedThreadPool(20);
CompletableFuture.supplyAsync(() -> {
return httpClient.get(url);
}, ioExecutor);
Проста аналогія:
- ForkJoinPool — як 4 каси в магазині
- Якщо одна касирка засне — черга не рухається
- Потрібно не давати касирам спати!
🟡 Middle Level
Thread pool starvation
// commonPool має 7 потоків (на 8-ядерному CPU)
// Якщо 7 задач заблокуються — всі нові задачі чекають
for (int i = 0; i < 100; i++) {
CompletableFuture.supplyAsync(() -> {
Thread.sleep(5000); // блокує потік
return "done";
});
}
// Тільки перші 7 виконуються
// Решта 93 чекають звільнення потоку
Наслідки
- Deadlock:
```java
CompletableFuture
cf1 = CompletableFuture.supplyAsync(() -> { return cf2.join(); // чекає cf2 });
CompletableFuture
2. **Performance degradation:**
```java
// Всі потоки зайняті блокуючими операціями
// Latency росте, throughput падає
Типові помилки
- Blocking у thenApply: ```java // ❌ thenApply виконується у ForkJoinPool cf.thenApply(s -> { return httpClient.sendBlocking(url); // блокує! });
// ✅ thenApplyAsync з IO Executor cf.thenApplyAsync(s -> httpClient.sendBlocking(url), ioExecutor);
---
## 🔴 Senior Level
### Internal Implementation
**ForkJoinPool.commonPool():**
```java
// Розмір = Runtime.getRuntime().availableProcessors() - 1
// Для 8 ядер = 7 потоків
// Для CPU-bound задач — ідеально
// За замовчуванням розмір пула = availableProcessors() - 1 (можна змінити через
// `java.util.concurrent.ForkJoinPool.common.parallelism`). Для CPU-bound задач
// підходить добре, для I/O — може стати вузьким місцем при великій кількості
// блокуючих операцій.
Work-stealing:
// ForkJoinPool використовує work-stealing
// Але якщо всі потоки blocked — stealing не допомагає
// Thread 1: [BLOCKED on I/O]
// Thread 2: [BLOCKED on I/O]
// Thread 3: [BLOCKED on I/O]
// ...
// Queue: [waiting tasks...] // ніхто не обробляє
Архітектурні Trade-offs
| Підхід | Плюси | Мінуси |
|---|---|---|
| commonPool | Не потрібно створювати | Тільки CPU-bound |
| Свій Executor | Контроль | Потрібно керувати |
| Virtual Threads | Best of both | Java 21+ |
Edge Cases
1. Cascading blocking:
// Один blocking виклик тягне інші
cf1.thenApply(s -> blockingCall1(s))
.thenCompose(r -> blockingCall2(r))
.thenAccept(result -> blockingCall3(result));
// Один CF = 3 blocking операції = 3x час блокування
2. Mixed workload:
// CPU-bound + I/O в одному пулі
CompletableFuture.supplyAsync(() -> heavyCalculation()); // CPU
CompletableFuture.supplyAsync(() -> httpClient.get(url)); // I/O
// I/O блокує потік — CPU задачі чекають
Продуктивність
Thread pool starvation ефект:
- У гіршому випадку, коли пул повністю насичений задачами, одна блокуюча задача
знижує throughput приблизно на 1/N, де N — розмір пула. Фактична втрата
залежить від характеру задач і роботи work-stealing.
- 7 blocking tasks = 100% втрата (повний deadlock)
Virtual Threads рішення:
- 100000+ блокуючих задач = без проблем
- Кожен blocking — suspend, не блокує OS thread
Production Experience
Detecting starvation:
# Thread dump
jstack <pid> | grep -A 10 "ForkJoinPool"
# Метрики
- Active threads < pool size
- Queue size росте
- Latency росте
Prevention:
// ✅ Розділення пулів
ExecutorService cpuExecutor = Executors.newFixedThreadPool(
Runtime.getRuntime().availableProcessors());
ExecutorService ioExecutor = Executors.newFixedThreadPool(50);
// CPU задачі
CompletableFuture.supplyAsync(cpuTask, cpuExecutor);
// I/O задачі
CompletableFuture.supplyAsync(ioTask, ioExecutor);
// ✅ Virtual Threads (Java 21+)
ExecutorService vThreads = Executors.newVirtualThreadPerTaskExecutor();
CompletableFuture.supplyAsync(anyTask, vThreads);
Best Practices
// ✅ Свій Executor для I/O
CompletableFuture.supplyAsync(ioTask, ioExecutor);
// ✅ Virtual Threads
Executors.newVirtualThreadPerTaskExecutor();
// ✅ Моніторинг thread pool
metrics.recordQueueSize(executor.getQueue().size());
// ❌ Blocking у commonPool
// ❌ join()/get() без таймауту
// ❌ Ігнорування thread starvation
🎯 Шпаргалка для співбесіди
Обов’язково знати:
- ForkJoinPool.commonPool() має мало потоків (availableProcessors - 1)
- Thread pool starvation: всі потоки заблоковані, нові задачі чекають
- Blocking у commonPool знижує throughput для УСІХ задач (Parallel Streams, інші CF)
- Cascading blocking: один blocking виклик тягне інші в ланцюжку
- Рішення: свій Executor для I/O, Virtual Threads (Java 21+), розділення пулів
Часті уточнюючі питання:
- Що буде якщо 7 задач заблокують commonPool? — Всі нові задачі чекають, повний deadlock
- Work-stealing допомагає при blocking? — Ні, якщо всі потоки blocked — stealing не працює
- Як detect starvation? — jstack: ForkJoinPool-worker-X у BLOCKED/WAITING, queue size росте
- Virtual Threads вирішують проблему? — Так, 100000+ blocking задач без problems (Java 21+)
Червоні прапорці (НЕ говорити):
- «Blocking у thenApply нормально — він же легкий» — thenApply в тому ж потоці, blocking вбиває пул
- «commonPool масштабується автоматично» — обмежений availableProcessors - 1
- «join() без таймауту в production — ок» — нескінченне очікування, cascading failure
Пов’язані теми:
- [[14. Що таке блокуючий код і як його відрізнити від неблокуючого]]
- [[12. Який пул потоків використовується за замовчуванням для async методів]]
- [[13. Як вказати свій Executor для CompletableFuture]]
- [[16. Що робить метод supplyAsync() і коли його використовувати]]