Skip to content
Chapter 35 · Core Java

Thread Pools in Java: ThreadPoolExecutor In Depth ​

Thread pools are one of those topics that every Java developer needs to understand deeply, not just at a surface level. It comes up in interviews constantly, and it comes up even more in day to day work where getting the configuration wrong can bring down your application under load. This article covers everything: why thread pools exist, how every parameter in ThreadPoolExecutor works, the exact decision process when a task arrives, all rejection policies, the difference between execute and submit, the shutdown lifecycle, and the real interview questions that trip people up.


Why Thread Pools Exist ​

Before you can appreciate what a thread pool does for you, you need to feel the pain of the alternative.

Imagine you are building a web server. Every time a request arrives, you create a brand new thread to handle it, do the work, and let the thread die when the work is done. This sounds reasonable until you think about what actually happens at the operating system level.

Creating a thread is not free. The operating system has to allocate stack memory for that thread, set up a program counter, create registers, and do a bunch of bookkeeping to make the thread schedulable. All of that takes time. For a single request it might feel instantaneous, but if your server is handling thousands of requests per second, you are spending a significant portion of your CPU just creating and destroying threads rather than doing actual work. Thread pools solve this by creating a batch of threads upfront, keeping them alive and ready to pick up tasks, and reusing them over and over. The creation cost is paid once, not on every request.

The second problem is even more dangerous: unbounded thread creation. If you create a thread per request and traffic spikes to ten thousand concurrent requests, you have ten thousand threads. Each thread needs its own stack space. On a typical JVM setup, a single thread might need 1 MB or more of stack space. Ten thousand threads means ten thousand megabytes of stack alone, which is ten gigabytes. Unless you have a very unusual machine, your JVM crashes with an OutOfMemoryError long before that. Thread pools give you a hard ceiling on how many threads can exist at once.

The third problem is context switching. Your CPU cores are the only ones doing real work. If you have eight cores and you have created a hundred threads, those hundred threads are not all running at the same time. The CPU switches between them rapidly, giving each one a tiny slice of time. Every switch requires saving the current thread's state and loading the next one's state. More threads means more context switching overhead and less actual processing. The goal of a well configured thread pool is to have enough threads to keep all CPU cores busy without so many threads that context switching eats your performance alive.

Thread pools solve all three problems simultaneously: they eliminate repetitive thread creation cost, cap maximum memory usage, and keep context switching under control.


The Executor Framework ​

Java wraps thread pools inside a clean hierarchy of interfaces and classes. Understanding the hierarchy matters because you will see these names constantly.

At the very top sits the Executor interface. It has exactly one method: execute(Runnable task). That is it. You give it a task and it runs it somewhere.

ExecutorService extends Executor and adds much richer lifecycle management: methods to submit tasks with return values, shut down the pool, wait for termination, and more.

AbstractExecutorService is a partial implementation of ExecutorService that handles common logic so concrete classes do not have to.

ThreadPoolExecutor extends AbstractExecutorService and is the core implementation you will use directly or indirectly. When you call Executors.newFixedThreadPool() or Executors.newCachedThreadPool(), those factory methods create a ThreadPoolExecutor underneath with specific parameter values.


The ThreadPoolExecutor Constructor: Seven Parameters ​

Here is the full constructor:

java
ThreadPoolExecutor executor = new ThreadPoolExecutor(
    int corePoolSize,
    int maximumPoolSize,
    long keepAliveTime,
    TimeUnit unit,
    BlockingQueue<Runnable> workQueue,
    ThreadFactory threadFactory,
    RejectedExecutionHandler handler
);

Seven parameters. Every single one matters. Walk through them one at a time.

Parameter 1: corePoolSize ​

This is the minimum number of threads that will always be kept alive in the pool, even when they have nothing to do. Think of it as your standing army. You pay to keep them ready even during quiet periods.

