🧵 Розділ 9 · Питання #13

Які типи Thread Pool існують в Java

У стандартній бібліотеці Java створення пулів потоків організовано через утилітарний фабричний клас java.util.concurrent.Executors або через пряме інстанціювання ThreadPoolExecu...


🟢 Junior Level

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

У стандартній бібліотеці Java створення пулів потоків організовано через утилітарний фабричний клас java.util.concurrent.Executors або через пряме інстанціювання ThreadPoolExecutor, ScheduledThreadPoolExecutor та ForkJoinPool.

Виділяють 6 основних типів пулів потоків:

  1. FixedThreadPool: пул із фіксованою кількістю потоків і необмеженою чергою задач.
  2. CachedThreadPool: динамічний еластичний пул, що створює нові потоки за потреби та знищує простоюючі через 60 секунд.
  3. SingleThreadExecutor: пул із суворо одного робочого потоку з гарантією послідовного виконання задач (FIFO).
  4. ScheduledThreadPool: пул для відкладеного та періодичного виконання задач (за таймером/розкладом).
  5. WorkStealingPool (Java 8+): пул на базі ForkJoinPool, що реалізує алгоритм крадіжки задач (Work-Stealing) для високопаралельних рекурсивних обчислень.
  6. VirtualThreadPerTaskExecutor (Java 21): спеціалізований непулюючий виконавець, що створює новий легковажний віртуальний потік на кожну задачу, яка надійшла.

Зведена таблиця стандартних фабрик Executors

Фабричний метод corePoolSize maximumPoolSize Тип внутрішньої черги Основний ризик у Production
newFixedThreadPool(n) n n LinkedBlockingQueue (необмежена) Heap OOM при накопиченні задач
newCachedThreadPool() 0 Integer.MAX_VALUE SynchronousQueue (місткість 0) Native Thread OOM (Thread Explosion)
newSingleThreadExecutor() 1 1 LinkedBlockingQueue (необмежена) Heap OOM при повільній обробці
newScheduledThreadPool(n) n Integer.MAX_VALUE DelayedWorkQueue (на базі min-heap) Черга не обмежена за розміром
newWorkStealingPool() Parallelism Parallelism Двосторонні черги потоків (Deque) Не підходить для блокуючого I/O
newVirtualThreadPerTaskExecutor() Н/Д $\infty$ (Virtual) Не пулює (створює на задачу) Не можна використовувати для CPU-bound

Приклади на Java 21

import java.util.concurrent.*;

public class ThreadPoolTypesDemo {
    public static void main(String[] args) throws InterruptedException {
        // 1. Фіксований пул для стабільного передбачуваного навантаження
        try (ExecutorService fixed = Executors.newFixedThreadPool(4)) {
            fixed.submit(() -> System.out.println("Fixed task on " + Thread.currentThread()));
        }

        // 2. Однопотоковий пул для суворої черговості виконання
        try (ExecutorService single = Executors.newSingleThreadExecutor()) {
            single.submit(() -> System.out.println("Single task 1"));
            single.submit(() -> System.out.println("Single task 2"));
        }

        // 3. Планувальник періодичних задач
        try (ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(2)) {
            scheduler.scheduleAtFixedRate(
                () -> System.out.println("Heartbeat: " + System.currentTimeMillis()),
                0, 1, TimeUnit.SECONDS
            );
            Thread.sleep(3000);
        }

        // 4. Віртуальні потоки Java 21 для масового мережевого I/O
        try (ExecutorService virtual = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < 1000; i++) {
                virtual.submit(() -> {
                    // Імітація виклику зовнішнього API
                    Thread.sleep(100);
                    return "Result";
                });
            }
        }
    }
}

🟡 Middle Level

Внутрішня будова фабричних методів

Розглянемо, як саме фабрика Executors конструює ThreadPoolExecutor:

1. newFixedThreadPool(int nThreads)

