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

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

Java надає кілька готових типів пулів потоків через фабрику Executors. Кожен тип оптимізований для певного сценарію.

Мовні версії: English Russian Ukrainian

Junior рівень

Базове розуміння

Java надає кілька готових типів пулів потоків через фабрику Executors. Кожен тип оптимізований для певного сценарію.

1. FixedThreadPool

Фіксована кількість потоків. Всі задачі виконуються паралельно, інші чекають в черзі. Обирайте, коли знаєте максимальне навантаження заздалегідь (наприклад, обробка 10 файлів одночасно).

ExecutorService pool = Executors.newFixedThreadPool(10);

for (int i = 0; i < 100; i++) {
    pool.submit(() -> {
        // Максимум 10 задач виконуються одночасно
        // Інші 90 чекають в черзі
    });
}
Параметр Значення
Розмір пулу Фіксований (10)
Черга Безрозмірна (LinkedBlockingQueue)
Коли використовувати Відоме, стабільне навантаження

2. CachedThreadPool

Створює нові потоки за необхідності, повторно використовує вільні.

ExecutorService pool = Executors.newCachedThreadPool();

pool.submit(() -> {
    // Якщо немає вільних потоків — створюється новий
    // Якщо потік вільний 60 секунд — знищується
});
Параметр Значення
Розмір пулу 0 до Integer.MAX_VALUE (теоретично ~2 млрд, на практиці обмежено пам’яттю ОС — зазвичай 32K потоків максимум на Linux)
Черга SynchronousQueue (ємність 0)
Коли використовувати Багато коротких задач

3. SingleThreadExecutor

Рівно один потік. Задачі виконуються строго послідовно.

ExecutorService pool = Executors.newSingleThreadExecutor();

pool.submit(() -> System.out.println("Задача 1"));
pool.submit(() -> System.out.println("Задача 2"));
// Завжди: "Задача 1" → "Задача 2"
Параметр Значення
Розмір пулу 1
Черга Безрозмірна
Коли використовувати Послідовне виконання

4. ScheduledThreadPool

Планування задач із затримкою або періодично.

ScheduledExecutorService pool = Executors.newScheduledThreadPool(5);

// Виконати через 10 секунд
pool.schedule(() -> System.out.println("Delayed"), 10, TimeUnit.SECONDS);

// Виконувати кожні 5 секунд
pool.scheduleAtFixedRate(() -> System.out.println("Periodic"), 0, 5, TimeUnit.SECONDS);
Параметр Значення
Розмір пулу Фіксований
Черга DelayedWorkQueue
Коли використовувати Періодичні задачі

5. WorkStealingPool (Java 8+)

Примітка: в Java 21+ з віртуальними потоками (Project Loom) для I/O-bound задач переважніше Executors.newVirtualThreadPerTaskExecutor(). Віртуальні потоки коштують ~KB замість ~1MB і можуть створюватися мільйонами.

Використовує ForkJoinPool з алгоритмом work-stealing.

ExecutorService pool = Executors.newWorkStealingPool();
// За замовчуванням: число потоків = число ядер CPU
Параметр Значення
Розмір пулу Число ядер CPU
Алгоритм Work-stealing
Коли використовувати Розпаралелювання обчислень

Порівняльна таблиця

Тип Розмір Черга Краще для
FixedThreadPool Фіксований Безрозмірна Стабільне навантаження
CachedThreadPool Динамічний SynchronousQueue Короткі задачі
SingleThreadExecutor 1 Безрозмірна Послідовність
ScheduledThreadPool Фіксований DelayedWorkQueue Розклад
WorkStealingPool CPU cores Work-stealing Обчислення

Middle рівень

FixedThreadPool — деталі

// Внутрішня реалізація
public static ExecutorService newFixedThreadPool(int nThreads) {
    return new ThreadPoolExecutor(
        nThreads,                   // corePoolSize
        nThreads,                   // maximumPoolSize
        0L, TimeUnit.MILLISECONDS,  // keepAliveTime
        new LinkedBlockingQueue<Runnable>() // БЕЗРОЗМІРНА черга
    );
}

Небезпека: Якщо задачі приходять швидше, ніж обробляються, черга росте до нескінченності → OutOfMemoryError.

Рішення: Створюйте вручну з обмеженою чергою:

