Представьте ThreadPoolExecutor с одним основным потоком, большим maximumPoolSize и неограниченной очередью: почему при всплеске задач пул обычно не расширяется сверх одного потока?
При неограниченной очереди новые задачи после заполнения основных потоков помещаются в очередь, поэтому до проверки maximumPoolSize дело обычно не доходит. В результате пул может оставаться размером corePoolSize, а задачи будут накапливаться в памяти.
Механизм ThreadPoolExecutor появился как часть java.util.concurrent для отделения отправки задач от управления потоками. Пулы решают проблему дорогого создания потоков, ограничивают их количество и позволяют централизованно обрабатывать перегрузку.
Размер пула и политика очереди проектируются совместно: очередь определяет, когда система должна создавать дополнительные потоки, отклонять задачи или продолжать накапливать работу.
Предположим, что corePoolSize равен одному, maximumPoolSize — четырём, а очередь неограниченная. Если единственный основной поток занят, следующая задача будет принята очередью, а не приведёт к созданию второго потока.
При постоянном поступлении задач очередь может расти быстрее, чем пул успевает их обрабатывать. Это приводит к увеличению задержек, потребления памяти и, в крайнем случае, к OutOfMemoryError. Большое значение maximumPoolSize само по себе не гарантирует параллельную обработку.
При отправке задачи ThreadPoolExecutor действует концептуально в такой последовательности:
У неограниченной очереди операция добавления обычно успешно выполняется практически всегда. Поэтому третий шаг не достигается, пока очередь не будет заполнена; для действительно неограниченной очереди это практически невозможно.
Минимальный пример показывает этот эффект:
Чтобы maximumPoolSize реально участвовал в масштабировании, используют ограниченную очередь, например ArrayBlockingQueue, или очередь без ёмкости, например SynchronousQueue. Ограниченная очередь позволяет сначала накопить ограниченное число задач, затем создавать дополнительные потоки, а после исчерпания ресурсов — отклонять новые задачи.
Компромисс состоит в выборе между задержкой, пропускной способностью и поведением при перегрузке. Большая очередь уменьшает число потоков, но увеличивает время ожидания; маленькая очередь быстрее включает дополнительные потоки, но раньше приводит к отказам и требует корректной политики обработки отказов.
Важно также учитывать, что maximumPoolSize — это верхняя граница, а не целевой размер пула. Потоки сверх corePoolSize создаются только при невозможности поставить задачу в очередь.
Сервис принимает HTTP-запросы и отправляет тяжёлые операции в пул с corePoolSize равным четырём, maximumPoolSize равным двадцати и неограниченной очередью. Во время нагрузки число потоков остаётся равным четырём, очередь растёт, а время ответа увеличивается с сотен миллисекунд до минут.
Вариант с ещё большим maximumPoolSize не помогает: неограниченная очередь по-прежнему принимает задачи. Полностью неограниченный рост потоков тоже опасен — он создаёт конкуренцию за процессор, увеличивает потребление памяти и расходы на переключение контекста.
Практическим решением становится ограниченная очередь, согласованный с нагрузкой maximumPoolSize и явная политика RejectedExecutionHandler. В данном случае часть запросов отклоняется быстро или получает сигнал о перегрузке, зато система сохраняет ограниченные задержки и не скрывает исчерпание ресурсов бесконечным накоплением очереди.
Нет, пока очередь успешно принимает новые задачи. Наличие ожидающих задач само по себе не является условием создания дополнительного потока: сначала executor пытается поставить задачу в очередь. Потоки сверх corePoolSize появляются только после отказа очереди принять задачу и при наличии свободного лимита maximumPoolSize.
После заполнения ограниченной очереди новые задачи не смогут быть поставлены в неё. Тогда executor начнёт создавать потоки до maximumPoolSize. Когда и очередь заполнена, и достигнут максимум потоков, сработает RejectedExecutionHandler.
Такой вариант делает перегрузку наблюдаемой и ограничивает потребление памяти, но требует выбрать разумную ёмкость очереди и корректно обработать отказ. Слишком маленькая очередь вызовет частые отказы, а слишком большая снова приведёт к значительным задержкам.
SynchronousQueue не хранит элементы: передача задачи завершается только при непосредственной передаче её потоку-получателю. Поэтому после достижения corePoolSize executor не может просто накопить задачи в очереди и начинает создавать дополнительные потоки до maximumPoolSize.
Это хорошо подходит для задач, которые нужно запускать немедленно и параллельно, но может быстро привести к достижению максимума и отказам при всплеске нагрузки. Для задач, допускающих короткое ожидание, ограниченная буферная очередь обычно даёт более плавное поведение.