Главная › Блог › ExecutorService и пулы потоков: как не выстрелить себе в ногу

ExecutorService и пулы потоков: как не выстрелить себе в ногу

Зачем нужен ExecutorService

Создавать новый Thread на каждую задачу — дорого и опасно. Создание потока — это системный вызов, выделение стека (по умолчанию 512KB-1MB), контекст для планировщика ОС. Если задач тысячи, а они короткие, накладные расходы на создание потока превысят время самой работы.

ExecutorService решает это разделением: есть пул заранее созданных потоков (worker'ов) и очередь задач. Клиентский код просто кладёт задачу в очередь через submit() или execute(), а пул сам решает, когда и каким потоком её выполнить.

Это как касса в банке с несколькими операторами и электронной очередью. Клиенты (задачи) берут талон и ждут. Операторы (потоки) освобождаются и берут следующий талон. Если касс не хватает — очередь растёт. Если касс слишком много — часть простаивает, а банк платит за аренду лишних окошек.

Анатомия ThreadPoolExecutor

Все фабричные методы из Executors под капотом создают ThreadPoolExecutor с разными параметрами. Конструктор принимает:

new ThreadPoolExecutor(
    corePoolSize,      // сколько потоков держим всегда
    maximumPoolSize,   // максимум потоков под нагрузкой
    keepAliveTime,      // сколько живёт "лишний" поток без работы
    TimeUnit.SECONDS,
    workQueue,          // очередь ожидающих задач
    threadFactory,      // как создавать потоки
    rejectedHandler     // что делать, если всё переполнено
);

Логика такая: если занятых потоков меньше corePoolSize — создаём новый поток под задачу. Если core исчерпан — задача идёт в очередь. Если очередь полна — создаём поток сверх core, вплоть до maximumPoolSize. Если и максимум исчерпан — включается RejectedExecutionHandler.

Частая ошибка: думать, что при росте нагрузки пул сразу начнёт создавать новые потоки до maximumPoolSize. На самом деле пока в очереди есть место, новые потоки сверх core НЕ создаются — задачи просто копятся в очереди. Это может привести к OOM на неограниченной LinkedBlockingQueue вместо ожидаемого масштабирования пула.

Почему Executors.newFixedThreadPool — ловушка

Executors.newFixedThreadPool(n) создаёт ThreadPoolExecutor с core = max = n и LinkedBlockingQueue без ограничения размера. Под нагрузкой, превышающей пропускную способность n потоков, очередь растёт бесконечно — и рано или поздно это OutOfMemoryError, а не graceful degradation.

Аналогично Executors.newCachedThreadPool() создаёт пул без верхнего предела (maximumPoolSize = Integer.MAX_VALUE) с SynchronousQueue. При всплеске нагрузки это бесконтрольный рост числа потоков — тоже путь к падению, но уже через исчерпание памяти на стеки потоков или лимиты ОС.

Поэтому в Java 9+ в код-ревью часто просят не использовать фабрики из Executors напрямую, а собирать ThreadPoolExecutor руками с явными и осознанными лимитами на все параметры, включая очередь.

ThreadPoolExecutor executor = new ThreadPoolExecutor(
    10, 20,
    60, TimeUnit.SECONDS,
    new ArrayBlockingQueue<>(100),
    new ThreadPoolExecutor.CallerRunsPolicy()
);

Политики отказа (RejectedExecutionHandler)

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

На практике CallerRunsPolicy часто самый разумный выбор для внутренних сервисов — он не теряет задачи и естественным образом тормозит источник нагрузки.

Future, submit и обработка исключений

Если вызвать execute(Runnable) и внутри задачи выстрелит исключение — оно уйдёт в UncaughtExceptionHandler потока и тихо осядет в логах (или вообще потеряется). Если же использовать submit(), который возвращает Future, исключение упаковывается внутрь Future и всплывёт только при вызове future.get(), причём обёрнутое в ExecutionException.

Почему задача в submit() "проглотила" исключение, а в логах тишина?
Исключение не теряется — оно сохраняется внутри Future. Если никогда не вызвать get() (или вызвать и проигнорировать результат), исключение просто никогда не всплывёт. Нужно либо вызывать get(), либо логировать его явно внутри самой задачи.

Shutdown: shutdown() vs shutdownNow()

shutdown() переводит пул в режим "не принимать новые задачи, но доделать текущие и те, что уже в очереди". shutdownNow() пытается прервать выполняющиеся задачи через Thread.interrupt() и возвращает список недоделанных задач из очереди, но не гарантирует их остановку — если задача не проверяет флаг прерывания, она продолжит работать.

interrupt() — это не "убить поток", а флаг-просьба. Если код задачи игнорирует Thread.currentThread().isInterrupted() и не кидает InterruptedException при блокирующих вызовах, shutdownNow() не остановит такую задачу мгновенно.

Что спрашивают на собесе

В чём разница между corePoolSize и maximumPoolSize?
corePoolSize — потоки, которые пул держит постоянно, даже без работы (если allowCoreThreadTimeOut не включён). maximumPoolSize — верхний предел, до которого пул может расти при заполненной очереди.
Почему newCachedThreadPool опасен в проде?
Нет ограничения на количество потоков — при всплеске нагрузки пул может создать тысячи потоков, что ведёт к исчерпанию памяти или лимитов ОС на число потоков.

Ещё разборы