Питання 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. Що таке блокуючий код і як його відрізнити від неблокуючого]]