🧵 Раздел 9 · Вопрос #15

В чём разница между Executors.newFixedThreadPool() и newCachedThreadPool()

Ключевая разница между Executors.newFixedThreadPool() и Executors.newCachedThreadPool() заключается в стратегии масштабирования пула, типе внутренней очереди и жизненном цикле р...


🟢 Junior Level

Краткий ответ для собеседования (30 секунд)

Ключевая разница между Executors.newFixedThreadPool() и Executors.newCachedThreadPool() заключается в стратегии масштабирования пула, типе внутренней очереди и жизненном цикле рабочих потоков:

  1. newFixedThreadPool(n) создает пул со строго фиксированным числом потоков (corePoolSize == maximumPoolSize == n). Потоки создаются один раз и живут постоянно. Избыточные задачи накапливаются в неограниченной очереди LinkedBlockingQueue.
  2. newCachedThreadPool() создает эластичный пул без ограничения сверху (maximumPoolSize = Integer.MAX_VALUE). Потоки создаются динамически по требованию и уничтожаются, если простаивают без работы более 60 секунд. Очередь задач — SynchronousQueue с нулевой ёмкостью (прямая передача из рук в руки).

Сравнительная таблица

Параметр newFixedThreadPool(n) newCachedThreadPool()
Размер пула Строго фиксированный ($n$) Динамический ($0 \dots 2^{31}-1$)
corePoolSize $n$ $0$
maximumPoolSize $n$ Integer.MAX_VALUE
Очередь задач LinkedBlockingQueue (неограниченная) SynchronousQueue (ёмкость = 0)
Время жизни простоя Потоки не уничтожаются (0 ms) Уничтожаются через 60 секунд
Главный риск в бою Heap OOM (разрастание очереди задач) Native Thread OOM (Thread Explosion)
Оптимальный сценарий Стабильный поток известной нагрузки Множество коротких неблокирующих задач

Примеры на Java 21

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class FixedVsCachedDemo {
    public static void main(String[] args) {
        // 1. Fixed: максимум 4 потока одновременно, остальные ждут в очереди
        try (ExecutorService fixedPool = Executors.newFixedThreadPool(4)) {
            for (int i = 0; i < 20; i++) {
                final int taskId = i;
                fixedPool.submit(() -> processBatch(taskId));
            }
        }

        // 2. Cached: каждый запрос немедленно получает поток (новый или свободный)
        try (ExecutorService cachedPool = Executors.newCachedThreadPool()) {
            for (int i = 0; i < 20; i++) {
                final int taskId = i;
                cachedPool.submit(() -> quickCalculation(taskId));
            }
        }
    }

    private static void processBatch(int id) { /* пакетная обработка */ }
    private static void quickCalculation(int id) { /* короткий расчёт */ }
}

🟡 Middle Level

Внутреннее устройство и механика передачи задач

Разберём исходный код фабрик в ThreadPoolExecutor:

1. Внутренности newFixedThreadPool(n)

new ThreadPoolExecutor(
    n, n,
    0L, TimeUnit.MILLISECONDS,
    new LinkedBlockingQueue<Runnable>() // Лимит по умолчанию Integer.MAX_VALUE
);
  • Механика: Новые задачи сразу заполняют потоки до n. После этого метод workQueue.offer() всегда возвращает true, потому что очередь LinkedBlockingQueue не имеет лимита по ёмкости.
  • Дополнительные потоки никогда не создаются, так как очередь никогда не считается переполненной.
  • Если задачи генерируются быстрее, чем n потоков успевают их обрабатывать, очередь начинает разрастаться в оперативной памяти (Heap), увеличивая нагрузку на Garbage Collector и задержки ответа (Latency).

2. Внутренности newCachedThreadPool()

