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 reclamation
  • Executors.newFixedThreadPool(int) — fixed size thread pool
  • Executors.newSingleThreadExecutor() — single background thread
  • Executors.newScheduledThreadPool(int) — fixed size pool supporting delayed and periodic task execution

Example

Consider the constructor that takes the most arguments to instantiate the ThreadPoolExecutor:

Java
public 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 corePoolSize threads and a new task arrives, a new thread is created even if other threads in the pool are idle.
  • When the pool has corePoolSize or more threads but fewer than maximumPoolSize, 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 corePoolSize threads are running, the executor prefers adding a new thread rather than queuing the task.
  • If corePoolSize or 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 typeBehaviormaximumPoolSize effect
SynchronousQueueNo buffering, direct handoffYes — threads grow up to max
LinkedBlockingQueue() (unbounded)Queue grows without limitNo — only core threads
ArrayBlockingQueue(n) or LinkedBlockingQueue(n)BoundedYes — 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.

Java
new ThreadPoolExecutor.AbortPolicy()

CallerRunsPolicy

The thread that called execute() runs the task itself. This throttles submission because the submitter is blocked executing the task.

Java
new 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).

Java
new 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.

Java
new 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 corePoolSize to zero and using an appropriate keepAliveTime, 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 task
  • afterExecute(Runnable r, Throwable t) — called after each task
  • terminated() — 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 Executors factory methods for common cases; use ThreadPoolExecutor when 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; keep maximumPoolSize = corePoolSize.
  • I/O-bound: use higher corePoolSize and maximumPoolSize; consider CallerRunsPolicy for backpressure.
  • Always shut down the pool when it is no longer needed (shutdown() or shutdownNow()).

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.