Еще один тип пула потока – кэшированный. Эти пулы потоков точно так же распространены в использовании, как и фиксированные.

Пул потоков кэширует потоки, отсюда и название. Он держит активными (но не используемыми) потоки в течение ограниченного количества времени, для того чтобы использовать эти потоки для выполнения новых задач. Такой пул потоков лучше всего использовать, когда у нас есть на выполнение некоторое разумное количество легких задач.

Понимание “некоторого разумного количества” довольно растянуто, но нужно понимать, что не для каждого количества задач подойдет такой пул. Например, если мы захотим создать миллион задач, каждая из которых занимает даже совсем малое количество времени, мы все равно необоснованно будем использовать ресурсы и ухудшим производительность. Также мы должны избегать такого пула, когда время выполнения непредсказуемо, например, при задачах ввода-вывода.

Под капотом вызывается конструктор ThreadPoolExecutor со следующими параметрами:

public static ExecutorService newCachedThreadPool() {
    return new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS,
      new SynchronousQueue<Runnable>());
}

В качестве параметров в конструктор передаются следующие значения:

Параметр Значение
corePoolSize (какое количество потоков будет готово (запущено) при старте executor сервиса) 0
maximumPoolSize (Максимальное количество потоков, которое может создать executor сервис) Integer.MAX_VALUE
keepAliveTime (Время, в течение которого освободившийся поток будет жить и впоследствии будет уничтожен, если количество потоков больше corePoolSize) 60L
unit (Временные единицы) TimeUnit.SECONDS
workQueue (Реализация очереди) new SynchronousQueue<Runnable>()

Также в параметры мы можем передать собственную реализацию ThreadFactory.

Поговорим о SynchronousQueue

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

В момент, когда новая задача поступает в очередь, если в пуле есть свободный активный поток, то он забирает эту задачу, а если все потоки заняты — создается новый поток.

Кэшированный пул начинается с нуля потоков и потенциально может вырасти до количества потоков Integer.MAX_VALUE. Практически единственным ограничением для кэшированного пула потоков являются системные ресурсы.

Чтобы управлять системными ресурсами, кэшированные пулы потоков удаляют потоки, которые простаивают в течение одной минуты.

Давайте на практике проверим, как это работает. Мы создаем класс задачи, моделируя запрос пользователя:

public class Task implements Runnable {
   int taskNumber;

   public Task(int taskNumber) {
       this.taskNumber = taskNumber;
   }

   @Override
   public void run() {
       System.out.println("Обработан запрос пользователя №" + taskNumber + " на потоке " + Thread.currentThread().getName());
   }
}

В main мы создаем newCachedThreadPool, после чего добавляем 3 задачи на исполнение. Здесь мы выводим статус нашего сервиса (1).

Далее мы делаем паузу в 30 секунд, после чего запускаем на исполнение еще одну задачу и выводим статус (2).

После чего мы ставим на паузу наш основной поток на 70 секунд, выводим статус (3) и снова добавляем 3 задачи на выполнение и снова печатаем статус (4).

Перед выводом статусов в местах, где статус выводится сразу после добавления задачи, мы поставим sleep на 1 секунду для актуального вывода.

ExecutorService executorService = Executors.newCachedThreadPool();
        for (int i = 0; i < 3; i++) {
            executorService.submit(new Task(i));
        }

        TimeUnit.SECONDS.sleep(1);
            System.out.println(executorService);	//(1)

        TimeUnit.SECONDS.sleep(30);

        executorService.submit(new Task(3));
        TimeUnit.SECONDS.sleep(1);
            System.out.println(executorService);	//(2)

        TimeUnit.SECONDS.sleep(70);

            System.out.println(executorService);	//(3)

        for (int i = 4; i < 7; i++) {
            executorService.submit(new Task(i));
        }

        TimeUnit.SECONDS.sleep(1);
            System.out.println(executorService);	//(4)
        executorService.shutdown();

Итак, результат выполнения:

Обработан запрос пользователя №0 на потоке pool-1-thread-1
Обработан запрос пользователя №1 на потоке pool-1-thread-2
Обработан запрос пользователя №2 на потоке pool-1-thread-3
(1) java.util.concurrent.ThreadPoolExecutor@f6f4d33[Running, pool size = 3, active threads = 0, queued tasks = 0, completed tasks = 3]
Обработан запрос пользователя №3 на потоке pool-1-thread-2
(2) java.util.concurrent.ThreadPoolExecutor@f6f4d33[Running, pool size = 3, active threads = 0, queued tasks = 0, completed tasks = 4]
(3) java.util.concurrent.ThreadPoolExecutor@f6f4d33[Running, pool size = 0, active threads = 0, queued tasks = 0, completed tasks = 4]
Обработан запрос пользователя №4 на потоке pool-1-thread-4
Обработан запрос пользователя №5 на потоке pool-1-thread-5
Обработан запрос пользователя №6 на потоке pool-1-thread-4
(4) java.util.concurrent.ThreadPoolExecutor@f6f4d33[Running, pool size = 2, active threads = 0, queued tasks = 0, completed tasks = 7]

Разберем каждый из шагов:

Шаг Разъяснение
1 (после 3х выполненных задач) Создались 3 потока, на этих трех потоках выполнялись 3 задачи.
В момент вывода статуса все 3 задачи выполнены, а потоки готовы к выполнению других задач.
2 (после паузы в 30 секунд и выполнения еще одной задачи) После 30 секунд бездействия потоки все еще живы и ждут задач.
Добавляется еще одна задача и выполняется на потоке из пула оставшихся живых потоков.
Нового потока в пуле не появилось.
3 (после паузы в 70 секунд) Потоки удалились из пула.
Потоков, готовых принять задачи, нет.
4 (после выполнения еще 3х задач) После того, как поступили еще задачи на выполнение, создались новые потоки, причем в этот раз всего два потока смогли обработать 3 задачи.

Таким образом мы познакомились с работой еще одного вида сервиса со своей логикой управления потоками.

По аналогии с другими утильными методами класса Executors, у метода newCachedThreadPool тоже есть его перегруженный вариант, который в качестве параметра принимает объект типа ThreadFactory.