public static ExecutorService newFixedThreadPool(int nThreads) {
    return new ThreadPoolExecutor(
        nThreads, 
        nThreads,
        0L, TimeUnit.MILLISECONDS,
        new LinkedBlockingQueue<Runnable>() // Небезпека: Integer.MAX_VALUE
    );
}

Оскільки corePoolSize == maximumPoolSize, пул ніколи не створює потоків понад nThreads. Проте використання LinkedBlockingQueue без зазначення ліміту місткості означає, що при повільних операціях черга задач буде безмежно розростатися в купі, поки не викличе OutOfMemoryError: Java heap space.

2. newCachedThreadPool()

public static ExecutorService newCachedThreadPool() {
    return new ThreadPoolExecutor(
        0, 
        Integer.MAX_VALUE, // Небезпека: потоки створюються нескінченно
        60L, TimeUnit.SECONDS,
        new SynchronousQueue<Runnable>() // Пряма передача без буфера
    );
}

SynchronousQueue не зберігає задачі: кожен виклик offer() блокується доти, доки інший потік не зробить take(). Якщо вільного потоку немає, пул зобов’язаний створити новий потік ОС. При різкому сплеску трафіку в 10 000 паралельних запитів пул спробує виділити 10 000 нативних потоків, що призведе до фатального збою операційної системи: OutOfMemoryError: unable to create native thread.

3. newSingleThreadExecutor()

На відміну від newFixedThreadPool(1), даний метод огортає ThreadPoolExecutor у захисну обгортку FinalizableDelegatedExecutorService:

  • Користувацький код не може звести отриманий інтерфейс до ThreadPoolExecutor ((ThreadPoolExecutor) executor викине ClassCastException).
  • Це гарантує, що сторонній код не зможе переналаштувати пул «на льоту» (наприклад, викликати setCorePoolSize(10)), порушивши інваріант суворої однопотоковості.
  • Якщо єдиний робочий потік упаде з необробленим Throwable, пул автоматично створить новий потік замість загиблого для обробки решти задач із черги.

Специфіка ScheduledThreadPoolExecutor: Періодичне виконання

ScheduledThreadPoolExecutor використовує спеціальну чергу DelayedWorkQueue, що є потокобезпечною двійковою купою (min-heap), відсортованою за часом наступного спрацьовування задачі.

Два ключові методи планування мають принципову різницю:

1. scheduleAtFixedRate(task, initialDelay, period = 5s):
   [Старт: 0s] ───── (виконується 2s) ─────> [Завершено: 2s] ── (чекає 3s) ──> [Старт: 5s]
   [Старт: 5s] ───── (виконується 7s!) ─────────────────────────────────────> [Завершено: 12s]
                                                                                │
                                           Наступний запуск стартує ВІДРАЗУ!   v [Старт: 12s]

2. scheduleWithFixedDelay(task, initialDelay, delay = 5s):
   [Старт: 0s] ───── (виконується 7s!) ────> [Завершено: 7s] ── (чекає 5s) ──> [Старт: 12s]
  • scheduleAtFixedRate: інтервал відраховується від часу початку попереднього запуску. Якщо задача затримується і виконується довше за період, наступна ітерація запуститься негайно без паузи. Задачі ніколи не запускаються паралельно на одному потоці, але при накопиченні відставання виникає ефект «черги запусків».
  • scheduleWithFixedDelay: гарантує паузу фіксованої тривалості між закінченням попереднього виконання та початком наступного.

🔴 Senior Level

Чому в Production заборонено виклик фабрик Executors?

У регламентах розробки високонавантажених систем (включаючи SonarQube, Alibaba Java Guidelines та банківський софт) використання фабричних методів Executors.newFixedThreadPool, newCachedThreadPool та newScheduledThreadPool категорично заборонено.

Причини заборони:

  1. Необмежені ресурси: фабрики використовують або нескінченну чергу (LinkedBlockingQueue), що призводить до Heap OOM, або нескінченну кількість потоків (SynchronousQueue), що призводить до Native Thread OOM.
  2. Анонімні потоки: фабрики використовують Executors.defaultThreadFactory(), що іменує потоки як pool-1-thread-1. У дампі потоків (jstack) неможливо зрозуміти, якому бізнес-модулю (платежам, експорту, аналітиці) належить завислий потік.
  3. Відсутність Back-pressure: використовується стандартна AbortPolicy, яка викидає виняток і обриває виклик клієнта без можливості пригальмувати генератор задач.

