Topic 13.6
Executors and Thread Pools
In one line
Instead of creating a thread per task, you hand tasks to an ExecutorService, which runs them on a reusable thread pool and gives back Futures for their results. Pools bound how many threads run at once, reuse threads, and separate *what* to do from *how* it's scheduled.
Think of it like this
A restaurant kitchen doesn't hire a new cook for every order and fire them afterwards. It has a fixed team of cooks and a ticket rail: orders go on the rail, and whichever cook is free takes the next ticket. The team is the thread pool, the rail is the task queue, and the slip you get back to collect your meal later is a Future.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Thread pool
- A fixed or growing group of threads that are reused to run many tasks.
- ExecutorService
- The Java interface for submitting tasks to be run by a pool, and shutting it down.
- Task
- A piece of work to run: a Runnable (no result) or a Callable (returns a result).
- Callable
- Like Runnable, but its call method returns a value and may throw a checked exception.
- Future
- A handle to a result that will be ready later: you can wait for it, check it, or cancel the task.
- Shutdown
- Telling a pool to stop taking new tasks and let its threads end.
- Rejection policy
- What a pool does when its queue is full: throw, run the task in the caller, or drop it.
Step by step
01Submit tasks, get Futures
ExecutorService pool = Executors.newFixedThreadPool(3); creates three worker threads. Future<Integer> f = pool.submit(() -> compute()); queues the task and returns at once. f.get() blocks until the result is ready.
Submitting is cheap; the pool's threads pull tasks from the queue as they become free.
02Results in a fixed order
Keep the Futures in a list in the order you submitted them and call get() on each in turn. Tasks may finish in any order, but you read results in your order, so the output is deterministic.
invokeAll does this for you: it waits for all tasks and returns Futures in the same order as the input list.
03Exceptions travel inside the Future
If a task throws, the pool thread survives and the exception is stored. future.get() throws ExecutionException whose getCause() is the original exception. If nobody calls get(), the failure goes unnoticed: a common production mystery.
With execute(runnable) instead of submit, an exception goes to the thread's uncaught-exception handler and is printed, and the pool replaces the dead worker.
04Always shut down
A fixed pool's threads are non-daemon: if you forget shutdown(), the program never exits. The safe pattern is pool.shutdown(); pool.awaitTermination(...), or try-with-resources on Java 19+.
// Java 19+: close() calls shutdown() and waits for tasks to finish
try (ExecutorService pool = Executors.newFixedThreadPool(4)) {
pool.submit(task1);
pool.submit(task2);
} // all tasks done here05Bounded queues and back-pressure
newFixedThreadPool uses an unbounded LinkedBlockingQueue: if tasks arrive faster than they're processed, the queue grows until the heap runs out. A ThreadPoolExecutor with an ArrayBlockingQueue of fixed size plus CallerRunsPolicy makes the submitting thread run the task itself when the queue is full, naturally slowing producers down.
ExecutorService pool = new ThreadPoolExecutor(
4, 4, // core and max threads
0L, TimeUnit.MILLISECONDS,
new ArrayBlockingQueue<>(100), // at most 100 waiting tasks
new ThreadPoolExecutor.CallerRunsPolicy());06Scheduled tasks
ScheduledExecutorService runs tasks after a delay (schedule) or repeatedly (scheduleAtFixedRate, scheduleWithFixedDelay). It replaces the old java.util.Timer, which used one thread and died entirely if a task threw.
Try it yourself
- 1
Forget shutdown
In the first example, delete
pool.shutdown();and theawaitTerminationpart of the last line. On your own JDK the program prints the results and then never exits, because the pool's threads are still alive. (In the browser it will hit the time limit.) - 2
Never call get
In "invokeAll and a failing task", replace the loop with
System.out.println(results.size() + " tasks finished");. The exception is never seen. That's how failures disappear in real systems.
Code & diagrams
Expected output
part 0: 31375
part 1: 93875
part 2: 156375
part 3: 218875
total: 500500, pool finished: trueThe failed task didn't kill the pool or the other tasks; its exception waited inside its Future until get() was called.
Expected output
ok: fetched users
failed: orders service down
ok: fetched pricesExpected output
heartbeat 1
heartbeat 2
heartbeat 3
stopped after 3 heartbeatsBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Submit after shutdown
Call pool.submit(() -> 1); after pool.shutdown();.
Myth vs fact
Myth
More threads means faster.
Fact
For CPU-bound work, more threads than cores just adds switching overhead. Size pools to the work.
Myth
If a task throws, you'll see a stack trace.
Fact
With submit, the exception is stored in the Future and only appears when you call get().
Myth
Executors.newFixedThreadPool is production-ready as is.
Fact
Its queue is unbounded, so overload turns into an OutOfMemoryError. Use a bounded ThreadPoolExecutor for untrusted load.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
ThreadPoolExecutorstarts core threads first, then queues tasks, and only creates threads beyond the core size when the queue is full. With an unbounded queue,maximumPoolSizeis never used, a surprise for many readers of the configuration. - ▸
ForkJoinPool(used by parallel streams andCompletableFutureby default) uses work-stealing deques: idle workers steal tasks from the tails of busy workers' queues, which suits many small recursive tasks. - ▸
Give pool threads names with a
ThreadFactory(Thread.ofPlatform().name("orders-", 0).factory()in Java 21) so thread dumps and logs show which pool is busy (Topic 14.5). - ▸
Context such as
ThreadLocalvalues doesn't follow a task into a pool thread, and leftover ThreadLocals in pooled threads leak between tasks; scoped values (Topic 13.11) are the modern answer.
Remember this
- 1
Creating a platform thread is expensive (an OS thread with its own stack, often around 1 MB of reserved memory, Topic 13.1), and thousands of them overwhelm the machine. An **
ExecutorService** keeps a pool of threads and a queue of tasks: yousubmitwork, and pool threads run it. - 2
Executorshas factory methods:newFixedThreadPool(n)(n threads, unbounded queue),newSingleThreadExecutor()(tasks run one at a time, in order),newCachedThreadPool()(grows as needed, reuses idle threads),newScheduledThreadPool(n)(delayed and periodic tasks), andnewVirtualThreadPerTaskExecutor()(Java 21, Topic 13.10). For production,new ThreadPoolExecutor(...)lets you set a bounded queue and a rejection policy. - 3
**
Runnablereturns nothing;Callable<V>** returns a value and may throw a checked exception.submitreturns a **Future<V>**:get()waits for the result (and rethrows a task's exception wrapped inExecutionException),get(timeout)waits at most that long,cancel(true)interrupts the task,isDone()checks without waiting. - 4
invokeAll(tasks)runs a collection of Callables and returns their Futures in the same order;invokeAny(tasks)returns the first successful result. Collecting results through Futures in submission order gives deterministic output even though tasks finish in any order. - 5
A pool must be shut down, or its non-daemon threads keep the JVM alive after
mainends.shutdown()stops accepting tasks and lets queued ones finish;awaitTerminationwaits;shutdownNow()interrupts running tasks. Since Java 19,ExecutorServiceisAutoCloseable, sotry (var pool = ...) { ... }shuts it down and waits automatically. - 6
Size pools for the work: for CPU-bound tasks about the number of cores (
Runtime.getRuntime().availableProcessors()); for tasks that mostly wait on I/O, more threads (or virtual threads). Watch out for unbounded queues (memory grows without limit under load) and for swallowed exceptions: an exception in a submitted task is stored in its Future, invisible until someone callsget().
Explain it without notes
Why use a thread pool instead of creating a thread per task?
What happens to an exception thrown inside a task submitted with submit()?
How do you shut down an executor correctly?
Practice
Use a pool of 4 threads to compute the squares of 1..8 as Callables and print them in order.
Use invokeAny to return whichever of two tasks finishes first.
Trade-offs
- ↔
Fixed pools bound resource use but can queue up delays; cached pools adapt but can create too many threads under load.
- ↔
Bounded queues protect memory and push back on callers, but you must decide what happens when they're full.
Done when you can
Done when you can submit Runnables and Callables and read results through Futures.
Done when you can collect results deterministically with ordered Futures or invokeAll.
Done when you know where task exceptions go and always shut pools down.
Done when you can explain pool sizing and the danger of unbounded queues.