DevNotes
Support
Multithreading · Chapter 11

ThreadPoolExecutor Internals & Pool Sizing

Core and maximum pool size, work queues, keep-alive, the task-acceptance algorithm, rejection policies, custom ThreadFactory, sizing pools for CPU- and I/O-bound work, and monitoring.

◷ 7 min read§ 12 sections? 6 interview questions{ } 4 code examples

#Introduction

Every Executors factory method (except work-stealing and virtual-thread executors) returns a ThreadPoolExecutor configured in a particular way. Building one directly gives you full control — and avoids the unbounded queues and thread counts hidden in the factories.

#The Constructor

Java
ThreadPoolExecutor pool = new ThreadPoolExecutor(
        4,                                    // corePoolSize
        16,                                   // maximumPoolSize
        60, TimeUnit.SECONDS,                 // keepAliveTime for threads above core
        new ArrayBlockingQueue<>(500),        // workQueue — bounded
        namedThreadFactory("order-worker"),   // threadFactory
        new ThreadPoolExecutor.CallerRunsPolicy()   // rejection handler
);
ParameterMeaning
corePoolSizeThreads kept alive even when idle (unless allowCoreThreadTimeOut(true))
maximumPoolSizeUpper limit on threads
keepAliveTimeHow long threads above core may stay idle before being terminated
workQueueWhere tasks wait when all core threads are busy
threadFactoryCreates threads — set names, daemon status, exception handlers
handlerRejectedExecutionHandler — what to do when the pool and queue are full

#How a Task Is Accepted

This algorithm surprises most developers:

Task submitted
Fewer than corePoolSize threads running?
start a new core thread for it
try the queue
Queue accepts the task?
task waits in the queue
queue is full
Fewer than maximumPoolSize threads?
start a new non-core thread
reject the task

Example

Core 2, max 4, queue capacity 3 — tasks are long-running:

TaskRunning threadsQueueWhat happens
110New core thread
220New core thread
3–523Queued
633Queue full → new non-core thread
743Another non-core thread
843Rejected

#Work Queues

QueueBehaviourUsed by
ArrayBlockingQueue(n)Bounded FIFO, array-backedProduction pools with back-pressure
LinkedBlockingQueue()Optionally bounded; unbounded by defaultnewFixedThreadPool, newSingleThreadExecutor
SynchronousQueueNo capacity — each hand-off needs a waiting threadnewCachedThreadPool (so it grows threads instead of queuing)
PriorityBlockingQueueUnbounded, ordered by priorityPriority task processing
DelayedWorkQueueOrdered by scheduled timeScheduledThreadPoolExecutor

How the Factory Methods Are Built

FactorycoremaxQueue
newFixedThreadPool(n)nnUnbounded LinkedBlockingQueue
newSingleThreadExecutor()11Unbounded LinkedBlockingQueue
newCachedThreadPool()0Integer.MAX_VALUESynchronousQueue, 60 s keep-alive

#Rejection Policies

When the queue is full and the pool is at maximumPoolSize — or after shutdown() — the handler decides what happens:

PolicyBehaviourWhen to use
AbortPolicy (default)Throw RejectedExecutionExceptionThe caller can handle the failure (return 503, retry later)
CallerRunsPolicyRun the task on the submitting threadNatural back-pressure — the producer slows down
DiscardPolicySilently drop the taskOnly for genuinely disposable work
DiscardOldestPolicyDrop the oldest queued task and retryLatest data matters more than old (e.g. live price updates)
Custom handlerLog, record a metric, persist to a retry queue…Most production systems
Java
RejectedExecutionHandler handler = (task, executor) -> {
    rejectedCounter.increment();
    log.warn("Task rejected; active={} queue={}", executor.getActiveCount(), executor.getQueue().size());
    throw new RejectedExecutionException("Order pool saturated");
};

#A Custom ThreadFactory

Java
static ThreadFactory namedThreadFactory(String prefix) {
    AtomicInteger counter = new AtomicInteger(1);
    return runnable -> {
        Thread t = new Thread(runnable, prefix + "-" + counter.getAndIncrement());
        t.setDaemon(false);
        t.setUncaughtExceptionHandler((th, ex) -> log.error("Uncaught in {}", th.getName(), ex));
        return t;
    };
}

Java 21 also offers Thread.ofPlatform().name("order-worker-", 1).factory(). Guava's ThreadFactoryBuilder and Spring's CustomizableThreadFactory do the same.

#Sizing a Thread Pool