When you submit the very first task to a brand new pool, the pool creates a new thread to handle it, even if corePoolSize is ten and only one task has arrived. Threads are created lazily. But once created, they stick around permanently. If you set corePoolSize to four, the pool will never have fewer than four live threads once it has started creating them, unless you explicitly configure it to allow core thread timeout.

Why not just set this to the maximum and be done with it? Because keeping threads alive costs memory and has a small overhead. You want to be thoughtful about how many threads sit around burning resources during normal, non peak traffic.

Parameter 2: maximumPoolSize ​

This is the absolute ceiling on how many threads can exist in the pool at any one time. The pool will never create more threads than this number.

The relationship between corePoolSize and maximumPoolSize defines your elasticity. The pool starts with up to corePoolSize threads for everyday work. During a traffic spike, it can grow beyond that, up to maximumPoolSize. The extra threads beyond the core size are temporary: they get cleaned up when they have been idle for longer than keepAliveTime.

Parameter 3: keepAliveTime and Parameter 4: TimeUnit ​

These two parameters work together. keepAliveTime is the duration, and TimeUnit is its unit (seconds, milliseconds, minutes, etc.).

When the pool has more threads than corePoolSize and some of those extra threads sit idle for longer than keepAliveTime, the pool terminates them. This prevents you from holding onto resources you no longer need after a traffic spike passes.

By default, this timeout applies only to the excess threads beyond corePoolSize. However, if you call allowCoreThreadTimeOut(true), even the core threads can be terminated after being idle for keepAliveTime. This is useful when you know your application goes through long quiet periods where you want to release all resources.

java
// Allow core threads to time out after 60 seconds of idleness
executor.allowCoreThreadTimeOut(true);

Parameter 5: workQueue (BlockingQueue) ​

This is the queue where incoming tasks wait when all core threads are busy. Think of it as a waiting room for tasks that have not yet been picked up.

There are two categories you need to know:

Bounded queues have a fixed capacity. ArrayBlockingQueue is the standard choice. When the queue is full and all threads are busy, new tasks either trigger more thread creation (up to maximumPoolSize) or get rejected.

Unbounded queues like LinkedBlockingQueue with no capacity argument can grow without limit. This sounds convenient but is dangerous. If tasks arrive faster than they can be processed, the queue keeps growing until you run out of heap memory. When you use an unbounded queue, maximumPoolSize effectively becomes meaningless because the queue never fills up, so extra threads beyond corePoolSize are never created.

java
// Bounded queue - safer, predictable memory usage
BlockingQueue<Runnable> queue = new ArrayBlockingQueue<>(100);

// Unbounded queue - use with caution
BlockingQueue<Runnable> queue = new LinkedBlockingQueue<>();

Parameter 6: ThreadFactory ​

The thread pool itself creates threads internally. By default it uses a factory that generates generic names like pool-1-thread-1. The ThreadFactory parameter lets you inject your own factory to customize how threads are created.

Why would you want this? Three main reasons:

  • Custom names: When you read a thread dump during a production incident, seeing order processor thread 3 is far more useful than pool-2-thread-3.
  • Priority: You can set the thread priority for every thread the pool creates.
  • Daemon flag: You can make all threads daemon threads, which means they will not prevent the JVM from shutting down.
java
ThreadFactory factory = new ThreadFactory() {
    private int count = 0;

    @Override
    public Thread newThread(Runnable r) {
        Thread t = new Thread(r);
        t.setName("order processor thread " + (++count));
        t.setDaemon(false);
        t.setPriority(Thread.NORM_PRIORITY);
        return t;
    }
};

If you do not provide one, ThreadPoolExecutor uses Executors.defaultThreadFactory() internally.

Parameter 7: RejectedExecutionHandler ​

When a task cannot be accepted because all threads are busy, the queue is full, and the pool is at maximum size, the pool has to do something. What it does is determined by the RejectedExecutionHandler. There are four built in options, and you can write your own.


The Four Step Task Arrival Decision ​