ExecutorService safePool = new ThreadPoolExecutor(
    10, 10, 0L, TimeUnit.MILLISECONDS,
    new ArrayBlockingQueue<>(1000) // Ліміт 1000 задач
);

CachedThreadPool — деталі

public static ExecutorService newCachedThreadPool() {
    return new ThreadPoolExecutor(
        0,                          // corePoolSize
        Integer.MAX_VALUE,          // maximumPoolSize — немає ліміту!
        60L, TimeUnit.SECONDS,      // Потоки вмирають через 60s
        new SynchronousQueue<Runnable>() // Ємність = 0
    );
}

Небезпека: При сплеску навантаження може створити тисячі потоків → OutOfMemoryError: unable to create new native thread.

SingleThreadExecutor — деталі

public static ExecutorService newSingleThreadExecutor() {
    return new FinalizableDelegatedExecutorService(
        new ThreadPoolExecutor(
            1, 1, 0L, TimeUnit.MILLISECONDS,
            new LinkedBlockingQueue<Runnable>()
        )
    );
}

Відмінність від newFixedThreadPool(1): гарантує, що налаштування неможливо змінити (інкапсуляція).

ScheduledThreadPool — деталі

ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(3);

// Одноразове виконання через затримку
scheduler.schedule(task, 10, TimeUnit.SECONDS);

// Фіксована частота (від початку попереднього запуску)
scheduler.scheduleAtFixedRate(task, 0, 5, TimeUnit.SECONDS);

// Фіксована затримка (від кінця попереднього запуску)
scheduler.scheduleWithFixedDelay(task, 0, 5, TimeUnit.SECONDS);
// scheduleAtFixedRate: якщо задача триває 7 секунд, а період 5с —
// наступна запуститься НЕГАЙНО після завершення (пропуск)
// scheduleWithFixedDelay: почекає 5 секунд ПІСЛЯ завершення попередньої

WorkStealingPool — деталі

public static ExecutorService newWorkStealingPool() {
    return new ForkJoinPool(
        Runtime.getRuntime().availableProcessors(),
        ForkJoinPool.defaultForkJoinWorkerThreadFactory,
        null,   // UncaughtExceptionHandler
        true    // asyncMode
    );
}

Алгоритм work-stealing:

  • У кожного потоку своя черга задач
  • Якщо потік закінчив свої задачі — “краде” з хвоста черги іншого потоку
  • Відмінно для нерівномірних задач, тому що вільний потік не простоює, а «краде» непророблені задачі у зайнятих потоків (work-stealing алгоритм).

Який пул НЕ використовувати

  • НЕ CachedThreadPool для довгих задач → thread explosion (тисячі потоків)
  • НЕ FixedThreadPool з LinkedBlockingQueue для непередбачуваного навантаження → OOM (черга росте нескінченно)
  • НЕ SingleThreadExecutor для CPU-bound паралелізму → один потік, немає паралелізму
  • НЕ WorkStealingPool для I/O-bound → блокування потоку блокує work-stealing

Senior рівень

Коли Executors не підходять

Senior-розробники часто уникають методів Executors.* і створюють ThreadPoolExecutor напряму.

Чому:

Проблема Executors.* ThreadPoolExecutor вручну
Ліміт черги Ні (OOM ризик) Є (ArrayBlockingQueue)
RejectedExecutionHandler AbortPolicy Будь-який (CallerRunsPolicy)
ThreadFactory Default Кастомний (імена, пріоритети)
keepAliveTime Фіксований Налаштовуваний

Вибір пулу залежно від задачі

Тип задачі Рекомендований пул Формула
CPU-bound (розрахунки) FixedThreadPool N або N+1 ядер
I/O-bound (БД, API) FixedThreadPool або кастомний N * (1 + W/S)
Короткі задачі CachedThreadPool (з лімітом!) maxPoolSize обмежений
Рекурсія / Стріми ForkJoinPool N ядер
Фонові задачі ScheduledThreadPool Залежить від частоти

Формула sizing для змішаних навантажень

Threads = Number of Cores * Target CPU Utilization * (1 + Wait time / Service time)

Приклад:

  • 8 ядер CPU
  • Цільова утилізація: 80% (0.8)
  • 90% часу чекаємо (W/S = 9)
Threads = 8 * 0.8 * (1 + 9) = 8 * 0.8 * 10 = 64 потоки

Thread Starvation

