⏱️ Раздел 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(), либо держать активным хотя бы один пользовательский поток.

2. В контейнере Kubernetes с лимитом resources.limits.cpu: 1 вы вызвали CompletableFuture.supplyAsync(...) 1000 раз. Что произойдёт?

Ответ: Будет создано 1000 системных потоков ОС! Так как ядро всего 1, ForkJoinPool.getCommonPoolParallelism() вернёт значение $\le 1$. В результате CompletableFuture переключится на аварийный ThreadPerTaskExecutor, где на каждый вызов выполняется new Thread(task).start(). Вместо пула потоков система создаст 1000 нативных потоков ОС, что вызовет жесткую деградацию производительности из-за перегрузки планировщика ядра Linux и потенциальный сбой OutOfMemoryError.

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 в цепочке.

Связанные вопросы