Thread Pools & ExecutorService

From Executors.newFixedThreadPool to ThreadPoolExecutor internals and correct pool sizing

← Back to Index

What Are Thread Pools and Why Do They Exist?

Creating a platform thread is expensive: the JVM allocates a stack (~512KBโ€“1MB), the OS allocates a kernel thread structure, and context-switching has measurable overhead. Creating a new thread per task at scale means thousands of threads competing for CPU time โ€” most doing nothing but sleeping or waiting.

A thread pool solves this by maintaining a fixed number of reusable worker threads. Tasks are submitted to a queue; idle workers pick them up. Thread creation happens once at startup, not per task.

// โŒ Thread-per-task: 10,000 requests = 10,000 threads = OOM or thrashing
for (Request req : requests) {
    new Thread(() -> handle(req)).start();  // unbounded, uncontrolled
}

// โœ… Thread pool: 10,000 requests handled by N reusable workers
ExecutorService pool = Executors.newFixedThreadPool(20);
for (Request req : requests) {
    pool.submit(() -> handle(req));  // excess tasks queue, don't spawn threads
}

/*
 *  Internal flow:
 *
 *  submit(task) โ”€โ”€โ–บ [Task Queue] โ”€โ”€โ–บ [Worker Thread 1]
 *                                 โ”€โ”€โ–บ [Worker Thread 2]
 *                                 โ”€โ”€โ–บ [Worker Thread N]
 *                   (tasks wait)       (pick up next task when free)
 */

ExecutorService API

submit vs execute

ExecutorService pool = Executors.newFixedThreadPool(4);

// execute(Runnable) โ€” fire and forget, no result, exceptions swallowed silently
pool.execute(() -> processEvent(event));

// submit(Callable) โ€” returns Future, exceptions held until future.get()
Future<Report> future = pool.submit(() -> generateReport(params));

// Always use get() with timeout โ€” never block indefinitely
try {
    Report report = future.get(30, TimeUnit.SECONDS);
} catch (TimeoutException e) {
    future.cancel(true);   // interrupt the task
    throw new ServiceException("Report generation timed out");
} catch (ExecutionException e) {
    throw new ServiceException("Report generation failed", e.getCause());
}

invokeAll and invokeAny โ€” batch operations

List<Callable<Price>> tasks = providers.stream()
    .map(p -> (Callable<Price>) () -> p.fetchPrice(sku))
    .toList();

// invokeAll: wait for ALL to complete (or timeout) โ€” returns in submission order
List<Future<Price>> results = pool.invokeAll(tasks, 5, TimeUnit.SECONDS);
for (Future<Price> f : results) {
    if (!f.isCancelled()) prices.add(f.get());  // timed-out tasks are cancelled
}

// invokeAny: return FIRST successful result, cancel the rest
// Perfect for "try multiple sources, take fastest" pattern
Price best = pool.invokeAny(tasks, 3, TimeUnit.SECONDS);

Shutdown โ€” always, always shut down

// โœ… Graceful shutdown pattern
void shutdownGracefully(ExecutorService pool) {
    pool.shutdown();  // reject new tasks, let queued tasks finish
    try {
        if (!pool.awaitTermination(30, TimeUnit.SECONDS)) {
            pool.shutdownNow();  // interrupt running tasks
            if (!pool.awaitTermination(10, TimeUnit.SECONDS)) {
                log.error("Pool did not terminate");
            }
        }
    } catch (InterruptedException e) {
        pool.shutdownNow();
        Thread.currentThread().interrupt();  // restore interrupt status
    }
}

// โœ… Java 19+: ExecutorService implements AutoCloseable
try (ExecutorService pool = Executors.newFixedThreadPool(4)) {
    pool.submit(task1);
    pool.submit(task2);
}  // calls shutdown() + awaitTermination() automatically

ThreadPoolExecutor โ€” Under the Hood

All Executors factory methods return a ThreadPoolExecutor (or subclass). Understanding its parameters is essential for production tuning โ€” the defaults are rarely correct for your workload.

