Skip to content
CalliCoder

Java ExecutorService and Thread Pool Tutorial

Published Updated Java 12 min read

Submitting work, sizing a pool, and shutting one down properly — plus the two Executors defaults that turn a slow consumer into an OutOfMemoryError, and why an uncaught exception in submit() vanishes.

Creating a thread per task works until the task rate exceeds what the machine can schedule, at which point you have thousands of threads, most of them waiting, and a megabyte of stack reserved for each. An ExecutorService separates what runs from the threads that run it.

The API is small. Two of its convenience factories have defaults that fail badly under load, and one of its methods swallows exceptions. Those are the parts worth reading carefully.

Written against Java 17, with notes on Java 21.

Submitting work

try (ExecutorService pool = Executors.newFixedThreadPool(4)) {

    pool.execute(() -> log.info("fire and forget"));         // Runnable, no result

    Future<Integer> total = pool.submit(() -> expensiveSum()); // Callable, has a result
    Integer result = total.get();                              // blocks
}

ExecutorService implements AutoCloseable from Java 19, so try-with-resources shuts it down and waits for running tasks. Before that, the shutdown had to be written out, see below.

execute takes a Runnable and returns nothing. submit takes a Callable and returns a Future. That difference is not just about return values, and it is the first trap.

An exception in submit() disappears

pool.execute(() -> { throw new IllegalStateException("visible"); });
// -> handled by the thread's UncaughtExceptionHandler, printed to stderr

pool.submit(() -> { throw new IllegalStateException("silent"); });
// -> captured in the Future. If nobody calls get(), it is never seen.

submit wraps the task so any throwable is stored in the Future and rethrown as ExecutionException when you call get(). Discard the Future, which is exactly what you do for fire-and-forget work, and the failure is invisible. No log line, no stack trace, and a task that silently stopped doing its job.

Two defences:

// 1. use execute() for work with no result
pool.execute(this::reconcile);

// 2. or catch inside the task
pool.submit(() -> {
    try {
        reconcile();
    } catch (Exception e) {
        log.error("reconcile failed", e);
    }
});

For a ThreadPoolExecutor you can also override afterExecute to log both cases centrally. Whichever you choose, decide deliberately. This is the most common way scheduled or background work fails without anyone noticing.

Futures

Future<String> f = pool.submit(() -> fetch(url));

f.isDone();
f.get();                                  // blocks indefinitely
f.get(2, TimeUnit.SECONDS);               // TimeoutException
f.cancel(true);                           // interrupt if running
f.isCancelled();

Always prefer the timed get. The untimed version blocks forever, so one hung task takes the calling thread with it, and if that thread belongs to another pool, the failure spreads.

cancel(true) interrupts the thread. Interruption is cooperative: it sets a flag and throws InterruptedException from blocking calls that support it. A task in a tight computational loop that never checks Thread.currentThread().isInterrupted() cannot be cancelled at all.

pool.submit(() -> {
    while (!Thread.currentThread().isInterrupted()) {
        step();
    }
});

get() after a TimeoutException still leaves the task running. Cancel it explicitly if you no longer want the result.

invokeAll and invokeAny

List<Callable<String>> tasks = urls.stream()
        .map(u -> (Callable<String>) () -> fetch(u))
        .toList();

// waits for every task, returns Futures in the same order
List<Future<String>> all = pool.invokeAll(tasks, 10, TimeUnit.SECONDS);

// returns the first successful result, cancels the rest
String fastest = pool.invokeAny(tasks, 5, TimeUnit.SECONDS);

invokeAll blocks until all tasks finish, are cancelled, or the timeout expires. With a timeout, unfinished tasks are cancelled and their Future.get() throws CancellationException, so iterate with that in mind rather than assuming every future holds a value.

invokeAny is the hedged-request pattern: ask three replicas, take whichever answers first.

Sizing the pool

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

Executors.newFixedThreadPool(cores);       // CPU-bound
Executors.newFixedThreadPool(cores * 8);   // I/O-bound, a starting point

For CPU-bound work, more threads than cores adds context switching and no throughput. For I/O-bound work, threads spend most of their time waiting, so more of them is useful, the theoretical figure is cores × (1 + wait/compute), which is a way of saying measure it.

One pool per kind of work. A single shared pool means a slow batch job starves request handling, and sizing it correctly for both is impossible.

The two Executors factories to avoid

Executors.newFixedThreadPool(n);    // unbounded LinkedBlockingQueue
Executors.newCachedThreadPool();    // unbounded thread count

Both are unbounded, in different directions, and both convert overload into an OutOfMemoryError rather than backpressure.

newFixedThreadPool queues submitted tasks in an unbounded LinkedBlockingQueue. When producers outpace consumers the queue grows until the heap is exhausted, and the failure surfaces far from the cause, as an OOM in unrelated code.

newCachedThreadPool creates a thread per task when none is idle, with no ceiling. A burst of ten thousand tasks attempts ten thousand threads.

Construct the executor instead, and state both bounds:

ExecutorService pool = new ThreadPoolExecutor(
        4, 8,                                       // core and max threads
        60L, TimeUnit.SECONDS,                      // idle timeout for non-core threads
        new ArrayBlockingQueue<>(500),               // BOUNDED queue
        new ThreadFactoryBuilder().setNameFormat("worker-%d").build(),
        new ThreadPoolExecutor.CallerRunsPolicy());  // what happens when full