new ThreadPoolExecutor(
    0, Integer.MAX_VALUE,
    60L, TimeUnit.SECONDS,
    new SynchronousQueue<Runnable>() // Ёмкость строго равна 0!
);
  • Механика: Очередь SynchronousQueue не имеет внутреннего буфера. Это «точка рандеву»: метод offer() завершится успешно только если прямо в этот момент другой рабочий поток уже вызвал метод poll()/take() и ждёт задачу.
  • Если в пуле нет свободного простаивающего потока, offer() немедленно возвращает false.
  • По алгоритму ThreadPoolExecutor проверяется условие: workerCount < maximumPoolSize? Так как лимит равен $2\,147\,483\,647$, JVM создаёт новый поток операционной системы.
                           Сравнение очередей
   FixedThreadPool (LinkedBlockingQueue):
   Задачи ──> [Task 1] [Task 2] [Task 3] ... [Task 1_000_000] ──> Потоки (1..N)
              (Пассивная очередь в памяти кучи)

   CachedThreadPool (SynchronousQueue):
   Задача ──> [ Рандеву (емкость 0) ] ──> Свободный поток (если есть)
                       │
                       └── Если свободных нет ──> Создать НОВЫЙ поток ОС!

🔴 Senior Level

Сценарии катастрофических сбоев в Production

Оба стандартных метода таят в себе смертельные риски для высоконагруженных систем:

Катастрофа 1: OOM в FixedThreadPool (Heap Exhaustion)

  • Сценарий: Внешний сервис или база данных начинают отвечать с задержкой в 5 секунд вместо 100 мс.
  • Все $N$ рабочих потоков пула переходят в состояние ожидания I/O.
  • Входящие запросы клиентов продолжают поступать со скоростью 1000 RPS.
  • За 60 секунд в LinkedBlockingQueue накапливается $60 \times 1000 = 60\,000$ объектов Runnable вместе с их контекстами и тяжелыми DTO.
  • Наступает java.lang.OutOfMemoryError: Java heap space, приводящий к аварийной остановке всего приложения.

Катастрофа 2: Катастрофа потоков в CachedThreadPool (Thread Explosion)

  • Сценарий: Задачи выполняют медленный блокирующий сетевой запрос (например, обращение к внешнему шлюзу).
  • Свободных потоков нет, так как все текущие потоки висят в сокетах.
  • newCachedThreadPool() начинает создавать по новому системному потоку на каждый входящий запрос.
  • Каждый поток операционной системы требует ~1 МБ нативного стека (-Xss) и дескриптор в ядре ОС.
  • На отметке в 2 000–5 000 потоков ядро Linux отказывает в аллокации: java.lang.OutOfMemoryError: unable to create native thread.
  • Процессор тратит 100% времени на переключение контекста ОС (Context Switching Thrashing), а JVM падает.

Архитектурные альтернативы для Production

Опытные инженеры заменяют стандартные фабрики явной конфигурацией ThreadPoolExecutor:

1. Безопасная замена FixedThreadPool:

// Ограниченная очередь + обратное давление (Back-pressure)
ExecutorService safeFixedPool = new ThreadPoolExecutor(
    16, 16,
    0L, TimeUnit.MILLISECONDS,
    new ArrayBlockingQueue<>(500), // Жёсткий лимит: максимум 500 задач в очереди
    new NamedThreadFactory("orders-fixed"),
    new ThreadPoolExecutor.CallerRunsPolicy() // При переполнении вызывающий поток сам выполняет задачу
);

2. Безопасная замена CachedThreadPool:

// Эластичный пул, но со строгим потолком масштабирования!
ExecutorService safeCachedPool = new ThreadPoolExecutor(
    0, 200,                        // Не более 200 нативных потоков!
    60L, TimeUnit.SECONDS,
    new SynchronousQueue<>(),
    new NamedThreadFactory("compute-cached"),
    new ThreadPoolExecutor.CallerRunsPolicy()
);

Революция Virtual Threads в Java 21

В Java 21 появление виртуальных потоков (Project Loom) полностью устранило дилемму между FixedThreadPool и CachedThreadPool для I/O-нагрузок:

// Java 21: Больше не нужно выбирать между очередью и созданием потоков
try (ExecutorService vExecutor = Executors.newVirtualThreadPerTaskExecutor()) {
    vExecutor.submit(this::blockingHttpCall);
}
Критерий CachedThreadPool (Java 8) VirtualThreadPerTaskExecutor (Java 21)
Тип потока Платформенный поток ОС (pthread) Виртуальный поток JVM (в куче)
Память на поток ~1 МБ нативный стек ~1 КБ в Java Heap
Лимит потоков Тысячи (ограничен памятью ядра ОС) Миллионы (ограничен кучей JVM)
Блокирующий I/O Блокирует реальный поток ядра ОС Размонтирует виртуальный поток (Unmount)

🎯 Шпаргалка для интервью

