Создавать новый поток на каждую задачу — дорого и опасно. 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() // политика отказа
);
Параметры работают вместе:
- Пока число потоков меньше
corePoolSize— создаётся новый поток. - Если
corePoolSizeдостигнут, задача кладётся в очередь. - Если очередь заполнена и потоков меньше
maximumPoolSize— создаётся ещё один поток. - Если очередь заполнена и потоков уже
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()у всех потоков пула и возвращает список задач, которые не успели выполниться. Задача должна реагировать на прерывание, иначе поток продолжит работу.
Диаграмма жизни задачи в пуле
Коротко
- Создавать поток на каждую задачу расточительно — пул переиспользует потоки.
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.
- Потокобезопасные коллекции — какие структуры данных безопасно использовать между потоками пула.