Before writing production code based on Java's executors, we should learn another inseparable concept of concurrency, thread pools.
Data Engineering Design Patterns

Looking for a book that defines and solves most common data engineering problems? I wrote
one on that topic! You can read it online
on the O'Reilly platform,
or get a print copy on Amazon.
I also help solve your data engineering problems 👉 contact@waitingforcode.com 📩
This article can be considered as a continuation of the article about executor service in Java. Its first part will explain what thread pools are. The second part will present thread pools in Java. After that, we'll see thread pools in action.
What thread pool are ?
Thread pool pattern looks like object pool design pattern. We create an container (pool) which holds a specific number of objects (threads in occurrence). The specificity of thread pool is that the threads created inside the pool must perform some tasks which are usually placed in a queue. When all tasks are executed, thread can both terminate or waiting for another tasks.
A big advantage of thread pool over threads creation at demand, is the memory influence. New thread creation and destruction for every task are the operations using a lot of memory which can be overloaded quickly. Thread recycling through a pool doesn't have so bad effects on application performance. In additionally, by using thread pool we don't need to deal with opening or closing threads because it's handled internally by Java's concurrent package. It limits a source of application bugs.
Thread pools in Java
Tasks executed in thread pool as called worker threads. As we mentioned earlier, threads are recycled. It's why worker threads can support only Runnable or Callable instances. Different types of thread pools are supported by Java's concurrent package:
- fixed-size thread pool created pool is with fixed size. It means that the pool always has fixed number of threads. If one thread terminates while it's still in use, it's replaced by new thread. Extra tasks which can't be handled at given moment, are hold in queue and executed as soon as one thread is available. The advantage of this type that it doesn't overload application resources. Ie. if resources allocated to the application can handle only 5 threads that download big Excel file, fixed-size thread pool won't force to create a 6th thread to handle freshly requested download. Thanks to it, we avoid a whole system failure and the necessity of restart it.
- cached thread pool unlike previous one, it creates a dynamic-sized thread pool. It means that the threads are created as long as application demands the new threads. Come back to case with big Excel files downloading. We start by download 5 files, each one in separate thread. With cached thread pool, all new download request will be make in new thread if no existing thread is available. So, if one of initial 5 threads ends and wasn't removed from the pool, the new download request will be made inside this thread. If this thread was removed, a new thread is created. Threads are removing if they are not used during 60 seconds. During this time, a thread that ended its task, remains in idle state and doesn't consume any resources.
- single thread pool only one worker thread is created. In consequence, only one task can be executed by this pool. If this thread fails or is shutdown, a new one is created at its place.
Java 8 introduces a supplementary type of thread pool, called work-stealing pool. Unlike previously seen pools, it determines the threads quantity automatically. Instead of taking this quantity in parameter, work stealing pool calculates it thanks to parallelism level specified in factory method newWorkStealingPool(int parallelism).
Thread pool can be got through one of Executors factory method: newFixedThreadPool for fixed-size, newCachedThreadPool for cached, newSingleThreadExecutor for single and newWorkStealingPool for stealing-work pool. Another way to create the pools are following classes: ScheduledThreadPoolExecutor and ThreadPoolExecutor.
Thread pools example in Java
In our test case we'll use thread sleeping feature to synchronize threads execution and check if expected number of threads is executed.
public class ThreadPoolTest { @Test public void testFixed() throws InterruptedException { ExecutorService fixedPool = Executors.newFixedThreadPool(2); fixedPool.execute(new SampleTask("Task-1", Executions.fixed)); fixedPool.execute(new SampleTask("Task-2", Executions.fixed)); fixedPool.execute(new SampleTask("Task-3", Executions.fixed)); fixedPool.execute(new SampleTask("Task-4", Executions.fixed)); Thread.sleep(50); assertTrue("2 tasks should be running, but only "+ Executions.fixed.getValue() + " were", Executions.fixed.getValue() == 2); System.out.println("----------------- executing new threads --------------------------"); Executions.fixed.reset(); Thread.sleep(4000); assertTrue("2 tasks should be running, but only "+ Executions.fixed.getValue() + " were", Executions.fixed.getValue() == 2); } @Test public void testCached() throws InterruptedException { ExecutorService cachedPool = Executors.newCachedThreadPool(); cachedPool.execute(new SampleTask("Task-1", Executions.cached)); cachedPool.execute(new SampleTask("Task-2", Executions.cached)); Thread.sleep(2000); assertTrue("2 tasks should be running, but only "+ Executions.cached.getValue() + " were", Executions.cached.getValue() == 2); System.out.println("----------------- executing new threads --------------------------"); Executions.cached.reset(); cachedPool.execute(new SampleTask("Task-3", Executions.cached)); cachedPool.execute(new SampleTask("Task-4", Executions.cached)); cachedPool.execute(new SampleTask("Task-5", Executions.cached)); cachedPool.execute(new SampleTask("Task-6", Executions.cached)); cachedPool.execute(new SampleTask("Task-7", Executions.cached)); Thread.sleep(3000); assertTrue("5 tasks should be running, but only "+ Executions.cached.getValue() + " were", Executions.cached.getValue() == 5); System.out.println("----------------- executing new threads --------------------------"); Executions.cached.reset(); cachedPool.execute(new SampleTask("Task-8", Executions.cached)); cachedPool.execute(new SampleTask("Task-9", Executions.cached)); cachedPool.execute(new SampleTask("Task-10", Executions.cached)); cachedPool.execute(new SampleTask("Task-11", Executions.cached)); cachedPool.execute(new SampleTask("Task-12", Executions.cached)); cachedPool.execute(new SampleTask("Task-13", Executions.cached)); cachedPool.execute(new SampleTask("Task-14", Executions.cached)); cachedPool.execute(new SampleTask("Task-15", Executions.cached)); Thread.sleep(3000); assertTrue("8 tasks should be running, but only "+ Executions.cached.getValue() + " were", Executions.cached.getValue() == 8); } @Test public void testSingle() throws InterruptedException { ExecutorService fixedPool = Executors.newSingleThreadExecutor(); fixedPool.execute(new SampleTask("Task-1", Executions.single)); fixedPool.execute(new SampleTask("Task-2", Executions.single)); fixedPool.execute(new SampleTask("Task-3", Executions.single)); fixedPool.execute(new SampleTask("Task-4", Executions.single)); Thread.sleep(50); assertTrue("1 task should be running, but only "+ Executions.single.getValue() + " were", Executions.single.getValue() == 1); Executions.single.reset(); for (int i = 0; i < 3; i++) { System.out.println("----------------- executing new thread --------------------------"); Thread.sleep(1000); assertTrue("1 task should be running, but only "+ Executions.single.getValue() + " were", Executions.single.getValue() == 1); Executions.single.reset(); } } } class SampleTask implements Runnable { private String name; private Executions executions; public SampleTask(String name, Executions exe) { this.name = name; executions = exe; } public SampleTask(String name) { this.name = name; } @Override public void run() { this.executions.increment(); System.out.println(this.name+": executing SampleTask"); Thread.sleep(1000); System.out.println(this.name+": was executed"); } } enum Executions { fixed, cached, single; private int value = 0; public void reset() { this.value = 0; } public synchronized void increment() { this.value++; } public int getValue() { return value; } }
The test should pass well. There are no mysterious things to explain. As we expected, in fixed-size thread pool, a fixed number of thread is created. In cached pool, the pool's capacity increases at demand. Regarding to single thread pool, only one thread is executed at given moment.
Pooling, known usually from object pool design pattern, was implemented in Java concurrency environment. Thanks to fixed-sized, cached or single thread pools, we can better control the consequences of concurrent tasks on application. As we can see in this article, every pool type behaves differently when tasks number to treat grows. Fixed-sized pool puts all supplementary tasks in a queue and treats them when pool's threads are available. Cached pool makes new threads instead. Single-thread pool is working with only thread at a moment and the rest are placed in the queue.
Consulting

With nearly 16 years of experience, including 8 as data engineer, I offer expert consulting to design and optimize scalable data solutions.
As an O’Reilly author, Data+AI Summit speaker, and blogger, I bring cutting-edge insights to modernize infrastructure, build robust pipelines, and
drive data-driven decision-making. Let's transform your data challenges into opportunities—reach out to elevate your data engineering game today!
👉 contact@waitingforcode.com
đź”— past projects