There's no universal number — it depends on the work.

CPU-Bound Work

Text
threads ≈ number of cores   (often cores + 1)

More threads than cores only add context switching.

I/O-Bound Work

Threads spend most of their time waiting (database, HTTP), so more threads keep the CPU busy:

Text
threads ≈ cores × (1 + wait time / compute time)

Example: 8 cores, each request waits 90 ms on a database and computes for 10 ms → 8 × (1 + 90/10) = 80 threads.

Little's Law

Text
threads needed = arrival rate × average time per task

200 requests/second × 0.25 seconds each = 50 concurrent tasks in flight.

Separate Pools for Different Workloads (Bulkheads)

Don't run slow calls to a flaky third-party API on the same pool as fast internal work. If the API hangs, it consumes every thread and everything else starves. Give each kind of work its own pool — the bulkhead pattern (as in Resilience4j).

#Hooks and Monitoring

Java
ThreadPoolExecutor pool = new ThreadPoolExecutor(4, 16, 60, TimeUnit.SECONDS, new ArrayBlockingQueue<>(500)) {
    @Override
    protected void beforeExecute(Thread t, Runnable r) {
        MDC.put("pool", "orders");
    }

    @Override
    protected void afterExecute(Runnable r, Throwable t) {
        MDC.clear();
        if (t != null) log.error("Task failed", t);   // only for execute(); submit() wraps exceptions in the Future
    }
};

Useful metrics: getActiveCount(), getPoolSize(), getQueue().size(), getCompletedTaskCount(), getLargestPoolSize(). Micrometer's ExecutorServiceMetrics exports them; Spring Boot does this automatically for its own task executors.

Pools can be tuned at runtime with setCorePoolSize(), setMaximumPoolSize() and allowCoreThreadTimeOut(true), and warmed up with prestartAllCoreThreads().

#Interview Questions

Q1Explain the main parameters of ThreadPoolExecutor.

corePoolSize (threads kept alive), maximumPoolSize (upper limit), keepAliveTime (idle timeout for extra threads), workQueue (waiting tasks), threadFactory (thread creation) and RejectedExecutionHandler (what happens when saturated).

Q2When does ThreadPoolExecutor create threads beyond corePoolSize?

Only when all core threads are busy and the queue is full. With an unbounded queue, it never exceeds the core size.

Q3What rejection policies are available?

AbortPolicy (throw — the default), CallerRunsPolicy (run on the submitting thread), DiscardPolicy (drop silently) and DiscardOldestPolicy (drop the oldest queued task), plus custom handlers.

Q4How do you choose the pool size?

About the number of cores for CPU-bound work; more for I/O-bound work — roughly cores × (1 + wait/compute) or arrival rate × task time (Little's law) — then validate by load testing, and align with downstream limits such as connection pools.

Q5Why is newCachedThreadPool risky?

Its maximum is Integer.MAX_VALUE with a SynchronousQueue, so a burst of slow tasks creates an unbounded number of threads and can exhaust memory or OS limits.

Q6What is the bulkhead pattern?

Isolating different workloads into separate pools so a slow or failing dependency can only exhaust its own threads, not the whole application's.

#Common Mistakes

  • Unbounded queues — hide overload until memory runs out, and make maximumPoolSize meaningless.
  • Setting max > core with an unbounded queue and expecting the pool to grow.
  • One shared pool for everything — one slow dependency starves all work.
  • Silent rejection (DiscardPolicy) with no metric or log.
  • Oversizing pools relative to database connections or CPU.
  • Unnamed threads — thread dumps show pool-7-thread-23.

#Best Practices

  • Construct ThreadPoolExecutor (or Spring ThreadPoolTaskExecutor) explicitly with a bounded queue.
  • Choose a rejection policy deliberately and record rejections as a metric.
  • Name threads and set an uncaught-exception handler through a ThreadFactory.
  • Size from the nature of the work, then load-test.
  • Use separate pools (bulkheads) for independent workloads.
  • Monitor active threads, queue depth and rejections.

#Key Takeaways

  • ThreadPoolExecutor is configured by core size, max size, keep-alive, queue, thread factory and rejection handler.
  • New threads beyond core are created only when the queue is full.
  • The Executors factories use unbounded queues or unbounded threads — avoid them for production services.
  • Rejection policies define behaviour under overload; CallerRunsPolicy gives back-pressure.
  • CPU-bound pools ≈ cores; I/O-bound pools are larger and must match downstream capacity.