This is the most important thing to understand about ThreadPoolExecutor, and it is also the source of the most common interview mistake. When a new task arrives, the pool follows exactly this sequence:

Step 1: If the number of currently running threads is less than corePoolSize, create a new thread immediately and assign this task to it. This happens even if other threads are idle. The pool prefers to grow to core size rather than reuse idle threads. Once core size is reached, this step stops triggering.

Step 2: If the thread count has reached corePoolSize, try to add the task to the queue. If there is space in the queue, the task waits there. No new threads are created.

Step 3: If the queue is full, check if the current thread count is below maximumPoolSize. If yes, create a new thread and assign this task directly to it, bypassing the queue.

Step 4: If the thread count is already at maximumPoolSize and the queue is full, there is nowhere to put this task. The RejectedExecutionHandler is called.

Written as a decision tree:

New task arrives
      |
      v
`Running threads < corePoolSize?`
      |                |
     YES              NO
      |                |
Create new thread    Try to add to queue
      |                |
Task runs         Queue has space?
                       |          |
                      YES        NO
                       |          |
                  Task waits   Running threads < maximumPoolSize?
                                   |                |
                                  YES              NO
                                   |                |
                             Create new thread   REJECT
                             (task bypasses queue)

The Spawn or Queue First Question ​

Here is the interview question that trips up experienced developers: in a ThreadPoolExecutor, does it spawn extra threads first or fill the queue first?

The answer is: it fills the queue first.

When all core threads are busy, the pool does not immediately create extra threads up to maximumPoolSize. It first tries to put the task into the queue. Only when the queue is full does it create additional threads beyond the core size, and only up to maximumPoolSize.

This surprises people because they assume the pool will use all available thread capacity before falling back to queuing. It does the opposite. If you have corePoolSize = 3, maximumPoolSize = 10, and a queue capacity of 100, and you submit 50 tasks all at once: the pool creates 3 threads, fills the queue with 47 tasks, and never creates threads 4 through 10 unless the queue fills up completely.

The practical implication: if you want the pool to scale up beyond core size quickly, use a small or bounded queue. If you want to absorb bursts without creating extra threads, use a large queue.


The Four Rejection Policies ​

When a task gets rejected (step 4 above), one of four things happens based on your configured handler.

AbortPolicy (Default) ​

Throws a RejectedExecutionException. The calling code must catch this or the thread submitting the task will crash.

java
// Default behavior - throws exception on rejection
RejectedExecutionHandler handler = new ThreadPoolExecutor.AbortPolicy();

This is the safest choice from a visibility perspective. Failures are loud and obvious. You will know immediately when your pool is overwhelmed.

DiscardPolicy ​

Silently drops the rejected task. No exception, no log message, nothing. The task just disappears.

java
RejectedExecutionHandler handler = new ThreadPoolExecutor.DiscardPolicy();

Use this only when losing tasks is genuinely acceptable, such as processing metrics or non critical events where missing a few data points does not matter.

CallerRunsPolicy ​

Executes the rejected task in the thread that submitted it, not in any pool thread. If your main thread submitted the task, then your main thread runs the task.

java
RejectedExecutionHandler handler = new ThreadPoolExecutor.CallerRunsPolicy();

This is an elegant backpressure mechanism. When the pool is overwhelmed, the submitting thread gets busy executing the task itself, which naturally slows down the rate at which new tasks arrive. The pool gets a chance to catch up. The downside is that it can slow down or block your submitting thread, which might be a problem if that thread is handling incoming network connections.

DiscardOldestPolicy ​

Discards the oldest task sitting at the head of the queue (the one that has been waiting the longest), then retries submitting the new task.

java
RejectedExecutionHandler handler = new ThreadPoolExecutor.DiscardOldestPolicy();

This makes sense in situations where newer work is more valuable than older work, such as real time data processing pipelines where stale data is useless. However, it is worth noting that this silently drops a task that has already been waiting, which can cause confusing behavior if you are not expecting it.

