Який пул потоків використовується за замовчуванням для async методів?
За замовчуванням усі асинхронні методи CompletableFuture (без параметра Executor) використовують ForkJoinPool.commonPool().
🟢 Junior Level
За замовчуванням усі асинхронні методи CompletableFuture (без параметра Executor) використовують ForkJoinPool.commonPool().
// Використовує ForkJoinPool.commonPool()
CompletableFuture<String> cf = CompletableFuture.supplyAsync(() -> {
return "Hello";
});
// Теж commonPool
cf.thenApplyAsync(s -> s.toUpperCase());
Характеристики commonPool:
- Паралелізм:
Runtime.getRuntime().availableProcessors() - 1 - Алгоритм: Work-Stealing (воркери “крадуть” задачі один в одного)
- Тип потоків: Daemon-потоки (не заважають завершенню JVM)
🟡 Middle Level
Проблема “Загальної кухні”
commonPool використовується усією JVM:
- Parallel Streams
CompletableFuture- Деякі бібліотеки (Selenium, внутрішні механізми Spring)
Усі вони б’ються за одні й ті ж потоки. Якщо одна частина додатку запустить важкий стрим, асинхронні відповіді в іншому модулі почнуть гальмувати.
Блокування (Starvation)
commonPool спроектований для CPU-bound задач.
// ❌ Блокуючий I/O в commonPool
CompletableFuture.supplyAsync(() -> {
return httpClient.get(url); // блокує потік із загального пулу!
});
Коли всі потоки (зазвичай 7 на 8-ядерному CPU) зайняті очікуванням мережі, додаток перестане виконувати будь-які асинхронні задачі — Thread Starvation.
Малоядерні системи
При 1 ядрі CPU ForkJoinPool.commonPool() все одно використовується, але з parallelism = 1. CompletableFuture НЕ перемикається на thread-per-task. Розмір пула можна змінити через:
System.setProperty("java.util.concurrent.ForkJoinPool.common.parallelism", "N");
Коли використовувати свій Executor?
- I/O операції: Завжди. Пул має бути більшим (50-100 потоків) або використовувати Virtual Threads.
- Ізоляція: Гарантувати, що збій у модулі “Сповіщення” не завалить модуль “Оплата”.
- Моніторинг:
commonPoolскладно моніторити. КастомнийThreadPoolExecutorдозволяє бачити розмір черги і кількість відхилених задач.
Діагностика
# Змінити розмір пулу через JVM флаг
-Djava.util.concurrent.ForkJoinPool.common.parallelism=N
# Програмно дізнатися розмір
ForkJoinPool.getCommonPoolParallelism()
# Потоки називаються ForkJoinPool.commonPool-worker-X
# Якщо бачите WAITING на мережевих сокетах — архітектурна проблема
jstack <pid>
🔴 Senior Level
Internal Implementation
Work-Stealing алгоритм:
- LIFO для локальних задач: ForkJoin воркери обробляють свої задачі в порядку LIFO. Це зберігає “гарячі” дані в кеші процесора (L1/L2).
- Stealing у порядку FIFO: “Крадучі” потоки забирають задачі з хвоста черги, мінімізуючи конкуренцію.
Архітектурні Trade-offs
| Підхід | Плюси | Мінуси |
|---|---|---|
| commonPool | Не потрібно налаштовувати | Загальний ресурс, складно моніторити |
| FixedThreadPool | Передбачуваний розмір | Немає work-stealing |
| Virtual Threads (Java 21+) | Ідеальний для I/O | Тільки Java 21+ |
Production Strategy
Розділення пулів за типом навантаження:
// I/O-Bound (HTTP, БД, Файли)
Executor ioExecutor = new ThreadPoolExecutor(
10, 100, 60L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(1000),
new ThreadFactoryBuilder().setNameFormat("io-pool-%d").build(),
new ThreadPoolExecutor.CallerRunsPolicy() // Backpressure
);
// CPU-Bound (Розрахунки, Маппінг)
Executor cpuExecutor = Executors.newFixedThreadPool(
Runtime.getRuntime().availableProcessors()
);
// Virtual Threads (Java 21+) — для I/O
Executor vtExecutor = Executors.newVirtualThreadPerTaskExecutor();
Ізоляція на кожному етапі ланцюжка:
// ❌ Тільки перший етап у своєму пулі, решта — в commonPool
CF.supplyAsync(task, myExecutor)
.thenApplyAsync(transform); // commonPool!
// ✅ Ізоляція зберігається
CF.supplyAsync(task, myExecutor)
.thenApplyAsync(transform, myExecutor);
Lifecycle Management
@PreDestroy
public void shutdown() {
((ExecutorService) ioExecutor).shutdown();
((ExecutorService) cpuExecutor).shutdown();
}
Без shutdown() — “зомбі-потоки”, що заважають коректному завершенню процесу.
Best Practices
// ✅ Завжди свій Executor для production
CompletableFuture.supplyAsync(task, ioExecutor);
// ✅ CallerRunsPolicy для backpressure
new ThreadPoolExecutor.CallerRunsPolicy();
// ✅ Thread naming для діагностики
new ThreadFactoryBuilder().setNameFormat("pool-%d").build();
// ❌ commonPool для I/O
// ❌ Блокування в commonPool
// ❌ Без shutdown() при виході
Резюме для Senior
- Пул за замовчуванням — ForkJoinPool.commonPool().
- Він тільки для швидких обчислень.
- Блокування в commonPool знижують throughput для всіх задач, що використовують цей пул. Це одна з найчастіших причин деградації продуктивності.
- У серйозних проєктах завжди впроваджуйте свої
Executorsчерез Spring@Bean.
🎯 Шпаргалка для співбесіди
Обов’язково знати:
- За замовчуванням — ForkJoinPool.commonPool() (availableProcessors - 1)
- Work-Stealing алгоритм: LIFO для локальних, FIFO для stealing
- commonPool тільки для CPU-bound задач, для I/O — свій Executor
- Усі задачі JVM (Parallel Streams, інші CF) ділять один commonPool
- Розмір пула: -Djava.util.concurrent.ForkJoinPool.common.parallelism=N
Часті уточнюючі питання:
- Чому commonPool поганий для I/O? — Мало потоків (N-1), блокування → thread pool starvation
- Work-Stealing як працює? — Воркери обробляють свої задачі (LIFO), «крадуть» з хвоста чужих черг (FIFO)
- Як діагностувати starvation? — jstack: ForkJoinPool-worker-X в WAITING на мережевих сокетах
- Що при 1 ядрі CPU? — commonPool з parallelism = 1, НЕ перемикається на thread-per-task
Червоні прапорці (НЕ говорити):
- «commonPool підходить для HTTP запитів» — blocking I/O → thread starvation
- «commonPool нескінченно масштабується» — обмежений availableProcessors - 1
- «Daemon потоки заважають завершенню JVM» — daemon НЕ заважають, але без shutdown() — zombie threads
Пов’язані теми:
- [[13. Як вказати свій Executor для CompletableFuture]]
- [[15. Чому важливо уникати блокуючих операцій в CompletableFuture]]
- [[11. У чому різниця між thenApply() і thenApplyAsync()]]
- [[14. Що таке блокуючий код і як його відрізнити від неблокуючого]]