⏱️ Розділ 19 · Питання #12

Який пул потоків використовується за замовчуванням для async методів

За замовчуванням усі асинхронні методи CompletableFuture без явної передачі екзекутора (supplyAsync(supplier), runAsync(runnable), thenApplyAsync(fn) тощо) використовують спільн...


🟢 Junior Level

За замовчуванням усі асинхронні методи CompletableFuture без явної передачі екзекутора (supplyAsync(supplier), runAsync(runnable), thenApplyAsync(fn) тощо) використовують спільний пул потоків JVM — ForkJoinPool.commonPool().

// Виконується в потоці ForkJoinPool.commonPool-worker-X:
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
    System.out.println("Потік: " + Thread.currentThread().getName());
    return "Дані";
});

Ключові властивості ForkJoinPool.commonPool()

  1. Розмір пулу (Паралелізм): за замовчуванням дорівнює кількості доступних логічних ядер процесора мінус 1: \(\text{Parallelism} = \max(1, \text{Runtime.getRuntime().availableProcessors()} - 1)\) На 8-ядерному процесорі пул міститиме 7 робочих потоків.
  2. Тип потоків: потоки спільного пулу є демонами (Daemon threads). Це означає, що якщо потік main завершить роботу і в JVM не залишиться інших не-демон потоків, застосунок завершиться негайно, навіть якщо завдання в commonPool ще не добігли кінця.
  3. Алгоритм Work-Stealing: кожен воркер має свою двосторонню чергу (Deque). Свої підзавдання воркер обробляє за принципом LIFO (для кеш-локальності), а якщо звільняється — «краде» чужі завдання з хвоста черги іншого воркера за принципом FIFO.

🟡 Middle Level

Архітектурна проблема: «Спільна комунальна квартира» (Noisy Neighbor)

ForkJoinPool.commonPool() — це глобальний синглтон на рівні всієї віртуальної машини Java. У ньому виконуються:

  • Асинхронні завдання CompletableFuture.*Async() (без кастомного пулу).
  • Паралельні стріми collection.parallelStream().
  • Внутрішні механізми багатьох бібліотек.

Якщо один модуль системи запустить довгий ресурсомісткий паралельний стрім або заблокує кілька потоків мережевим викликом, усі інші асинхронні операції у всьому застосунку почнуть зависати в черзі.

Блокуюче введення-виведення (Thread Pool Starvation)

Пул ForkJoinPool спочатку спроєктований виключно для швидких обчислювальних CPU-bound завдань, що не блокують потоки:

// ❌ КАТАСТРОФА ДЛЯ ПРОДУКТИВНОСТІ:
CompletableFuture.supplyAsync(() -> {
    // Блокуючий HTTP-виклик із затримкою 500 мс
    return restTemplate.getForObject("https://api.external.com/rates", Rates.class);
});

Якщо у вас 4 ядра CPU, в commonPool усього 3 потоки. Достатньо чотирьом користувачам одночасно зробити запит до сервісу, як усі потоки пулу стануть у блокування I/O wait. Уся асинхронна обробка в JVM буде повністю паралізована (Thread Starvation).

Керування розміром commonPool

Паралелізм пулу можна налаштувати через прапорець запуску JVM:

-Djava.util.concurrent.ForkJoinPool.common.parallelism=16

Програмно дізнатися поточний паралелізм можна через:

int parallelism = ForkJoinPool.getCommonPoolParallelism();

🔴 Senior Level

Прихований механізм: Fallback на ThreadPerTaskExecutor

Більшість розробників не знає, що CompletableFuture використовує ForkJoinPool.commonPool() не завжди! У вихідному коді CompletableFuture міститься наступна перевірка:

private static final boolean USE_COMMON_POOL = 
    (ForkJoinPool.getCommonPoolParallelism() > 1);

private static final Executor ASYNC_POOL = USE_COMMON_POOL ?
    ForkJoinPool.commonPool() : new ThreadPerTaskExecutor();

private static final class ThreadPerTaskExecutor implements Executor {
    public void execute(Runnable r) { new Thread(r).start(); }
}

[!CAUTION] Якщо застосунок запускається в контейнері (Docker/K8s) з обмеженням в 1 ядро (resources.limits.cpu <= 1), або примусово задано прапорець -Djava.util.concurrent.ForkJoinPool.common.parallelism=0 (чи 1), умова getCommonPoolParallelism() > 1 повертає false! У цьому випадку CompletableFuture активує внутрішній ThreadPerTaskExecutor, який на кожен асинхронний виклик породжує новий Thread ОС без жодних обмежень. Під високим навантаженням це призводить до вибухового зростання кількості потоків і миттєвого падіння з java.lang.OutOfMemoryError: unable to create new native thread.

Правильна стратегія розділення пулів в Enterprise-застосунках

У продакшені використання commonPool за замовчуванням для бізнес-логіки вважається антипатерном. Рекомендується явне конфігурування ізольованих пулів (Bulkhead Pattern):