Custom Handler ​

You can implement RejectedExecutionHandler yourself to do anything you want: log the rejection, write the task to a database, send an alert, or put the task back into a different queue.

java
RejectedExecutionHandler customHandler = (task, executor) -> {
    System.err.println("Task rejected: " + task.toString());
    // Could log to monitoring system, write to dead letter queue, etc.
};

execute vs submit: What Is the Actual Difference ​

Both methods submit a task to the pool, but there are important differences.

execute(Runnable task) ​

execute is defined on the base Executor interface. It accepts a Runnable and returns nothing. If the task throws an unchecked exception, it propagates to the thread's uncaught exception handler.

java
executor.execute(() -> {
    System.out.println("Running in thread: " + Thread.currentThread().getName());
});

You use execute when you just want to fire and forget. You do not care about a return value.

submit(Runnable task) and submit(Callable<T> task) ​

submit is defined on ExecutorService. It can accept either a Runnable or a Callable<T>. It returns a Future<T> that you can use to:

  • Check if the task is done yet
  • Wait for the result (blocking)
  • Cancel the task
  • Retrieve exceptions that occurred during execution
java
// Submit a Callable that returns a result
Future<String> future = executor.submit(() -> {
    Thread.sleep(1000);
    return "result from background thread";
});

// Do other work here while task runs...

// Block until result is ready
String result = future.get();
System.out.println(result);

A critical difference: with execute, if your task throws a runtime exception, it goes to the thread's uncaught exception handler. With submit, exceptions are captured inside the Future. They do not surface until you call future.get(), at which point they are wrapped in an ExecutionException. This means if you submit tasks with submit but never call get(), exceptions can silently disappear.

java
Future<?> future = executor.submit(() -> {
    throw new RuntimeException("something went wrong");
});

// Exception is silent until you call get()
future.get(); // throws ExecutionException wrapping the RuntimeException

Use execute for fire and forget tasks. Use submit when you need the result or need to handle exceptions explicitly.


The Shutdown Lifecycle ​

ThreadPoolExecutor goes through several states during its lifetime. Understanding this is important for graceful shutdown in production services.

Running State ​

This is the normal state. The pool accepts new tasks and processes queued tasks.

shutdown() ​

When you call shutdown(), the pool enters the Shutdown state. Two things happen:

  1. The pool stops accepting any new tasks. If you try to submit a new task after calling shutdown(), it gets rejected by the RejectedExecutionHandler.
  2. The pool continues processing all tasks that are already in progress or waiting in the queue.

Once all queued and in progress tasks complete, the pool moves to the Terminated state and all threads are released.

java
executor.shutdown();
// No new tasks accepted here
// Existing tasks finish normally

shutdownNow() ​

This is the force stop option. It does three things:

  1. Stops accepting new tasks.
  2. Attempts to interrupt all currently running threads by calling interrupt() on each one.
  3. Returns a List<Runnable> containing all tasks that were waiting in the queue but never started.

Note that interrupting a thread does not guarantee it stops immediately. If your task does not check Thread.interrupted() or call interruptible blocking operations, it keeps running despite the interrupt. shutdownNow() is a request to stop, not a command.

java
List<Runnable> unprocessedTasks = executor.shutdownNow();
System.out.println("Tasks that never ran: " + unprocessedTasks.size());

awaitTermination() ​

After calling shutdown() or shutdownNow(), you often want to wait for the pool to fully finish before your application exits. awaitTermination blocks the calling thread until the pool reaches terminated state or the timeout expires.

java
executor.shutdown();
if (!executor.awaitTermination(30, TimeUnit.SECONDS)) {
    executor.shutdownNow(); // Force stop if tasks are taking too long
}

This pattern is best practice for graceful shutdown: give tasks a chance to finish normally, then force stop if they take too long.

The Stop State ​

The stop state is reached after shutdownNow(). The pool does not accept new tasks and it interrupts running threads. Tasks waiting in the queue that have not started yet are not executed.