Production-Ready еталон конфігурації:

public class SafeThreadPoolConfiguration {

    public static ExecutorService createProductionPool(String poolName, int poolSize, int queueCapacity) {
        // Кастомна фабрика зі зрозумілими іменами та правильними прапорцями
        ThreadFactory customFactory = new ThreadFactory() {
            private final AtomicInteger threadNum = new AtomicInteger(1);

            @Override
            public Thread newThread(Runnable r) {
                Thread t = new Thread(r, poolName + "-worker-" + threadNum.getAndIncrement());
                t.setDaemon(false); // Робочі потоки застосунку не повинні бути демонами
                t.setPriority(Thread.NORM_PRIORITY);
                // Обробник критичних збоїв
                t.setUncaughtExceptionHandler((thread, ex) -> 
                    System.err.printf("Критичний збій у потоці %s: %s%n", thread.getName(), ex.getMessage())
                );
                return t;
            }
        };

        return new ThreadPoolExecutor(
            poolSize,                           // corePoolSize
            poolSize,                           // maximumPoolSize (фіксований)
            0L, TimeUnit.MILLISECONDS,          // keepAliveTime
            new ArrayBlockingQueue<>(queueCapacity), // ОБМЕЖЕНА ЧЕРГА
            customFactory,
            new ThreadPoolExecutor.CallerRunsPolicy() // ЗВОРОТНИЙ ТИСК (Back-pressure)
        );
    }
}

Архітектура WorkStealingPool: Як влаштований Work-Stealing

Executors.newWorkStealingPool() повертає екземпляр ForkJoinPool. На відміну від ThreadPoolExecutor, де всі потоки конкурують за одну спільну чергу workQueue, у ForkJoinPool:

  1. Кожен робочий потік (ForkJoinWorkerThread) володіє власною двосторонньою чергою (WorkQueue / Deque).
  2. Власник черги забирає задачі з голови (LIFO). Це максимізує локальність даних у процесорному кеші (L1/L2), оскільки щойно створена підзадача з найбільшою ймовірністю знаходиться в кеші.
  3. Вільний потік (злодій) при вичерпанні своїх задач звертається до чужих черг і краде задачі з хвоста (FIFO). Це мінімізує конкуренцію за вказівники з потоком-власником і бере найбільш великі підзадачі.

Революція Java 21: newVirtualThreadPerTaskExecutor()

Віртуальні потоки кардинально змінили вибір виконавця для I/O-bound задач:

// Java 21: Більше немає черг задач, немає налаштування core/max pool size
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
    executor.submit(this::blockingHttpCall);
}

Чому відпала потреба в newCachedThreadPool:

  • Віртуальний потік займає всього ~1 КБ у купі проти 1 МБ стека платформеного потоку.
  • При виконанні блокуючого I/O (мережа, файл) віртуальний потік автоматично розмонтовується (unmount) від несучого потоку ОС (Carrier Thread).
  • Пул потоків більше не потрібен як механізм економії ресурсів для I/O.
  • Але пули потоків зберігаються: для CPU-bound задач (обчислень), де кількість паралельних задач зобов’язана суворо відповідати кількості фізичних ядер процесора задля уникнення деградації планувальника.

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

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

У Java доступно кілька ключових типів пулів потоків: FixedThreadPool (фіксований розмір, LinkedBlockingQueue), CachedThreadPool (еластичний розмір, SynchronousQueue), SingleThreadExecutor (один потік, послідовне виконання), ScheduledThreadPool (виконання за розкладом через min-heap чергу) та WorkStealingPool (ForkJoinPool з локальними деками). У Production утилітарні методи Executors.* не використовуються через ризик OOM (необмежені черги або необмежені потоки); замість них вручну конфігурується ThreadPoolExecutor з ArrayBlockingQueue та CallerRunsPolicy. У Java 21 для мережевих I/O задач впроваджено newVirtualThreadPerTaskExecutor().


