Получить доступ

Зачем нужен пул потоков и как выбрать его размер?

MiddleКонкурентность
Ещё спрашивают так:
  • Как работает ThreadPoolExecutor?
  • Что будет, если очередь пула неограниченная?
  • Сколько потоков нужно для I/O-задач?
Коротко

Пул держит заранее созданные потоки и очередь задач: не платим за создание потока на каждую задачу и ограничиваем параллелизм.

  • Вычисления: потоков ≈ числу ядер
  • Ожидание (I/O): ядра × (1 + время ожидания / время работы)
  • Очередь должна быть ограниченной, иначе при перегрузке — рост памяти и задержек до OOM

А ещё Политика отказа при полной очереди: отклонить, выполнить в вызывающем потоке, выкинуть старое

Так подсказку видит пользователь sotto на живом собеседовании — за пару секунд, пока вы думаете вслух.

Подробно

Создание потока — системный вызов, выделение стека (около мегабайта), регистрация в планировщике. Делать это на каждый запрос дорого, а главное — неограниченно: при всплеске нагрузки тысячи потоков съедят память и время на переключения. Пул решает обе проблемы: фиксированный набор рабочих потоков берёт задачи из очереди.

**Как устроен 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 слышит вопрос интервьюера и показывает короткий ответ и план, как в блоке «Коротко». Оставьте контакт — пришлём доступ и промокод.

  • Промокод на 2 часа — сверх пробных 30 минут
  • Программа для Windows — скачать сразу, войти — через Яндекс ID или VK ID
  • Прямая связь с разработчиком: что улучшить — сделаем