ExecutorService and Thread Pools in Java
Executive Summary
Thread pools in Java start with Executors.newFixedThreadPool(n), the default choice: n standing threads pulling from a queue. newCachedThreadPool grows and shrinks with demand but is unbounded, suited to short bursty tasks, never to untrusted input. submit returns a Future: call get, with a timeout, to collect a Callable’s result. Also, get throws ExecutionException wrapping the task’s failure, so the exception surfaces only where you look. invokeAll submits a batch and waits for all of it, which parallelizes the link checker in four lines.
Shutdown is a three-step idiom: shutdown to stop accepting, awaitTermination to wait gracefully, shutdownNow to interrupt stragglers. Since Java 19, the pool is AutoCloseable, so try-with-resources does the first two. Sizing: CPU-bound work gets availableProcessors threads, while I/O-bound work gets more, bounded by downstream capacity. The honest answer is measured, not guessed. The pitfall to internalize: the common factory methods use an unbounded queue. As a result, a producer faster than the pool does not get slow; it accumulates tasks until the heap dies. The bounded ThreadPoolExecutor with an explicit rejection policy is the production fix.
The Factory Methods and What They Actually Give You
Executors is a factory class, and each method is a different policy, with a different failure mode. The Executors documentation describes them tersely; the table adds what production experience adds:
| Factory method | Semantics | Right for | The risk you inherit |
|---|---|---|---|
| newFixedThreadPool(n) | Exactly n threads forever, unbounded task queue | The default choice: bounded threads, predictable | Unbounded queue: fast producers accumulate tasks until OOM |
| newCachedThreadPool() | Creates threads on demand, reclaims after 60s idle | Many short, bursty, lightweight tasks | Unbounded threads: a burst creates thousands |
| newSingleThreadExecutor() | One thread, tasks in order | Serializing access to a resource that is not thread-safe | Same unbounded queue; one slow task stalls everything behind it |
| newVirtualThreadPerTaskExecutor() | A cheap virtual thread per task, no pool at all | Blocking I/O-heavy workloads (article 47) | Not for CPU-bound work: it parallelizes nothing that pins cores |
var pool = Executors.newFixedThreadPool(4); // the default reach
var elastic = Executors.newCachedThreadPool(); // bursts only, trusted input
var serial = Executors.newSingleThreadExecutor(); // order matters, one lane
var modern = Executors.newVirtualThreadPerTaskExecutor(); // the future, literally
The pattern to notice: every factory method hides a policy decision. However, the two you can never see in the signature are queue size and rejection behavior. newFixedThreadPool gives you a beautiful bounded thread count attached to an infinite shelf of waiting work. That is the correct choice for most services, and the next sections show when and how to make the shelf finite.
Submitting Work: execute, submit, and invokeAll
Three entry points, three levels of feedback. execute is fire-and-forget: a Runnable in, no result out. In contrast, submit is the working form. It takes a functional interface, Runnable or Callable, and returns a Future, a handle to the pending result. invokeAll is the batch form, and it is what parallelizes the link checker:
// the sequential Part 4 loop:
for (var uri : urls) {
results.add(fetchWithRetry(uri)); // one thread, one URL at a time
}
// the concurrent version, same sealed Result types, four lines of change:
try (var pool = Executors.newFixedThreadPool(4)) {
var tasks = urls.stream()
.map(uri -> (Callable<Result>) () -> fetchWithRetry(uri)) // Callable: returns
.toList();
var results = new ArrayList<Result>();
for (var future : pool.invokeAll(tasks)) { // submit all, wait for all
results.add(future.get()); // already done: get returns at once
}
System.out.println(report(results));
}
Read what changed and what did not. The domain logic, fetchWithRetry, the sealed Result hierarchy, the report, is untouched. Concurrency wrapped the outside of the design instead of infecting the inside, which is the entire argument for keeping work in Callable and results in records. Four threads fetch concurrently, so the wall time approaches the slowest URL rather than the sum of all URLs. And the try-with-resources is the Java 19+ idiom: ExecutorService is AutoCloseable, close waits for completion, so the pool cannot leak.
Future: The Handle to Pending Work
submit hands back a Future immediately, and the Future is the conversation you can have with unfinished work:
Future<Result> future = pool.submit(() -> fetch(uri));
future.isDone(); // has it finished?
Result r = future.get(5, TimeUnit.SECONDS); // wait up to 5s, TimeoutException after
future.cancel(true); // true: interrupt a running task
Three rules keep Futures honest. First, always call get with a timeout. Bare get can wait forever, and forever is the failure mode the networking article already taught you to refuse. get throws ExecutionException wrapping the task’s own exception, which is the next section’s subject. Finally, cancel(true) only interrupts; it does not kill. Whether the task stops depends on whether it honors interruption, which is why the threads article’s interrupt etiquette was not optional.
Exceptions: Where Pool Failures Hide
A task that throws in a pool does not crash the pool and does not print by default. Where the exception goes depends on how you submitted:
// WRONG: the exception is captured into the Future, and nobody ever calls get
pool.submit(() -> parse(file)); // throws inside? stored silently, gone
// task "succeeded" from the caller's view
// RIGHT: get() surfaces it, wrapped in ExecutionException
try {
var record = pool.submit(() -> parse(file)).get(10, TimeUnit.SECONDS);
} catch (ExecutionException e) {
throw new IllegalStateException("parse task failed", e.getCause()); // the real one
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // caller's stop signal
}
The delta is silent failure versus surfaced failure. submit stores the task’s exception inside the Future; get rethrows it wrapped in ExecutionException, and getCause recovers the original. Fire-and-forget submit calls with no get are the pool-era version of swallowing exceptions, and they are everywhere in real codebases. If you genuinely do not need the result, use execute, whose failures at least reach the thread’s uncaught-exception handler and stderr. Alternatively, wrap the task body with its own try-catch and logging, which the logging article in Part 7 formalizes.
Shutdown Etiquette: The Three-Step Idiom
Shutdown is where young services leak threads and old services hang. The ExecutorService contract gives you three operations, and the idiom composes them:
pool.shutdown(); // 1. stop accepting; finish what's queued
if (!pool.awaitTermination(30, TimeUnit.SECONDS)) { // 2. wait gracefully, with a deadline
pool.shutdownNow(); // 3. interrupt stragglers, drop queued tasks
pool.awaitTermination(10, TimeUnit.SECONDS); // give the interrupted a moment to exit
}
shutdown is the soft close: no new tasks, existing work finishes. In contrast, shutdownNow is the hard close: it interrupts running tasks and returns the queued ones. Whether a running task actually stops depends, again, on interruption etiquette. awaitTermination with a deadline converts “hangs forever on a stuck task” into “logged and escalated after 30 seconds”. Since Java 19, the try-with-resources form does steps 1 and 2, waiting indefinitely. That suits short-lived tools like the link checker, while long-running services usually want explicit control and deadlines. One more distinction for the toolbox: the factory Executors.newVirtualThreadPerTaskExecutor aside, pools are non-daemon by default. So a forgotten shutdown keeps the JVM alive, which is the symptom you will meet first: the program should have exited and did not.
Sizing Thread Pools in Java: Cores, Blocking, and the Honest Answer
The ThreadPoolExecutor documentation states the classic guidance plainly, and it splits cleanly by workload:
| Workload | What the tasks do | Starting size | The constraint that actually binds |
|---|---|---|---|
| CPU-bound | Parse, hash, compress, transform | availableProcessors(), maybe +1 | More threads than cores just adds context-switching |
| I/O-bound | HTTP calls, JDBC queries, file reads | Larger: 2-4x cores as a starting point | Not cores: the downstream service, its connection pool, its rate limits |
| Mixed | A bit of both in one task | Separate pools per workload | One shared pool lets CPU work starve I/O work or vice versa |
The honest answer to “what size” is measured, not derived. Load the pool with realistic work, watch queue depth and latency, and adjust. The guidance gets you into the right order of magnitude; the monitoring tells you the number. Part 6’s profiling article gives you the measurement tools.
The Production Pool: Bounded Queue, Explicit Rejection
The factory methods cannot express the configuration production usually wants, so the production form constructs ThreadPoolExecutor directly:
var bounded = new ThreadPoolExecutor(
4, 8, // core 4 threads, max 8 under pressure
60, TimeUnit.SECONDS, // threads above core idle out in 60s
new ArrayBlockingQueue<>(100), // FINITE shelf: 100 queued tasks, no more
new ThreadPoolExecutor.AbortPolicy()); // full queue: submit throws immediately
// instead of growing the heap
AbortPolicy is the default and the right choice for services. For example, a RejectedExecutionException at submit time is backpressure you can catch, log, and convert into a 503 or a retry. By contrast, CallerRunsPolicy runs the task on the submitting thread, and it is the classic choice for batch pipelines. It slows the producer to the consumer’s pace for free. What no policy can fix is the unbounded queue, which is the next section’s war story.
How Real Systems Do This
Executors are the connective tissue of every Java server. For example, servlet containers run request handling on fixed pools sized in configuration files. Similarly, batch frameworks process chunks through dedicated pools, and message listeners consume through pools per topic. Every major framework, Spring, Quartz, gRPC, Kafka clients, exposes an ExecutorService as its concurrency knob.
The conventions you should expect to inherit are consistent. Thread pools in Java production code are named and bounded, created at startup and shut down at teardown. They are sized per workload rather than shared across unlike work, and monitored by queue depth, because the queue is the pool’s early-warning instrument.
My unbounded queue war story is a data pipeline. In fact, it is the most common executor failure pattern in the industry. The pipeline read events from a fast source and submitted a processing task per event to a newFixedThreadPool(8). Processing was slower than arrival, which is normal. The pool absorbed the difference the only way it could: the unbounded queue grew. For hours the service looked healthy: CPU fine, threads fine, latency fine. That was because the queued tasks sat quietly in the heap as FutureTask objects with their closures.
The OOM arrived at 4 a.m., and the heap dump was 2 gigabytes of waiting tasks. Then the diagnosis took one glance at the dominators. The fix was three lines. It used an ArrayBlockingQueue sized to what the service could actually drain, an AbortPolicy, and a rejection handler that logged and dropped with a counter. The deeper fix was admitting that a producer faster than its consumer needs backpressure, not a bigger shelf. Every pool I have configured since starts bounded. I also treat unbounded queues in review the way I treat bare get(): as a question, not an error, but a question that must be answered.
Decision Framework
- Is the work CPU-bound, parsing, hashing, transforming? Fixed pool at availableProcessors, and resist more threads than cores.
- Is the work I/O-bound, HTTP, JDBC, files, with much blocking? Fixed pool larger than cores, sized to the downstream’s tolerance, or the virtual-thread executor of article 47.
- Can the producer outrun the consumer, ever, including under retry storms and traffic spikes? Bounded ThreadPoolExecutor with an explicit rejection policy, decided now, not at 4 a.m.
- Do you need results? submit or invokeAll, and every Future gets a get with a timeout.
- Fire-and-forget, genuinely? execute, or a submit with an in-task try-catch that logs: no orphaned exceptions.
- Is the executor short-lived and local? try-with-resources, the Java 19 idiom.
- Is the executor long-lived and shared? Create at startup, shut down with the three-step idiom and deadlines, and expose its metrics.
- Are unlike workloads sharing one pool? Split them: a shared pool couples their failure modes and makes sizing impossible to reason about.
When NOT to Use This
- Do not use newCachedThreadPool for untrusted input or request paths. The unbounded thread growth is the same OOM with worse locality, one thread per incoming request in a burst.
- Do not create an executor per request or per operation. Pool construction is service initialization, not a per-call cost, and the factory-per-request pattern inverts everything this article teaches.
- Do not call get() without a timeout. Also, do not call get() on the request thread of a service to a pool you also service requests with. A pool starved by waiting on itself is a self-inflicted deadlock.
- Do not put one task that runs for minutes in a pool shared with tasks that run for milliseconds. Otherwise, one lane-hogger becomes everyone’s latency.
- Do not reach for a pool at all for single-shot background work on an otherwise sequential tool. Instead, a named raw thread from the threads article is simpler, and honest simplicity is a design decision too.
Common Mistakes
- Forgetting shutdown: the program finishes its work and never exits. That is because non-daemon pool threads are still alive and waiting for tasks.
- Fire-and-forget submit: exceptions are captured into Futures nobody reads, so failures vanish. Use execute, get the result, or log inside the task.
- Bare get(): waits forever on a stuck task; every get wants a timeout, like every socket did.
- Unbounded queue as an architecture: it converts overload into OOM. Meanwhile, the system looks healthy right up until the heap dump.
- Sizing by guessing or by copying another service’s number: the two binding constraints, cores and downstream capacity, are per workload. Therefore, the number comes from measurement.
- Ignoring ExecutionException’s cause: catching it and logging the wrapper loses the real stack trace. Instead, unwrap getCause or pass it along.
- shutdownNow and expecting tasks to be gone: it interrupts, but it does not kill. So a task that never checks the flag survives it.
Key Takeaways
- An ExecutorService is a roster of reusable threads plus a queue. Your code submits work as Callable or Runnable lambdas and never manages a lifecycle.
- newFixedThreadPool is the default. However, every factory method hides a queue and rejection policy you did not choose, and the unbounded queue is the one that bites.
- submit returns a Future: get, with a timeout, collects the result or the failure, wrapped in ExecutionException. So an unread Future is a swallowed exception.
- invokeAll parallelized the link checker in four lines, touching no domain logic: concurrency at the boundary, sealed types inside.
- Shutdown is the three-step idiom, shutdown, awaitTermination with a deadline, shutdownNow, and try-with-resources does the graceful version since Java 19.
- Sizing splits by workload: cores for CPU-bound and downstream capacity for I/O-bound. Use separate pools for mixed work, and measurement for the actual number.
- The production pool is bounded: ThreadPoolExecutor with a finite queue and an explicit rejection policy. That turns overload into backpressure instead of OOM.
- cancel and shutdownNow interrupt, but they do not kill. The interrupt etiquette from the threads article is what makes every pool promise true.
FAQ
What is ExecutorService in Java?
An interface representing a managed group of threads. It accepts tasks, runs them concurrently, returns Futures for results, and supports orderly shutdown. Production code submits work to it instead of creating threads by hand.
What is the difference between execute and submit in ExecutorService?
execute takes a Runnable and returns nothing, with task exceptions going to the thread’s uncaught-exception handler. In contrast, submit takes a Runnable or Callable and returns a Future. The Future stores any exception and rethrows it wrapped in ExecutionException when get is called.
What is the difference between shutdown and shutdownNow?
shutdown stops accepting new tasks and lets queued and running work finish. shutdownNow additionally interrupts running tasks and drops the queued ones. The production idiom is both, with awaitTermination and a deadline between them.
How do I size a thread pool in Java?
By workload and measurement: CPU-bound work gets availableProcessors threads, since more just context-switches. I/O-bound work gets more threads, bounded by the downstream service’s capacity, not the cores. Start with the guidance, then adjust from measured queue depth and latency.
What happens to exceptions thrown inside a thread pool task?
For submit, the Future captures the exception and rethrows it as ExecutionException when you call get. So unread Futures hide failures. For execute, it propagates to the thread’s uncaught-exception handler and stderr. Either way the pool survives and keeps serving other tasks.
Conclusion
You now hold the workhorse of Java concurrency: thread pools in Java that reuse threads and submissions that return Futures. You also have a shutdown idiom with deadlines, sizing rules split by workload, and the bounded-queue discipline that separates services that degrade from services that crash. The link checker gained a concurrent fetch in four lines, without its domain design changing by a single character.
Two tools in this article were deliberately shallow. The first is the intrinsic-lock synchronization from the threads article, which needs its own treatment. The second is the Future, which is a handle to one value when pipelines need composition. The next article takes the first: synchronization and locks in Java. It covers synchronized, volatile, ReentrantLock, ReadWriteLock, and the deadlock you will one day write.
Submit work, bound the shelf, and get your results with a deadline. The pool does the rest.
Last updated on 15 September 2026.