1. 30-секундный эссенс (Elevator Pitch)

newFixedThreadPool(n) использует фиксированное количество постоянных потоков и безлимитную очередь LinkedBlockingQueue (риск падения по Heap OOM). newCachedThreadPool() не ограничивает количество потоков (Integer.MAX_VALUE), использует SynchronousQueue с нулевой ёмкостью и удаляет потоки после 60 секунд простоя (риск падения по unable to create native thread). В Production обе стандартные фабрики заменяют на кастомный ThreadPoolExecutor с ограниченной ArrayBlockingQueue и CallerRunsPolicy. В Java 21 виртуальные потоки полностью вытесняют newCachedThreadPool для любых задач с вводом-выводом.


2. 4 каверзных вопроса с глубокими ответами

Вопрос 1: Почему SynchronousQueue внутри newCachedThreadPool() имеет нулевую ёмкость, и как поток передаёт задачу другому потоку?

Ответ: SynchronousQueue — это не буфер хранения, а структура прямой передачи данных («рандеву»). Метод put() или offer() блокируется до тех пор, пока другой рабочий поток не вызовет take() или poll(). Передача ссылки на задачу происходит непосредственно из рук в руки без промежуточного размещения в очереди памяти кучи. Если в момент отправки задачи нет свободного потока, ждущего на poll(), очередь рапортует об отсутствии свободного места, вынуждая ThreadPoolExecutor немедленно породить новый рабочий поток.

Вопрос 2: Какой именно тип OutOfMemoryError возникает при перегрузке newFixedThreadPool(), а какой при перегрузке newCachedThreadPool()?

Ответ:

  • В newFixedThreadPool возникает java.lang.OutOfMemoryError: Java heap space, так как перегрузка приводит к бесконечному накоплению объектов Runnable и вложенных данных внутри безлимитной LinkedBlockingQueue в куче JVM.
  • В newCachedThreadPool возникает java.lang.OutOfMemoryError: unable to create native thread, так как перегрузка заставляет виртуальную машину непрерывно запрашивать у ядра операционной системы создание новых потоков pthread с выделением стеков по 1 МБ, пока не будет исчерпан системный лимит max_user_processes или виртуальная память ОС.

Вопрос 3: Почему newCachedThreadPool() категорически противопоказан для задач с длительным временем выполнения (I/O, сетевые запросы к медленным сервисам)?

Ответ: Если задачи выполняются долго, потоки не успевают завершать работу и освобождаться. При высоком входящем потоке запросов SynchronousQueue будет непрерывно сигнализировать об отсутствии свободных исполнителей, что заставит пул лавинообразно создавать новые потоки ОС на каждый входящий запрос. Это приведёт к взрывному росту потоков (Thread Explosion), параличу планировщика операционной системы из-за непрерывных переключений контекста и быстрому падению сервера.

Вопрос 4: Как виртуальные потоки в Java 21 меняют архитектурный выбор между этими двумя пулами?

Ответ: Фабрика Executors.newVirtualThreadPerTaskExecutor() объединяет лучшие черты обоих подходов: она запускает задачи с нулевой задержкой очереди (как в CachedThreadPool), но использует легковесные виртуальные потоки по ~1 КБ вместо системных потоков по 1 МБ. Блокирующий ввод-вывод освобождает базовый поток ядра ОС (Carrier Thread). Поэтому в Java 21 newCachedThreadPool для сетевых задач считается устаревшим, а FixedThreadPool остаётся востребован исключительно для ресурсоёмких вычислений (CPU-bound) с размером пула, равным количеству процессорных ядер.


3. Типичные ошибки и Red Flags

❌ “В newCachedThreadPool задачи ждут в очереди, пока освободится поток.”
Правильно: В newCachedThreadPool очередь SynchronousQueue не может хранить задачи. Если свободного потока нет прямо сейчас, задача не ждёт в очереди, а сразу создаётся новый поток.

❌ “newFixedThreadPool безопасен, потому что число потоков строго ограничено.”
Правильно: Ограничены только потоки, но очередь задач неограниченна. Под нагрузкой переполнение памяти в очереди убьёт приложение так же надёжно, как и переполнение потоков.

❌ “Потоки в newCachedThreadPool живут бесконечно.”
Правильно: Потоки уничтожаются планировщиком пула, если простаивают без задач более 60 секунд.


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