Thread Pools in Java (Core Parameters)
Explore the ThreadPoolExecutor class in Java to manage thread pools effectively. Understand its parameters like core and maximum pool size, queuing strategies, task rejection policies, and shutdown mechanisms. This article helps you configure and fine-tune thread pools for advanced concurrency scenarios in Java.
Overview
In general, a thread pool is a group of threads instantiated and kept alive to execute submitted tasks. Thread pools achieve better performance and throughput than creating an individual thread per task by circumventing the overhead associated with thread creation and destruction. Additionally, system resources can be better managed using a thread pool, which allows you to limit the number of threads in the system.
Generally the use of the ThreadPoolExecutor class is reserved for scenarios where you need fine-grained control. The Executors factory methods provide pre-configured thread pools that work well for most use cases. Before we delve into ThreadPoolExecutor, here are the common pools from Executors:
Executors.newCachedThreadPool()— unbounded thread pool, with automatic thread reclamationExecutors.newFixedThreadPool(int)— fixed size thread poolExecutors.newSingleThreadExecutor()— single background threadExecutors.newScheduledThreadPool(int)— fixed size pool supporting delayed and periodic task execution
Example
Consider the constructor that takes the most arguments to instantiate the ThreadPoolExecutor:
Javapublic ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue, RejectedExecutionHandler handler)
We will study how each of these arguments impacts the behavior of the executor. First, a simple example program:
Java// main.java import java.util.concurrent.*; public class Main { public static void main(String[] args) { ThreadPoolExecutor executor = new ThreadPoolExecutor( 2, // corePoolSize 5, // maximumPoolSize 60, TimeUnit.SECONDS, // keepAliveTime new ArrayBlockingQueue<>(10), new ThreadPoolExecutor.AbortPolicy() ); for (int i = 0; i < 10; i++) { final int taskId = i; executor.submit(() -> { System.out.println("Task " + taskId + " on " + Thread.currentThread().getName()); try { Thread.sleep(100); } catch (InterruptedException e) {} }); } executor.shutdown(); try { executor.awaitTermination(5, TimeUnit.SECONDS); } catch (InterruptedException e) { executor.shutdownNow(); } } }
The first five tasks may run immediately (up to five threads). The next five go into the queue. If more than 15 tasks were submitted (5 threads + 10 queue capacity), additional tasks would be rejected.
corePoolSize and maximumPoolSize
The arguments corePoolSize and maximumPoolSize together determine the number of threads created in the pool. The workflow is as follows:
- When the pool has fewer than
corePoolSizethreads and a new task arrives, a new thread is created even if other threads in the pool are idle. - When the pool has
corePoolSizeor more threads but fewer thanmaximumPoolSize, a new thread is created only if the work queue is full. - The maximum number of threads is capped by
maximumPoolSize.
Both corePoolSize and maximumPoolSize can be changed dynamically after construction. Note that a newly created pool creates core threads only when tasks start arriving. You can override this by calling prestartCoreThread() or prestartAllCoreThreads(), which is useful when creating a pool with a pre-populated queue.
Setting corePoolSize equal to maximumPoolSize — creates a fixed-size thread pool.
Setting maximumPoolSize to a very large value (e.g. Integer.MAX_VALUE) — allows the pool to accommodate an arbitrary number of concurrent tasks, at the risk of exhausting system resources.
Keep-Alive
A thread pool eliminates threads in excess of corePoolSize after keepAliveTime has elapsed. The unit argument specifies the TimeUnit for keepAliveTime (milliseconds, seconds, minutes, etc.). Idle non-core threads are removed to free resources.
ThreadFactory
The pool creates new threads using a ThreadFactory. You can pass your own factory to customize thread names, thread group, priority, or daemon status. If not specified, the default factory is used.
Queuing
The ThreadPoolExecutor takes a BlockingQueue<Runnable> as the work queue. The queue holds tasks submitted to the executor and works in tandem with the pool's thread size:
- If fewer than
corePoolSizethreads are running, the executor prefers adding a new thread rather than queuing the task. - If
corePoolSizeor more threads are running, the executor prefers queuing the task. - If the queue is full and creating a new thread would exceed
maximumPoolSize, the task is rejected.
Queuing Strategies
Direct handoffs (SynchronousQueue)
SynchronousQueue has no internal capacity. An item can be inserted only if another thread is simultaneously removing it. If you add an item without a consumer, the inserting thread blocks.
Java// Using SynchronousQueue: no queue capacity, tasks handed off directly ThreadPoolExecutor executor = new ThreadPoolExecutor( 2, 5, 60, TimeUnit.SECONDS, new SynchronousQueue<>(), new ThreadPoolExecutor.AbortPolicy() ); // Submit 6 tasks quickly — the 6th may be rejected if all 5 threads are busy for (int i = 0; i < 6; i++) { final int id = i; try { executor.submit(() -> { try { Thread.sleep(2000); } catch (InterruptedException e) {} System.out.println("Task " + id); }); } catch (RejectedExecutionException e) { System.out.println("Task " + id + " rejected"); } }
With SynchronousQueue, each task is handed off directly to a thread. If no free thread exists and the pool has reached maximumPoolSize, the task is rejected. This strategy requires an unbounded or high maximumPoolSize to avoid rejections under load. It is useful when tasks may have internal dependencies and you want to avoid lockups from queued tasks.
Unbounded queues (LinkedBlockingQueue without capacity)
If you use LinkedBlockingQueue() without a capacity, the queue can grow indefinitely. Tasks are queued when all core threads are busy. maximumPoolSize has no effect — only corePoolSize threads are ever created.
Java// Unbounded queue: only corePoolSize threads, rest queue up ThreadPoolExecutor executor = new ThreadPoolExecutor( 2, 10, 60, TimeUnit.SECONDS, new LinkedBlockingQueue<>(), // unbounded new ThreadPoolExecutor.AbortPolicy() ); for (int i = 0; i < 20; i++) { final int id = i; executor.submit(() -> { System.out.println("Task " + id + " on " + Thread.currentThread().getName()); try { Thread.sleep(500); } catch (InterruptedException e) {} }); } // Only 2 threads ever run; 18 tasks wait in the queue
You can also use LinkedBlockingQueue(capacity) with a defined capacity. In that case, the executor can reject tasks when the queue is full and maximumPoolSize threads are busy.
Bounded queues
A bounded queue with a finite maximumPoolSize helps prevent resource exhaustion. There is a tradeoff:
- Large queue + small pool: lower CPU and context-switching, but potentially low throughput and high latency.
- Small queue + larger pool: CPUs busier, but more scheduling overhead.
Choose based on your workload (CPU-bound vs I/O-bound).
Queue summary
| Queue type | Behavior | maximumPoolSize effect |
|---|---|---|
SynchronousQueue | No buffering, direct handoff | Yes — threads grow up to max |
LinkedBlockingQueue() (unbounded) | Queue grows without limit | No — only core threads |
ArrayBlockingQueue(n) or LinkedBlockingQueue(n) | Bounded | Yes — threads grow when queue full |
Task Rejection
When the executor cannot accept a task (queue full and at max threads, or executor shut down), it invokes the RejectedExecutionHandler. Four built-in policies:
AbortPolicy (default)
Throws RejectedExecutionException. Use when you want failures to be visible and handled explicitly.
Javanew ThreadPoolExecutor.AbortPolicy()
CallerRunsPolicy
The thread that called execute() runs the task itself. This throttles submission because the submitter is blocked executing the task.
Javanew ThreadPoolExecutor.CallerRunsPolicy()
When the pool is saturated, the main thread (or whichever thread submits) will execute tasks directly, slowing down the submission rate.
DiscardPolicy
Silently drops the task. Use when loss is acceptable (e.g. best-effort logging).
Javanew ThreadPoolExecutor.DiscardPolicy()
DiscardOldestPolicy
Discards the oldest unhandled task in the queue and retries submission of the new task. If the executor is shut down, the new task is discarded.
Javanew ThreadPoolExecutor.DiscardOldestPolicy()
Shutting Down
Shut down the pool by calling shutdown(). The pool stops accepting new tasks and finishes existing ones. For immediate shutdown, use shutdownNow(), which interrupts running tasks and returns the list of queued tasks.
If shutdown() is not called, ensure unused threads eventually die by:
- Setting
corePoolSizeto zero and using an appropriatekeepAliveTime, or - Calling
allowCoreThreadTimeOut(true)so core threads also time out when idle.
Hooks
ThreadPoolExecutor exposes protected methods you can override:
beforeExecute(Thread t, Runnable r)— called before each taskafterExecute(Runnable r, Throwable t)— called after each taskterminated()— called when the executor has fully terminated
Use these for logging, metrics, or reinitializing ThreadLocals. Exceptions thrown in these hooks can cause worker threads to terminate.
Key Rules
- Prefer
Executorsfactory methods for common cases; useThreadPoolExecutorwhen you need fine-grained control. - Use a bounded queue and an explicit rejection policy to avoid unbounded growth and OOM.
- CPU-bound:
corePoolSize≈ CPU cores; keepmaximumPoolSize=corePoolSize. - I/O-bound: use higher
corePoolSizeandmaximumPoolSize; considerCallerRunsPolicyfor backpressure. - Always shut down the pool when it is no longer needed (
shutdown()orshutdownNow()).
What's Next
For the basics of thread pools and ExecutorService, see Thread Pools in Java. For deadlock risks when tasks depend on each other, see Deadlock - How to Detect and Prevent.