I/O-bound пул занадто малий (5 потоків):
  → Всі 5 потоків чекають відповіді від БД
  → Нові запити чекають в черзі
  → Система "встала"

Рішення: збільшити розмір пулу для I/O-bound задач

Sizing Formula в коді

public class PoolSizer {
    public static int calculatePoolSize(int cores, double targetUtilization,
                                        double waitTime, double serviceTime) {
        return (int) Math.ceil(
            cores * targetUtilization * (1 + waitTime / serviceTime)
        );
    }

    public static void main(String[] args) {
        int cores = Runtime.getRuntime().availableProcessors();
        int poolSize = calculatePoolSize(cores, 0.8, 90, 10);
        // 8 * 0.8 * (1 + 90/10) = 64
        System.out.println("Recommended pool size: " + poolSize);
    }
}

Діагностика

// Моніторинг пулу
ThreadPoolExecutor executor = ...;

// Критичні метрики
executor.getActiveCount();         // Скільки потоків працюють
executor.getQueue().size();        // Зростаюча черга = проблема
executor.getCompletedTaskCount();  // Прогрес
executor.getPoolSize();            // Поточний розмір

Best Practices

  1. Уникайте Executors.newCachedThreadPool() в production — використовуйте кастомний з лімітом
  2. Обмежуйте чергу — ArrayBlockingQueue замість LinkedBlockingQueue
  3. Формула sizing — CPU-bound: N+1, I/O-bound: N * (1 + W/S)
  4. WorkStealingPool — для рекурсивних задач та parallel streams
  5. ScheduledThreadPool — для періодичних задач (cron-like)
  6. Моніторьте queue.size() — зростаюча черга = деградація
  7. Кастомний ThreadFactory — іменування потоків для відладки

🎯 Шпаргалка для співбесіди

Обов’язково знати:

  • 5 типів пулів: FixedThreadPool (фіксований розмір), CachedThreadPool (динамічний), SingleThreadExecutor (1 потік), ScheduledThreadPool (за розкладом), WorkStealingPool (work-stealing алгоритм)
  • FixedThreadPool використовує LinkedBlockingQueue без ліміту — ризик OOM при непередбачуваному навантаженні
  • CachedThreadPool: maximumPoolSize = Integer.MAX_VALUE, SynchronousQueue (ємність 0) — ризик thread explosion
  • WorkStealingPool = ForkJoinPool з asyncMode=true, кожен потік має свою deque-чергу
  • scheduleAtFixedRate — від початку попереднього запуску (може пропустити якщо задача довга), scheduleWithFixedDelay — від кінця
  • SingleThreadExecutor гарантує інкапсуляцію — неможливо змінити налаштування, на відміну від newFixedThreadPool(1)
  • В production уникайте Executors.* — створюйте ThreadPoolExecutor вручну з обмеженою чергою та RejectedExecutionHandler
  • Формула sizing: CPU-bound = N+1, I/O-bound = N * (1 + W/S), змішана = N * util * (1 + W/S)

Часті уточнюючі запитання:

  • Чим FixedThreadPool відрізняється від SingleThreadExecutor? — FixedThreadPool можна переналаштувати, SingleThreadExecutor — інкапсульований (FinalizableDelegatedExecutorService)
  • Чому CachedThreadPool небезпечний в production? — При сплеску навантаження створює тисячі потоків → unable to create new native thread
  • Як працює work-stealing? — У кожного потоку своя deque; вільний потік «краде» задачі з хвоста чужої deque (FIFO), власник бере з голови (LIFO)
  • Що обрати для I/O-bound задач? — FixedThreadPool з формулою N * (1 + W/S) або віртуальні потоки (Java 21+)

Червоні прапори (НЕ говорити):

  • “CachedThreadPool — мій вибір для production” — небезпечний без обмежень, потрібен maxPoolSize
  • “FixedThreadPool з LinkedBlockingQueue безпечний” — безлімітна черга = OOM
  • “WorkStealingPool гарний для I/O” — ні, блокування потоку блокує work-stealing
  • “scheduleAtFixedRate гарантує інтервал” — ні, якщо задача довша за період — наступний запуститься негайно

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

  • [[12. Що таке пул потоків (Thread Pool)]]
  • [[15. Що робить ExecutorService]]
  • [[16. В чому різниця між Executors.newFixedThreadPool() та newCachedThreadPool()]]
  • [[17. Що таке ForkJoinPool]]