new ThreadPoolExecutor(
    4,                              // corePoolSize: threads kept alive even when idle
    16,                             // maximumPoolSize: max threads when queue is full
    60L, TimeUnit.SECONDS,          // keepAliveTime: idle extra threads terminated after
    new LinkedBlockingQueue<>(1000), // workQueue: bounded โ€” critical for production
    new ThreadFactory() { ... },   // threadFactory: name threads, set daemon status
    new CallerRunsPolicy()           // rejectionHandler: what to do when queue is full
);

/*
 *  Task submission flow:
 *
 *  submit(task)
 *    โ”‚
 *    โ”œโ”€ threads < corePoolSize?     โ†’ create new core thread
 *    โ”‚
 *    โ”œโ”€ queue not full?             โ†’ add to queue (existing threads will pick up)
 *    โ”‚
 *    โ”œโ”€ threads < maximumPoolSize?  โ†’ create non-core thread (temporary)
 *    โ”‚
 *    โ””โ”€ queue full AND at max?      โ†’ RejectedExecutionHandler fires
 */

Named thread factory โ€” non-negotiable in production

// Anonymous threads in stack traces = debugging nightmare
// "pool-3-thread-7 threw an exception" tells you nothing

ThreadFactory factory = new ThreadFactory() {
    private final AtomicInteger count = new AtomicInteger();
    @Override
    public Thread newThread(Runnable r) {
        Thread t = new Thread(r, "order-processor-" + count.incrementAndGet());
        t.setDaemon(true);  // JVM can exit even if task is running
        return t;
    }
};

// Or with Guava (cleaner):
ThreadFactory factory = ThreadFactoryBuilder.newBuilder()
    .setNameFormat("payment-worker-%d")
    .setDaemon(true)
    .build();

Rejection policies

/*
 *  AbortPolicy (default)  โ€” throws RejectedExecutionException
 *                           Caller must catch it or the task is lost silently
 *
 *  CallerRunsPolicy       โ€” task runs on the submitting thread
 *                           Natural backpressure: slows down producers
 *                           โœ… Recommended for most services
 *
 *  DiscardPolicy          โ€” task silently dropped
 *                           โŒ Almost never correct โ€” you lose work
 *
 *  DiscardOldestPolicy    โ€” drops the oldest queued task to make room
 *                           โŒ Also rarely correct
 */

// Custom policy: log + metric + controlled fail
new RejectedExecutionHandler() {
    @Override
    public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
        metrics.increment("pool.rejections");
        log.error("Task rejected โ€” queue={}, active={}",
                  executor.getQueue().size(), executor.getActiveCount());
        throw new RejectedExecutionException("Service overloaded");
    }
}

Pool Sizing โ€” The Science

Wrong pool size is one of the most common performance bugs in Java services. Too small: throughput limited. Too large: context-switching overhead, memory pressure, I/O contention.

/*
 *  Little's Law applied to thread pools:
 *
 *  N = ฮป ร— W
 *
 *  N  = number of threads needed
 *  ฮป  = task arrival rate (tasks/sec)
 *  W  = average task duration (seconds)
 *
 *  Example: 500 req/s, each takes 200ms average
 *  N = 500 ร— 0.2 = 100 threads minimum for full throughput
 *
 *  โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
 *  CPU-BOUND tasks (image processing, crypto, computation):
 *
 *  threads = N_cpu + 1
 *
 *  +1 for when a thread stalls on GC or page fault.
 *  More threads just means context-switching overhead.
 *
 *  โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
 *  I/O-BOUND tasks (DB, HTTP, files):
 *
 *  threads = N_cpu ร— (1 + wait_time / compute_time)
 *
 *  Example: 8 cores, task spends 90% waiting on DB
 *  threads = 8 ร— (1 + 0.9 / 0.1) = 8 ร— 10 = 80
 *
 *  Java 21 virtual threads remove this calculation for I/O-bound work.
 */

int cores = Runtime.getRuntime().availableProcessors();

// CPU-bound pool
ExecutorService cpuPool = Executors.newFixedThreadPool(cores + 1);

// I/O-bound pool (rule of thumb: 2โ€“10x cores, measure your wait ratio)
ExecutorService ioPool  = Executors.newFixedThreadPool(cores * 4);

