🧵 Розділ 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 секунд
Головний ризик у Production 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. Пов’язані питання