CallerRunsPolicy is usually the right rejection handler: the submitting thread executes the task itself, which slows the producer down naturally instead of queueing more work. The alternatives are AbortPolicy (throws RejectedExecutionException, the default), DiscardPolicy and DiscardOldestPolicy, the last two lose work silently and are rarely what anyone wants.

Name the threads. A stack trace from worker-3 tells you which pool; one from pool-2-thread-1 does not. It costs one line and saves an incident.

One counter-intuitive detail of ThreadPoolExecutor: it only creates threads beyond corePoolSize when the queue is full. With an unbounded queue, maximumPoolSize is never reached, which is another reason the queue bound is the important number.

Shutting down

pool.shutdown();                                   // stop accepting; finish what is queued
if (!pool.awaitTermination(30, TimeUnit.SECONDS)) {
    pool.shutdownNow();                            // interrupt running tasks
    if (!pool.awaitTermination(10, TimeUnit.SECONDS)) {
        log.error("pool did not terminate");
    }
}

shutdown is graceful; shutdownNow interrupts and returns the tasks that never started. The two-phase pattern above is what close() does in Java 19+.

A pool that is never shut down keeps non-daemon threads alive and the JVM will not exit. That is the classic “my program finished but the process is still running”.

In Spring, prefer a managed executor so the container handles this:

@Bean(destroyMethod = "shutdown")
ThreadPoolTaskExecutor taskExecutor() {
    ThreadPoolTaskExecutor ex = new ThreadPoolTaskExecutor();
    ex.setCorePoolSize(4);
    ex.setMaxPoolSize(8);
    ex.setQueueCapacity(500);
    ex.setThreadNamePrefix("task-");
    ex.setWaitForTasksToCompleteOnShutdown(true);
    ex.setAwaitTerminationSeconds(30);
    ex.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
    return ex;
}

Scheduled execution

ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(2);

scheduler.schedule(this::once, 5, TimeUnit.SECONDS);
scheduler.scheduleAtFixedRate(this::sample, 0, 1, TimeUnit.MINUTES);      // start to start
scheduler.scheduleWithFixedDelay(this::poll, 0, 1, TimeUnit.MINUTES);     // end to start

scheduleWithFixedDelay for anything whose duration varies. scheduleAtFixedRate when the cadence itself matters and you have confirmed the task finishes inside the interval.

The trap here is severe: an uncaught exception silently cancels the repeating task. Not the next run, all of them. The scheduler stops, no exception is logged, and a job simply never runs again.

scheduler.scheduleWithFixedDelay(() -> {
    try {
        poll();
    } catch (Exception e) {
        log.error("poll failed", e);     // without this, one failure ends the schedule
    }
}, 0, 1, TimeUnit.MINUTES);

Wrap every scheduled task body. This is not optional defensive coding. It is the difference between a job that recovers and one that stops permanently on its first bad response.

Java 21: virtual threads

try (var pool = Executors.newVirtualThreadPerTaskExecutor()) {
    for (Request r : requests) {
        pool.submit(() -> handle(r));      // a million of these is fine
    }
}

A virtual thread costs hundreds of bytes rather than a megabyte, and blocking on I/O unmounts it from its carrier. For I/O-bound work this removes pool sizing as a concern: create one per task.

Two rules that come with them. Do not pool virtual threads: newVirtualThreadPerTaskExecutor creates one per task, which is the point, and a fixed pool of them defeats it. And they change nothing about correctness: races, deadlocks and the swallowed-exception behaviour of submit are all exactly as before.

For CPU-bound work, a bounded platform-thread pool is still the right tool.

Frequently asked questions

What is the difference between execute and submit?

execute takes a Runnable and returns nothing; exceptions reach the uncaught handler. submit returns a Future and captures any exception inside it, invisible unless you call get().

Why did my exception disappear?

You used submit and discarded the Future. Use execute for fire-and-forget, or catch inside the task.

How many threads should a pool have?

Around the core count for CPU-bound work, considerably more for I/O-bound. Measure; the ratio depends on how long tasks wait.

Why avoid Executors.newFixedThreadPool?

Its task queue is unbounded, so a slow consumer grows the queue until the heap is exhausted. Construct a ThreadPoolExecutor with a bounded queue.

Why avoid newCachedThreadPool?

It creates a thread per task with no maximum. A burst of tasks attempts a burst of threads.

Which rejection policy should I use?

CallerRunsPolicy usually, the submitter runs the task, which applies backpressure. AbortPolicy when a rejection should be an error. Avoid the discard policies, which lose work silently.

Why is maximumPoolSize being ignored?

ThreadPoolExecutor only creates threads past corePoolSize when the queue is full. With an unbounded queue that never happens.

Why does my JVM not exit?

A pool that was never shut down keeps non-daemon threads alive. Call shutdown and awaitTermination, or use try-with-resources on Java 19+.

Why did my scheduled task stop running?

An uncaught exception cancels a repeating task permanently, with nothing logged. Wrap the body in try/catch.

Can I cancel a running task?

future.cancel(true) interrupts the thread, but interruption is cooperative, a loop that never checks isInterrupted() cannot be stopped.

Should I use virtual threads instead?

For I/O-bound work on Java 21, yes, one per task, no pooling. For CPU-bound work, keep a bounded platform-thread pool. Neither changes anything about thread safety.

Where should I go next?

CompletableFuture composes the results these pools produce, and Java concurrency basics covers the memory model underneath.