// Or for I/O-bound on Java 21: virtual threads eliminate the guessing
ExecutorService vtPool  = Executors.newVirtualThreadPerTaskExecutor();
Container-aware sizing โ€” critical in Kubernetes
// Runtime.getRuntime().availableProcessors() returns HOST CPU count, not container limit.
// A container limited to 2 CPUs on a 64-core host returns 64. Pool of 65 threads
// on 2 CPUs = severe context-switching overhead.

// โœ… Fix 1: JVM flags (Java 8u191+ / Java 10+)
// -XX:ActiveProcessorCount=2  โ† override what availableProcessors() returns

// โœ… Fix 2: Read CPU limit from cgroup (Java 11+ does this automatically with
//    -XX:+UseContainerSupport, which is ON by default since Java 10)

// โœ… Fix 3: Read the limit programmatically for logging/sizing decisions
int effectiveCpus = Runtime.getRuntime().availableProcessors();
log.info("Sizing pool for {} CPUs", effectiveCpus);

ScheduledExecutorService

The modern replacement for Timer. Timer uses a single thread for all tasks โ€” one slow task delays all others, and an uncaught exception kills the timer permanently. ScheduledExecutorService has none of these problems.

ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(2,
    r -> { Thread t = new Thread(r, "scheduler"); t.setDaemon(true); return t; });

// schedule: run once after delay
scheduler.schedule(() -> retryFailedPayments(), 30, TimeUnit.MINUTES);

// scheduleAtFixedRate: next run starts N units after PREVIOUS START
// If task takes longer than the period, runs back-to-back (no overlap)
scheduler.scheduleAtFixedRate(() -> {
    metrics.report();
}, 0, 1, TimeUnit.MINUTES);

// scheduleWithFixedDelay: next run starts N units after PREVIOUS END
// Guarantees a gap between runs โ€” use for tasks of variable duration
scheduler.scheduleWithFixedDelay(() -> {
    processPendingOrders();  // takes unpredictable time
}, 0, 5, TimeUnit.SECONDS);  // always 5s gap after completion

// โš ๏ธ Uncaught exceptions in scheduled tasks SILENTLY STOP the schedule
// The future completes exceptionally โ€” no more executions, no log output
ScheduledFuture<?> handle = scheduler.scheduleAtFixedRate(() -> {
    try {
        doWork();
    } catch (Exception e) {
        log.error("Scheduled task failed โ€” will retry next cycle", e);
        // Don't rethrow: that would cancel the schedule
    }
}, 0, 1, TimeUnit.MINUTES);

ForkJoinPool โ€” Divide and Conquer

ForkJoinPool is designed for recursive, CPU-bound work that can be split into subtasks. It uses work stealing: idle threads steal tasks from busy threads' queues, maximising CPU utilisation. It is the engine behind parallel streams and CompletableFuture defaults.

// Direct use: RecursiveTask returns a result
class SumTask extends RecursiveTask<Long> {
    private static final int THRESHOLD = 10_000;
    private final long[] array;
    private final int from, to;

    SumTask(long[] array, int from, int to) {
        this.array = array; this.from = from; this.to = to;
    }

    @Override
    protected Long compute() {
        int size = to - from;
        if (size <= THRESHOLD) {
            // Base case: compute directly
            long sum = 0;
            for (int i = from; i < to; i++) sum += array[i];
            return sum;
        }
        // Recursive case: split and fork
        int mid = from + size / 2;
        SumTask left  = new SumTask(array, from, mid);
        SumTask right = new SumTask(array, mid, to);
        left.fork();                     // schedule left asynchronously
        long rightResult = right.compute(); // compute right on current thread
        return left.join() + rightResult;   // wait for left, combine
    }
}

long[] data = /* large array */ new long[10_000_000];
ForkJoinPool pool = ForkJoinPool.commonPool();  // shared across JVM
long total = pool.invoke(new SumTask(data, 0, data.length));

// For custom parallelism level (avoid monopolising common pool):
ForkJoinPool custom = new ForkJoinPool(4);
long result = custom.submit(new SumTask(data, 0, data.length)).get();
commonPool vs custom ForkJoinPool

ForkJoinPool.commonPool() is shared by parallel streams, CompletableFuture.supplyAsync() (when no executor specified), and any code that uses it directly. Blocking tasks in the common pool (I/O, locks) starve everyone else. For I/O-bound work in CompletableFuture, always pass a dedicated executor. For CPU-bound parallel work, the common pool is appropriate.