The progression looks like this:

Running → shutdown() → Shutdown (existing tasks complete) → Terminated
Running → shutdownNow() → Stop (running threads interrupted, queue dumped) → Terminated

A Complete Working Example ​

Here is a concrete example that puts all the pieces together:

java
import java.util.concurrent.*;

public class ThreadPoolDemo {

    public static void main(String[] args) throws InterruptedException {

        // Custom thread factory with meaningful names
        ThreadFactory factory = r -> {
            Thread t = new Thread(r);
            t.setName("worker-" + t.getId());
            return t;
        };

        // Custom rejection handler that logs rejections
        RejectedExecutionHandler onReject = (task, pool) -> {
            System.out.println("Task rejected: " + task);
        };

        ThreadPoolExecutor executor = new ThreadPoolExecutor(
            2,                              // corePoolSize: always keep 2 threads alive
            4,                              // maximumPoolSize: grow up to 4 during spikes
            30,                             // keepAliveTime: extra threads idle for 30 seconds...
            TimeUnit.SECONDS,               // ...measured in seconds
            new ArrayBlockingQueue<>(2),    // workQueue: hold up to 2 waiting tasks
            factory,                        // threadFactory: use our custom factory
            onReject                        // handler: log rejections instead of throwing
        );

        // Submit 7 tasks to a pool that can handle at most 4 + 2 = 6 at once
        for (int i = 1; i <= 7; i++) {
            final int taskNum = i;
            executor.submit(() -> {
                try {
                    System.out.println("Task " + taskNum
                        + " running on " + Thread.currentThread().getName());
                    Thread.sleep(2000); // simulate work
                    System.out.println("Task " + taskNum + " done");
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            });
        }

        // Graceful shutdown
        executor.shutdown();
        executor.awaitTermination(1, TimeUnit.MINUTES);
    }
}

Walk through what happens when all seven tasks arrive nearly simultaneously:

  • Task 1 arrives: fewer than 2 core threads running, so a new thread (worker) is created and task 1 runs immediately.
  • Task 2 arrives: still under corePoolSize, so another new thread is created and task 2 runs immediately.
  • Task 3 arrives: corePoolSize reached (2 threads busy). Try the queue. Queue has space. Task 3 waits in queue.
  • Task 4 arrives: queue still has space (size 2, one slot used). Task 4 waits in queue.
  • Task 5 arrives: queue is full (2/2). Thread count (2) is below maximumPoolSize (4). Create a new thread. Task 5 runs immediately on the new thread, bypassing the queue.
  • Task 6 arrives: queue is full. Thread count (3) is below maximumPoolSize (4). Create another new thread. Task 6 runs.
  • Task 7 arrives: queue is full. Thread count (4) equals maximumPoolSize. Reject task 7. The custom handler logs the rejection.

Six tasks complete successfully. Task 7 is rejected. The output demonstrates the queue first, then spawn extra threads behavior exactly.


How to Choose the Right Pool Size ​

This is the interview question at the heart of this chapter: an interviewer asks a candidate why they chose corePoolSize = 2. What should the answer be?

The correct answer is: it depends on multiple factors, and you derive the number from a formula combined with constraints.

The CPU Core Formula ​

A well known starting formula for thread count is:

Number of threads = Number of CPU cores × (1 + Wait time / Service time)

Where:

