Вопрос 12 · Раздел 19

Какой пул потоков используется по умолчанию для async методов?

По умолчанию все асинхронные методы CompletableFuture (без параметра Executor) используют ForkJoinPool.commonPool().

Версии по языкам: English Russian Ukrainian

🟢 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?

  1. I/O операции: Всегда. Пул должен быть большим (50-100 потоков) или использовать Virtual Threads.
  2. Изоляция: Гарантировать, что сбой в модуле “Уведомления” не завалит модуль “Оплата”.
  3. Мониторинг: 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. Что такое блокирующий код и как его отличить от неблокирующего]]