← назад к разделу

Создавать новый поток на каждую задачу — дорого и опасно. ExecutorService решает эту проблему: держит готовые потоки и распределяет между ними работу.

Проблема: зачем вообще пул

Поток в Java — дорогой объект. Его создание занимает миллисекунды и требует выделения памяти под стек (по умолчанию 512 КБ–1 МБ). Если приложение создаёт по потоку на каждый HTTP-запрос или фоновую задачу, при нагрузке это приводит к двум проблемам:

  • Перерасход памяти. Тысяча одновременных потоков — это гигабайты только на стеки.
  • Перегрузка планировщика. Операционная система тратит больше времени на переключение между потоками, чем на полезную работу.

Пул потоков — это набор заранее созданных потоков, которые ожидают задачи в очереди. Задача поступает → свободный поток берёт её → выполняет → возвращается ждать следующую. Создание потока происходит один раз, а не при каждом запросе.

ExecutorService: базовый интерфейс

ExecutorService — главный интерфейс для управления пулом. Он расширяет Executor, добавляя возможность отправлять задачи с результатом и управлять жизненным циклом.

Два основных способа передать задачу:

ExecutorService pool = Executors.newFixedThreadPool(4);

// execute — для задач без результата (Runnable)
pool.execute(() -> System.out.println("фоновая работа"));

// submit — для задач с результатом (Callable) или Runnable
Future<Integer> future = pool.submit(() -> {
    // вычисление занимает время
    return 42;
});

Integer result = future.get(); // блокирует до готовности результата

Callable<V> отличается от Runnable тем, что может вернуть значение и выбросить проверяемое исключение. Future<V> — это «квитанция»: задача ещё выполняется, но результат можно получить позже через future.get().

Фабрики Executors и их ограничения

Класс Executors предоставляет готовые фабрики:

МетодПоведение
newFixedThreadPool(n)ровно n потоков, очередь не ограничена
newCachedThreadPool()потоки создаются по требованию, живут 60 с после простоя
newSingleThreadExecutor()один поток, задачи строго по очереди
newScheduledThreadPool(n)для задач с задержкой и по расписанию

Почему в продакшене лучше явный ThreadPoolExecutor. У newFixedThreadPool очередь не ограничена (LinkedBlockingQueue без лимита). Если задачи поступают быстрее, чем обрабатываются, очередь растёт до исчерпания памяти. У newCachedThreadPool нет верхнего предела числа потоков: при всплеске нагрузки система может создать тысячи потоков.

ThreadPoolExecutor: полный контроль

ThreadPoolExecutor pool = new ThreadPoolExecutor(
    4,                              // corePoolSize — постоянные потоки
    8,                              // maximumPoolSize — максимум при пиковой нагрузке
    30, TimeUnit.SECONDS,           // keepAliveTime — как долго жить «лишним» потокам
    new ArrayBlockingQueue<>(200),  // ограниченная очередь
    new ThreadPoolExecutor.CallerRunsPolicy() // политика отказа
);

Параметры работают вместе:

  1. Пока число потоков меньше corePoolSize — создаётся новый поток.
  2. Если corePoolSize достигнут, задача кладётся в очередь.
  3. Если очередь заполнена и потоков меньше maximumPoolSize — создаётся ещё один поток.
  4. Если очередь заполнена и потоков уже maximumPoolSize — срабатывает политика отказа.

Политики отказа

RejectedExecutionHandler определяет, что делать с задачей, которую некуда поставить:

  • AbortPolicy (по умолчанию) — бросает RejectedExecutionException.
  • CallerRunsPolicy — задачу выполняет сам поток, который её отправил (естественное замедление отправителя).
  • DiscardPolicy — задача молча отбрасывается.
  • DiscardOldestPolicy — из очереди удаляется самая старая задача, новая встаёт на её место.

Для большинства сервисов CallerRunsPolicy — разумный выбор: вместо потери задач система сама замедляет приём.

Сколько потоков создавать

Универсальной формулы нет, но есть два полюса:

CPU-bound задачи (вычисления, обработка данных без ожидания): размер пула ≈ числу ядер процессора. Лишние потоки только создают конкуренцию за CPU.

int cores = Runtime.getRuntime().availableProcessors();
ExecutorService cpuPool = Executors.newFixedThreadPool(cores);

IO-bound задачи (запросы к БД, HTTP-вызовы, чтение файлов): потоки большую часть времени ждут ответа. Пул может быть в несколько раз больше числа ядер — пока один поток ждёт IO, другие работают. Типичные ориентиры: 2×–4× ядер для умеренного IO, но точное значение подбирается нагрузочным тестированием.

// для IO-bound: больше потоков, чем ядер
ExecutorService ioPool = new ThreadPoolExecutor(
    cores * 2, cores * 4,
    60, TimeUnit.SECONDS,
    new ArrayBlockingQueue<>(500),
    new ThreadPoolExecutor.CallerRunsPolicy()
);

Корректное завершение пула

Пул — это ресурс, его нужно закрывать. Если пул не остановить, JVM не завершится, пока живы потоки пула (если они не демонические).

pool.shutdown(); // запрещает принимать новые задачи, ждёт завершения текущих

try {
    // ждём не дольше 10 секунд
    if (!pool.awaitTermination(10, TimeUnit.SECONDS)) {
        pool.shutdownNow(); // прерываем незавершённые задачи
        // ещё подождём прерывания
        pool.awaitTermination(5, TimeUnit.SECONDS);
    }
} catch (InterruptedException e) {
    pool.shutdownNow();
    Thread.currentThread().interrupt(); // восстанавливаем флаг прерывания
}

Разница между методами:

  • shutdown() — «мягкая» остановка: новые задачи не принимаются, уже поставленные в очередь и выполняющиеся — доделываются.
  • shutdownNow() — «жёсткая»: вызывает interrupt() у всех потоков пула и возвращает список задач, которые не успели выполниться. Задача должна реагировать на прерывание, иначе поток продолжит работу.

Диаграмма жизни задачи в пуле

diagram

Коротко

  • Создавать поток на каждую задачу расточительно — пул переиспользует потоки.
  • ExecutorService — главный интерфейс; execute для Runnable, submit для Callable/Future.
  • Фабрики Executors удобны для прототипов, но у них неограниченные очереди или число потоков — опасно в продакшене.
  • ThreadPoolExecutor с явными corePoolSize, maximumPoolSize, ограниченной очередью и политикой отказа — правильный выбор для сервисов.
  • CPU-bound: пул ≈ числу ядер; IO-bound: пул в 2–4 раза больше, точное значение — из нагрузочного тестирования.
  • Всегда завершать пул через shutdown() + awaitTermination() + shutdownNow() при таймауте.

Что почитать дальше

  • CompletableFuture: асинхронные цепочки — как строить зависимые асинхронные шаги без ручного ожидания Future.
  • Виртуальные потоки — альтернатива пулам для IO-bound задач в Java 21.
  • Потокобезопасные коллекции — какие структуры данных безопасно использовать между потоками пула.