  • Number of CPU cores: the number of processors available, readable via Runtime.getRuntime().availableProcessors()
  • Wait time: time the thread spends waiting (for IO, DB calls, external API calls, etc.)
  • Service time: time the thread actually spends computing

Let us say you have 8 CPU cores. If a task spends equal time waiting and computing, wait time divided by service time is 1.0, so:

8 × (1 + 1.0) = 16 threads

If the task is almost entirely IO (waiting), say it waits 9 units for every 1 unit of CPU work:

8 × (1 + 9) = 80 threads

This makes intuitive sense. When threads spend most of their time blocked on IO, they are not using CPU anyway. You can have many more threads without causing context switching overhead, because most of them sleep while waiting for IO responses.

For CPU intensive tasks where wait time approaches zero:

8 × (1 + 0) = 8 threads

This also makes sense. If tasks never wait, each thread always needs CPU. Having more threads than cores just causes context switching overhead.

The Memory Constraint ​

The formula above does not consider memory, which can be a binding constraint. You need to calculate whether you actually have enough JVM memory to support the thread count the formula suggests.

Start with total JVM heap allocation. Subtract what you need for the heap (your objects, caches, etc.), code cache (often around 128 MB), and JVM internal overhead. What remains is available for thread stacks.

Each thread needs stack space. The default stack size is platform dependent but is often around 512 KB to 1 MB. You can control it with the -Xss JVM flag.

Available memory for threads = Total JVM memory - Heap - Code cache - JVM overhead
Max threads by memory = Available memory for threads / Stack size per thread

For example: 2 GB JVM, 1 GB heap, 128 MB code cache, 256 MB JVM overhead, leaving 616 MB for thread stacks. At 5 MB per thread (a generous estimate including stack, PC, and other per thread structures), you can support about 123 threads at most.

The final answer is the minimum of the formula result and the memory result. You take the conservative bound.

Task Nature Matters Most ​

For purely CPU intensive work, the answer is simple: one thread per CPU core, maybe slightly more to keep cores busy during minor scheduling delays.

For IO intensive work like database queries, HTTP calls, or file reads, you can have many more threads than cores because most threads are sleeping while waiting for the IO to complete. The formula guides you, but load testing under realistic conditions is the definitive answer.

Practical Recommendation ​

Do not guess. Start with the formula, apply the memory constraint, then load test. Monitor context switching overhead, CPU utilization, and response time percentiles. Tune from there. The number is specific to your workload, your machine, and your task mix.

In Spring Boot, @Async runs methods on a pool like this one: see @Async and thread pools.

Check yourself ​

  1. Why use a thread pool instead of creating a thread per task?
  2. Name the seven ThreadPoolExecutor constructor parameters.
  3. When a task arrives, in what order does the pool decide between creating a thread, queuing, and rejecting?
  4. Does the pool fill the queue first or grow to maximumPoolSize first? Why is an unbounded queue risky?
  5. What do the four built-in rejection policies do?
  6. What is the difference between execute and submit, especially for exceptions?
  7. What is the difference between shutdown(), shutdownNow() and awaitTermination()?
  8. How would you size a pool for CPU-bound versus I/O-bound work?
Answers
  1. Creating and destroying threads is expensive; a pool reuses a fixed set of threads and limits how many run at once.
  2. corePoolSize, maximumPoolSize, keepAliveTime, TimeUnit, workQueue, ThreadFactory, RejectedExecutionHandler.
  3. Fewer than core threads: create a thread. Otherwise try the queue. Queue full and below max: create a thread. At max with a full queue: reject.
  4. The queue first. With an unbounded queue it never fills, so threads beyond the core size are never created and the queue can grow until memory runs out.
  5. AbortPolicy (default) throws RejectedExecutionException; DiscardPolicy silently drops the task; CallerRunsPolicy runs it on the submitting thread; DiscardOldestPolicy drops the oldest queued task and retries.
  6. execute(Runnable) returns nothing and exceptions go to the thread's uncaught-exception handler. submit returns a Future; exceptions are stored in it and appear only when you call get() (wrapped in ExecutionException).
  7. shutdown() stops accepting tasks but finishes queued ones. shutdownNow() also interrupts running tasks and returns the tasks that never started. awaitTermination() blocks until the pool terminates or a timeout passes.
  8. Start from cores × (1 + wait time / compute time): about one thread per core for CPU-bound work, many more for I/O-bound work. Check the memory each thread's stack needs, then load test.