Зачем нужен пул потоков и как выбрать его размер?
- Как работает ThreadPoolExecutor?
- Что будет, если очередь пула неограниченная?
- Сколько потоков нужно для I/O-задач?
Пул держит заранее созданные потоки и очередь задач: не платим за создание потока на каждую задачу и ограничиваем параллелизм.
- Вычисления: потоков ≈ числу ядер
- Ожидание (I/O): ядра × (1 + время ожидания / время работы)
- Очередь должна быть ограниченной, иначе при перегрузке — рост памяти и задержек до OOM
А ещё Политика отказа при полной очереди: отклонить, выполнить в вызывающем потоке, выкинуть старое
Подробно
Создание потока — системный вызов, выделение стека (около мегабайта), регистрация в планировщике. Делать это на каждый запрос дорого, а главное — неограниченно: при всплеске нагрузки тысячи потоков съедят память и время на переключения. Пул решает обе проблемы: фиксированный набор рабочих потоков берёт задачи из очереди.
**Как устроен ThreadPoolExecutor** в Java:
- пока потоков меньше
corePoolSize, на новую задачу создаётся поток; - дальше задачи кладутся в очередь;
- если очередь полна, создаются потоки до
maximumPoolSize; - если и это исчерпано — срабатывает политика отказа (
RejectedExecutionHandler): исключение, выполнение в вызывающем потоке (CallerRunsPolicy— естественный backpressure), выбросить задачу.
Ловушка: Executors.newFixedThreadPool использует неограниченную очередь. При перегрузке задачи копятся, память растёт, задержка каждой задачи — время в очереди, и в итоге OOM. Неограниченный newCachedThreadPool создаёт потоки без предела.
Размер пула:
- CPU-bound — около числа ядер (иногда +1): больше потоков не ускорят, только добавят переключений;
- I/O-bound — формула из «Java Concurrency in Practice»: N = ядра × загрузка × (1 + W/C), где W — время ожидания, C — время вычислений. Задача ждёт базу 90 мс и считает 10 мс на 8 ядрах — 8 × (1 + 9) = 80 потоков;
- но верхний предел часто задаёт не формула, а зависимость: нет смысла в 200 потоках, если пул соединений к базе — 20. Это закон Литтла: параллелизм = пропускная способность × время ответа.
Изоляция (bulkhead). Разные зависимости — разные пулы: если медленный внешний сервис займёт все потоки общего пула, встанут и запросы, которые с ним не связаны.
Другие подходы: виртуальные потоки Java 21 — дешёвые, можно на задачу, но ограничивать параллелизм к ресурсам всё равно нужно (семафором); ForkJoinPool — для рекурсивных вычислительных задач с work stealing; event loop — для большого числа соединений.
Не забыть: корректно останавливать пул (shutdown + awaitTermination), давать потокам имена, обрабатывать исключения в задачах — в submit они прячутся внутри Future.
Пример
ExecutorService pool = new ThreadPoolExecutor(
16, 16, 0, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(500), // ограниченная очередь
new ThreadFactoryBuilder().setNameFormat("report-%d").build(),
new ThreadPoolExecutor.CallerRunsPolicy()); // при перегрузке тормозим источникЧто хотят услышать
- Зачем пул: стоимость создания и ограничение параллелизма.
- Как работает ThreadPoolExecutor и политика отказа.
- Опасность неограниченной очереди.
- Размер для CPU и I/O задач, ограничение зависимостями, bulkhead.
Частые ошибки
- Брать
Executors.newFixedThreadPoolбез мысли об очереди. - Ставить пул на 500 потоков «для запаса», не глядя на пул соединений к базе.
Проверьте себя
Один верный вариант. Подумайте, потом откройте ответ.
Показать варианты и ответ
- ✗Пул создаёт новый поток на каждую задачу, а размер выбирают ровно по числу ядер и для вычислений, и для ввода-вывода; очередь делают неограниченной
- ✗Очередь пула лучше делать неограниченной
- ✓Пул переиспользует потоки и ограничивает параллелизм; для вычислений потоков ≈ ядер, для ожидания — больше; очередь ограничена
- ✗Пул создаёт новый поток на каждую задачу
Что спросят дальше
Связанные вопросы
Разбор собеседования с sotto
После собеседования sotto разбирает разговор: оценка, каждый вопрос — хорошо, частично или слабо, чего не хватило в ответе и над чем поработать к следующему разу. Первые 30 минут бесплатно.