Какой пул потоков используется по умолчанию для 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. Что такое блокирующий код и как его отличить от неблокирующего]]