Java ExecutorService and Thread Pools: Future and Shutdown
An ExecutorService runs tasks on a pool of reusable Java threads and returns a Future for each. Fixed vs cached pools, Future.get() and a clean shutdown.
- Course: Java study plan
- Module: Concurrency
- Kind: Lesson
- Reading time: 14 min
- Runtime: OpenJDK 18
What is ExecutorService in Java?
ExecutorService in Java is a pool of reusable threads that runs the Runnable and Callable tasks you submit, so you manage tasks rather than threads. submit returns a Future whose get() gives the result or rethrows the task's failure wrapped in ExecutionException. Call shutdown() and awaitTermination() when finished, or the pool's threads keep the JVM alive.
Lesson
Creating a thread per task does not scale, and juggling Thread objects by hand — start, join, collect results, handle the one that threw — is repetitive and easy to get wrong. java.util.concurrent separates the task from the thread that runs it: you submit Runnables and Callables to an ExecutorService, a pool of reusable threads runs them, and each submission hands back a Future through which you collect the result or the exception. This lesson covers the pools, the two submission styles, the shutdown protocol everybody forgets, and what a Future actually promises.
The executor model
ExecutorService pool = Executors.newFixedThreadPool(4);
Future<Integer> f = pool.submit(() -> expensiveComputation(42)); // a Callable<Integer>
// … do other things …
int result = f.get(); // blocks until the task completes; rethrows its exception wrapped
pool.shutdown();
execute(Runnable) fires and forgets; submit(Callable) returns a Future<V>; submit(Runnable) returns a Future<?> that completes with null. Tasks queue up when every pool thread is busy and run in submission order per queue — but on different threads, so nothing about their relative timing is guaranteed.
The factory methods, and when each is wrong
| Factory | Threads | Queue | Use | Danger |
|---|---|---|---|---|
newFixedThreadPool(n) | exactly n | unbounded | CPU-bound work, n ≈ cores | unbounded queue → memory grows under overload |
newCachedThreadPool() | as many as needed, reused for 60 s | none (direct hand-off) | many short I/O tasks, bursty | unbounded threads → unable to create native thread |
newSingleThreadExecutor() | 1 | unbounded | serialise access to a resource; ordered event processing | one slow task blocks everything |
newScheduledThreadPool(n) | n | delayed | periodic jobs, timeouts | an exception kills the periodic task silently |
newWorkStealingPool() | parallelism = cores | per-thread deques | a ForkJoinPool for many small tasks | no ordering; daemon threads |
newVirtualThreadPerTaskExecutor() (21) | a virtual thread per task | none | blocking I/O at scale | not for CPU-bound work |
For production services the honest answer is the constructor underneath: new ThreadPoolExecutor(core, max, keepAlive, unit, new ArrayBlockingQueue<>(capacity), factory, rejectionHandler) — bounded queue, named threads, and an explicit policy (CallerRunsPolicy, AbortPolicy) for what happens when both are full. "Both the queue and the thread count are bounded" is the sentence that shows you have run one.
Sizing: CPU-bound → about the number of cores (Runtime.getRuntime().availableProcessors()); I/O-bound → more, roughly cores × (1 + wait/compute), or virtual threads.
Submitting many tasks
List<Callable<Integer>> tasks = List.of(() -> work(1), () -> work(2), () -> work(3));
List<Future<Integer>> futures = pool.invokeAll(tasks); // blocks until ALL are done; same order as tasks
for (Future<Integer> f : futures) System.out.println(f.get());
Integer first = pool.invokeAny(tasks); // the first to succeed; the rest are cancelled
invokeAll returns the futures in the order of the task list, regardless of which finished first — which is how a multi-threaded program prints deterministic output. invokeAny is for redundant requests (three replicas, take the fastest).
What a Future gives you
get()— block until done; returns the value, or throwsExecutionExceptionwrapping the task's exception (e.getCause()is the real one), orInterruptedExceptionif you were interrupted while waiting, orCancellationException.get(timeout, unit)— the same with a deadline;TimeoutExceptionif it passes. Prefer this in anything user-facing.isDone(),isCancelled();cancel(mayInterruptIfRunning)— removes a queued task, or interrupts a running one if you say so (and only helps if the task honours interruption).
A Future is pull-based: you ask and wait. It cannot be composed ("when this finishes, do that") — that is CompletableFuture (lesson 6). A task that throws does not print anything: the exception sits inside the Future until someone calls get(). Uncollected futures hide failures; either call get() or use an executor whose afterExecute logs them.
Shutting down, properly
An ExecutorService keeps its threads alive; a pool you never shut down keeps the JVM alive (non-daemon threads) and is a resource leak in anything long-running.
pool.shutdown(); // stop accepting tasks; let queued ones finish
if (!pool.awaitTermination(30, TimeUnit.SECONDS)) { // wait for them
pool.shutdownNow(); // interrupt what is still running; returns the never-started tasks
}
shutdown() is graceful; shutdownNow() interrupts. Since Java 19 ExecutorService is AutoCloseable, so try (var pool = Executors.newFixedThreadPool(4)) { … } does shutdown-and-await for you. In a scheduled pool, remember that a periodic task that throws is cancelled silently — wrap the body in a try/catch that logs.
Exceptions and thread death
In a pool, an exception escaping a Runnable given to execute() kills the worker thread (the pool replaces it) and reaches the uncaught-exception handler; the same exception from submit() is captured in the Future and nothing is printed. Choose deliberately: execute when you want loud failures, submit when you will look at the Future.
ThreadLocal in pools — the classic leak
ThreadLocal gives each thread its own value (a per-request context, a non-thread-safe formatter). In a pool the thread outlives the task, so the value survives into the next unrelated task unless you remove() it in a finally. Always pair set with remove.
Interview angle
- "Why use an executor instead of
new Thread?" Reuse (thread creation is expensive), bounded concurrency, queuing, results viaFuture, a clean lifecycle. - "Fixed versus cached pool?" Fixed: bounded threads, unbounded queue — for CPU work. Cached: unbounded threads — for short bursty I/O. Production:
ThreadPoolExecutorwith both bounded. - "What does
Future.get()throw?"ExecutionExceptionwrapping the task's failure,InterruptedException,CancellationException,TimeoutExceptionwith a deadline. - "
shutdownversusshutdownNow?" Graceful (finish queued tasks) versus interrupt-and-return-the-rest. - "Why is
Callableneeded?"Runnablecannot return or throw checked exceptions;Callable<V>does both.
Key takeaways
- Task in,
Futureout; the pool owns the threads.submitfor results,executefor fire-and-forget. - Know the factories and their unbounded side; in production build a
ThreadPoolExecutorwith a bounded queue and a rejection policy. invokeAllreturns futures in task order — deterministic output from parallel work.get()rethrows asExecutionException; never leave futures uncollected;getwith a timeout in user-facing code.shutdown()+awaitTermination()(+shutdownNow()), or try-with-resources on 19+;ThreadLocal.remove()in afinally.
Common questions
What is the difference between newFixedThreadPool and newCachedThreadPool?
A fixed pool has exactly n threads and an unbounded queue, which suits CPU-bound work sized to the core count. A cached pool creates as many threads as needed and reuses idle ones for 60 seconds, which suits short bursty I/O tasks, but its unbounded thread count can exhaust native threads.
What is the difference between shutdown and shutdownNow?
shutdown() stops accepting new tasks and lets queued ones finish; shutdownNow() interrupts running tasks and returns those that never started. The usual sequence is shutdown(), then awaitTermination with a timeout, then shutdownNow() if the timeout expires.
What exceptions does Future.get() throw?
ExecutionException wrapping the task's own exception (call getCause() for the real one), InterruptedException if the waiting thread is interrupted, CancellationException if the task was cancelled, and TimeoutException from get(timeout, unit) when the deadline passes.
What is the difference between execute and submit in ExecutorService?
execute(Runnable) fires and forgets: an exception kills the worker thread and reaches the uncaught-exception handler. submit returns a Future and captures any exception inside it, so nothing is printed until someone calls get().
How many threads should a Java thread pool have?
For CPU-bound work, about the number of cores, from Runtime.getRuntime().availableProcessors(). For I/O-bound work, more — roughly cores × (1 + wait time / compute time) — or virtual threads. In production, bound both the queue and the thread count with a ThreadPoolExecutor.
Exercises
invokeAll keeps the order
Read a pool size p, an integer n and n integers. Build a Callable<Integer> per integer x that counts the primes ≤ x, submit them all with invokeAll on a fixed pool of p threads, and print <x> -> <count> in input order — invokeAll returns the futures in task order however the threads finish. Then shutdown(), awaitTermination, and print terminated=<isTerminated()>.
Example: 2 then 3 then 10 20 1 →
10 -> 4
20 -> 8
1 -> 0
terminated=trueFailures stay in the Future
Read an integer n and n integers. On a fixed pool of 2 threads, submit a Callable per value that returns 100 / x. Collect each Future in order: print task <i>: <result> on success, or task <i>: failed <simple class name of the cause> when get() throws ExecutionException (use getCause()). Then print completed=<n ok> failed=<n failed> and shut the pool down.
Example: 3 then 4 0 25 →
task 0: 25
task 1: failed ArithmeticException
task 2: 4
completed=2 failed=1In this module: Concurrency
← Visibility, volatile and the Java Memory Model · Locks, atomics and the concurrent collections →