@Configuration
public class ThreadPoolConfiguration {

    // 1. Пул для блокуючого I/O (БД, сторонні HTTP/SOAP сервіси)
    @Bean(name = "ioExecutor")
    public ExecutorService ioExecutor() {
        return new ThreadPoolExecutor(
            16, 64, 60L, TimeUnit.SECONDS,
            new LinkedBlockingQueue<>(1000),
            new ThreadFactoryBuilder().setNameFormat("io-pool-%d").setDaemon(true).build(),
            new ThreadPoolExecutor.CallerRunsPolicy() // Плавний зворотний тиск (Backpressure)
        );
    }

    // 2. У Java 21+ для I/O ідеально використовувати Virtual Threads
    @Bean(name = "virtualThreadExecutor")
    public ExecutorService virtualThreadExecutor() {
        return Executors.newVirtualThreadPerTaskExecutor();
    }
}

4 Tricky Questions

1. Що станеться із запущеними в commonPool завданнями, якщо головний потік (main) завершиться, а завдання ще не виконані?

Відповідь: JVM завершить свою роботу негайно, а виконання завдань буде перервано без виклику блоків finally. Потоки в ForkJoinPool.commonPool() створюються фабрикою ForkJoinWorkerThreadFactory із прапорцем daemon = true. Віртуальна машина завершує процес, щойно в ній не залишається жодного активного потоку, який не є демоном (non-daemon thread). Щоб завдання добігли кінця, необхідно або явно дочекатися їхнього завершення через join(), або тримати активним хоча б один користувацький non-daemon потік.


2. У контейнері Kubernetes з лімітом resources.limits.cpu: 1 ви викликали CompletableFuture.supplyAsync(...) 1000 разів. Що станеться?

Відповідь: Буде створено 1000 системних потоків ОС! Оскільки ядро лише 1, ForkJoinPool.getCommonPoolParallelism() поверне значення $\le 1$. У результаті CompletableFuture перемкнеться на аварійний ThreadPerTaskExecutor, де на кожен виклик виконується new Thread(task).start(). Замість пулу потоків система створить 1000 нативних потоків ОС, що спричинить жорстку деградацію продуктивності через перевантаження планувальника ядра Linux та потенційний збій OutOfMemoryError: unable to create new native thread.


3. Чи можна динамічно на льоту (в runtime) змінити розмір ForkJoinPool.commonPool()?

Відповідь: Ні, програмно в runtime змінити паралелізм commonPool неможливо. Екземпляр commonPool є статичним синглтоном, параметри якого (parallelism, threadFactory, exceptionHandler) зчитуються із системних властивостей System.getProperty(...) один раз у момент ініціалізації класу ForkJoinPool. Єдиний спосіб змінити його розмір — передати JVM-параметр -Djava.util.concurrent.ForkJoinPool.common.parallelism=N при старті застосунку (до того, як будь-який код звернеться до ForkJoinPool).


4. Чому при виклику cf.supplyAsync(task, customExecutor).thenApplyAsync(fn) друга операція може зламати застосунок, якщо не передати customExecutor у другий виклик?

Відповідь: Тому що в CompletableFuture екзекутор не успадковується вниз ланцюжком! Виклик cf.supplyAsync(task, customExecutor) виконається у виділеному пулі customExecutor. Але наступний виклик .thenApplyAsync(fn) без аргументів за контрактом CompletionStage відправить лямбду fn у дефолтний ForkJoinPool.commonPool(). Якщо в fn міститься блокуючий I/O або довгий розрахунок, він ненавмисно витече в загальний системний пул JVM і створить там дефіцит потоків.


🎯 Шпаргалка для інтерв’ю

Головне за 60 секунд

  • Пул за замовчуванням — ForkJoinPool.commonPool().
  • Розмір: Runtime.getRuntime().availableProcessors() - 1 (мінімум 1).
  • Усі потоки пулу — демони (daemon = true).
  • Пул призначений тільки для CPU-bound обчислень. Будь-який блокуючий I/O в commonPool призводить до Thread Starvation усієї JVM.
  • При паралелізмі $\le 1$ (1-ядерний Docker-контейнер) CompletableFuture перемикається на ThreadPerTaskExecutor (new Thread().start() на кожне завдання!).
  • У production для I/O завжди впроваджується кастомний ThreadPoolExecutor або Virtual Threads (Java 21+).

Типові помилки на інтерв’ю

  • Стверджувати, що commonPool за замовчуванням створює потоки за кількістю ядер (насправді $N - 1$, резервуючи 1 ядро під main).
  • Вважати, що commonPool відстежує блокування потоків і автоматично достворює нові потоки для звичайних Runnable (він робить це тільки для завдань, загорнутих у ManagedBlocker).
  • Вважати, що customExecutor автоматично підхоплюється всіма наступними методами із суфіксом Async у ланцюжку.

Пов’язані теми