Monitoring Pool Health

A thread pool that silently saturates is one of the hardest production issues to diagnose. Expose metrics from the start.

ThreadPoolExecutor tpe = (ThreadPoolExecutor) pool;

// Key metrics to expose (Micrometer, Prometheus, etc.)
tpe.getPoolSize();           // current number of threads
tpe.getActiveCount();        // threads actively executing tasks
tpe.getCorePoolSize();       // core thread count
tpe.getMaximumPoolSize();    // max thread count
tpe.getQueue().size();       // tasks waiting โ€” KEY signal for saturation
tpe.getQueue().remainingCapacity(); // 0 = about to reject
tpe.getCompletedTaskCount(); // total tasks completed since pool creation
tpe.getTaskCount();          // total submitted

// Hook into lifecycle for custom metrics
ExecutorService monitored = new ThreadPoolExecutor(4, 16, 60L, TimeUnit.SECONDS,
        new LinkedBlockingQueue<>(1000)) {
    @Override
    protected void beforeExecute(Thread t, Runnable r) {
        metrics.gauge("pool.queue.size", getQueue().size());
    }
    @Override
    protected void afterExecute(Runnable r, Throwable ex) {
        if (ex != null) metrics.increment("pool.task.failures");
    }
};

Interview Questions

๐ŸŽ“ Junior level

Q: Why use a thread pool instead of creating threads manually?
Thread creation is expensive (OS thread allocation, stack memory). Creating one thread per task at scale means thousands of threads competing for CPU, exhausting memory and causing excessive context switching. A thread pool creates threads once and reuses them, queuing excess tasks instead of spawning new threads.

Q: What is the difference between execute() and submit()?
execute(Runnable) returns void โ€” no way to get a result or catch exceptions (they're passed to the thread's uncaught exception handler). submit(Callable) returns a Future โ€” you can retrieve the result, and exceptions are held until future.get() is called, where they're wrapped in ExecutionException.

Q: What happens if you don't call shutdown() on an ExecutorService?
The pool's worker threads are non-daemon by default and will keep the JVM alive indefinitely โ€” even after main() returns. This is a resource leak: threads consume memory and OS handles without doing any work.

๐Ÿ”ฅ Senior level

Q: Explain the ThreadPoolExecutor task submission flow.
(1) If running threads < corePoolSize, create a new core thread even if idle threads exist. (2) If at core size, try to enqueue the task. If the queue accepts it, return โ€” existing threads will pick it up. (3) If the queue is full and running threads < maximumPoolSize, create a temporary non-core thread. (4) If at max size and queue is full, the rejection handler fires. This is why with an unbounded queue (as in Executors.newFixedThreadPool), maximumPoolSize is irrelevant โ€” the queue never fills, so extra threads are never created.

Q: What is the danger of Executors.newCachedThreadPool() in production?
newCachedThreadPool() uses a SynchronousQueue (zero capacity) and maximumPoolSize = Integer.MAX_VALUE. Every submitted task either finds an idle thread or spawns a new one โ€” there is no queue and no upper bound. Under sustained load, this creates thousands of threads, leading to OutOfMemoryError. In production, always use ThreadPoolExecutor directly with a bounded queue and explicit core/max sizes.

Q: What is work stealing and when does ForkJoinPool outperform fixed thread pools?
In a fixed pool, each thread has a shared task queue โ€” all threads compete for the same lock. In ForkJoinPool, each thread has its own double-ended deque. Threads push/pop from their own deque (no contention) and steal from the tail of other threads' deques when idle. This reduces contention and keeps all threads busy. ForkJoinPool outperforms fixed pools for recursive divide-and-conquer tasks where subtask counts are high and tasks are short. For independent tasks of similar duration, a fixed pool is simpler and equally efficient.

Q: How do you handle uncaught exceptions in scheduled tasks?
An uncaught exception in a scheduleAtFixedRate task silently cancels all future executions โ€” the ScheduledFuture completes exceptionally and no more executions are scheduled. The fix: wrap the task body in a try-catch that logs and does NOT rethrow. To detect silent cancellation, call future.get() periodically โ€” it will throw ExecutionException if the task failed. In Spring, @Scheduled with TaskSchedulerCustomizer can configure a global error handler.