Another type of thread pool is "cached". Such thread pools are just as commonly used as fixed ones.

As indicated by the name, this kind of thread pool caches threads. It keeps unused threads alive for a limited time in order to reuse those threads to perform new tasks. Such a thread pool is best for when we have some reasonable amount of light work.

The meaning of "some reasonable amount" is rather broad, but you should know that such a pool is not suitable for every number of tasks. For example, suppose we want to create a million tasks. Even if each takes a very small amount of time, we will still use an unreasonable amount of resources and degrade performance. We should also avoid such pools when the execution time is unpredictable, for example, with I/O tasks.

Under the hood, the ThreadPoolExecutor constructor is called with the following arguments:

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

The following values are passed to the constructor as arguments:

Parameter Value
corePoolSize (how many threads will be ready (started) when the executor service starts) 0
maximumPoolSize (the maximum number of threads that an executor service can create) Integer.MAX_VALUE
keepAliveTime (the time that a freed thread will continue to live before being destroyed if the number of threads is greater than corePoolSize) 60L
unit (units of time) TimeUnit.SECONDS
workQueue (implementation of a queue) new SynchronousQueue<Runnable>()

And we can pass our own implementation of ThreadFactory in exactly the same way.

Let's talk about SynchronousQueue

The basic idea of a synchronous transfer is quite simple and yet counter-intuitive (that is, intuition or common sense tells you that it is wrong): you can add an element to a queue if and only if another thread receives the element at the same time. In other words, a synchronous queue cannot have tasks in it, because as soon as a new task arrives, the executing thread has already picked up the task.

When a new task enters the queue, if there is a free active thread in the pool, then it picks up the task. If all the threads are busy, then a new thread is created.

A cached pool starts with zero threads and can potentially grow to Integer.MAX_VALUE threads. Essentially, the size of a cached thread pool is limited only by system resources.

To conserve system resources, cached thread pools remove threads that are idle for one minute.

Let's see how it works in practice. We'll create a task class that models a user request:

public class Task implements Runnable {
   int taskNumber;

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

   @Override
   public void run() {
       System.out.println("Processed user request #" + taskNumber + " on thread " + Thread.currentThread().getName());
   }
}

In the main method, we create newCachedThreadPool and then add 3 tasks for execution. Here we print the status of our service (1).

Next, we pause for 30 seconds, start another task, and display the status (2).

After that, we pause our main thread for 70 seconds, print the status (3), then again add 3 tasks, and again print the status (4).

In places where we display the status immediately after adding a task, we first add a 1-second sleep for up-to-date output.

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();

And here's the result:

Processed user request #0 on pool-1-thread-1 thread
Processed user request #1 on pool-1-thread-2 thread
Processed user request #2 on pool-1-thread-3 thread
(1) java.util.concurrent.ThreadPoolExecutor@f6f4d33[Running, pool size = 3, active threads = 0, queued tasks = 0, completed tasks = 3]
Processed user request #3 on pool-1-thread-2 thread
(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]
Processed user request #4 on pool-1-thread-4 thread
Processed user request #5 on pool-1-thread-5 thread
Processed user request #6 on pool-1-thread-4 thread
(4) java.util.concurrent.ThreadPoolExecutor@f6f4d33[Running, pool size = 2, active threads = 0, queued tasks = 0, completed tasks = 7]

Let's go over each of the steps:

Step Explanation
1 (after 3 completed tasks) We created 3 threads, and 3 tasks were executed on these three threads.
When the status is displayed, all 3 tasks are done, and the threads are ready to perform other tasks.
2 (after 30-second pause and execution of another task) After 30 seconds of inactivity, the threads are still alive and waiting for tasks.
Another task is added and executed on a thread taken from the pool of the remaining live threads.
No new thread was added to the pool.
3 (after a 70-second pause) The threads have been removed from the pool.
There are no threads ready to accept tasks.
4 (after executing 3 more tasks) After more tasks were received, new threads were created. This time just two threads managed to process 3 tasks.

Well, now you're acquainted with the logic of another type of executor service.

By analogy with other methods of the Executors utility class, the newCachedThreadPool method also has an overloaded version that takes a ThreadFactory object as an argument.