2. 4 каверзних питання з глибокими відповідями

Питання 1: У чому різниця між scheduleAtFixedRate() та scheduleWithFixedDelay(), якщо задача виконується довше за заданий інтервал?

Відповідь:

  • У scheduleAtFixedRate(task, 0, 5, SECONDS): інтервал розраховується суворо від часу старту першої задачі ($T_0, T_0+5, T_0+10$). Якщо задача тривала 7 секунд (довше за період), пул не запускає другу задачу паралельно (одна й та сама задача ніколи не дублюється), але запускає наступну ітерацію негайно після завершення поточної, намагаючись наздогнати графік.
  • У scheduleWithFixedDelay(task, 0, 5, SECONDS): інтервал відраховується суворо від моменту фізичного завершення попередньої ітерації. Якщо задача виконувалася 7 секунд, наступна почнеться рівно через $7 + 5 = 12$ секунд. Жодного нашарування або наздоганяючих запусків не відбувається.

Питання 2: Що станеться, якщо задача, запущена через ScheduledExecutorService.scheduleAtFixedRate(), викине необроблений RuntimeException?

Відповідь: Це найнебезпечніша прихована пастка Java: якщо чергова ітерація викине будь-який необроблений виняток (RuntimeException або Error), задача мовчки припинить своє періодичне виконання назавжди! При цьому сам робочий потік не вмирає, пул продовжує працювати, але таймер для цієї конкретної задачі більше ніколи не спрацює, і в консоль нічого не надрукується.
Правило: тіло періодичної задачі зобов’язане бути повністю огорнуте в захисний блок try-catch(Throwable t) із явним логуванням.

Питання 3: Чому Executors.newSingleThreadExecutor() повертає приховану обгортку FinalizableDelegatedExecutorService, а не відкритий ThreadPoolExecutor?

Відповідь: Для забезпечення архітектурного інваріанта: метод гарантує, що пул виконує задачі суворо послідовно в один потік. Якби метод повертав звичайний ThreadPoolExecutor, клієнтський код міг би виконати зведення типів (ThreadPoolExecutor) executor і викликати setCorePoolSize(10) або setMaximumPoolSize(10), перетворивши послідовний пул на паралельний. Обгортка делегує методи інтерфейсу ExecutorService, але приховує внутрішні методи керування конфігурацією.

Питання 4: Як змінилася роль newCachedThreadPool() з появою Executors.newVirtualThreadPerTaskExecutor() у Java 21?

Відповідь: Метод newCachedThreadPool() створювався для короткоживучих асинхронних задач, але завжди страждав від ризику аварійного створення тисяч системних потоків (Thread Explosion). З появою віртуальних потоків у Java 21 метод newCachedThreadPool() для задач введення-виведення (I/O, мережеві запити, звернення до БД) став застарілим антипатерном. newVirtualThreadPerTaskExecutor() масштабується до мільйонів легковажних задач без ризику вичерпання пам’яті ОС, розмонтовуючи віртуальні потоки під час блокувань.


3. Типові помилки та Red Flags

❌ “Використання Executors.newFixedThreadPool(100) у критичному мікросервісі.”
Правильно: Необмежена черга LinkedBlockingQueue гарантовано призведе до Heap OutOfMemoryError при сплеску навантаження.

❌ “Використання newWorkStealingPool() для викликів повільних зовнішніх SOAP/REST API.”
Правильно: WorkStealingPool побудований на ForkJoinPool і оптимізований для швидких CPU-задач без блокувань. Блокуюче введення-виведення всередині робочих потоків призводить до блокування черг крадіжки та деградації пулу.

❌ “Ігнорування обробки винятків у scheduleWithFixedDelay.”
Правильно: Будь-який необроблений RuntimeException назавжди вбиває розклад задачі без слідів у логах.


4. Пов’язані питання