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.
Почему 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.
Shutdown: shutdown() vs shutdownNow()
shutdown() переводит пул в режим "не принимать новые задачи, но доделать текущие и те, что уже в очереди". shutdownNow() пытается прервать выполняющиеся задачи через Thread.interrupt() и возвращает список недоделанных задач из очереди, но не гарантирует их остановку — если задача не проверяет флаг прерывания, она продолжит работать.