Lesson 45 • Advanced
Thread Pools & Executors
Stop creating a fresh thread for every task. Learn to hand work to an ExecutorService — a small team of reusable threads — so your program stays fast and stable even under heavy load.
What You'll Learn in This Lesson
- ✓Why a thread pool beats raw new Thread() calls
- ✓Create pools with Executors: fixed, cached, single, scheduled
- ✓submit() work and read results back with Future
- ✓Use Callable when a task must return a value
- ✓Close pools cleanly with shutdown() and awaitTermination()
- ✓Tune a ThreadPoolExecutor with a bounded queue
Before You Start
You should already know how to create a thread directly — see Multithreading. This lesson shows you why you almost never do that by hand, and what to use instead.
A Real-World Analogy: The Coffee Shop
💡 Analogy: A busy coffee shop does not hire a new barista for every single order and fire them after one drink. That would be slow and absurdly expensive. Instead it keeps a fixed team of baristas. Orders pile up on a queue; whenever a barista is free, they grab the next order.
That team is a thread pool. Each barista is a reusable thread. Each coffee order is a task. The order rail is the pool's internal queue. You (the customer) don't manage baristas — you just submit your order and, if you want the actual drink back, you wait at the counter for it. That "wait for the result" is exactly what a Future does in code.
Creating a real OS thread costs roughly 1MB of memory plus setup time. Fire off 10,000 raw threads and you run out of memory. A pool reuses a handful of threads for unlimited tasks — that's why pools win.
1️⃣ Submitting Tasks to a Fixed Pool
An ExecutorService is the interface you talk to: you submit tasks and it runs them on its threads. You rarely build one by hand — the Executors factory class hands you ready-made pools.
The most common one is Executors.newFixedThreadPool(n): it keeps exactly n threads alive forever. Submit more than n tasks and the extras wait in a queue until a thread is free. In the example below, 6 tasks share 3 threads — so threads get reused.
A () -> {...} lambda passed to submit() is a Runnable: a unit of work that returns nothing. Notice the loop copies i into a final-ish local taskId — a lambda can only capture variables that never change.
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public class Main {
public static void main(String[] args) throws InterruptedException {
// Create a pool that REUSES exactly 3 threads for every task.
// You hand it work; it decides which thread runs each piece.
ExecutorService pool = Executors.newFixedThreadPool(3);
// Submit 6 tasks. Only 3 run at once; the rest WAIT in a queue.
for (int i = 1; i <= 6; i++) {
int taskId = i; // capture i for the lambda
pool.submit(() -> { // submit() hands the pool a Runnable
String worker = Thread.currentThread().getName();
System.out.println("Task " + taskId + " ran on " + worker);
});
}
// shutdown() = "no new tasks", but let queued ones finish.
pool.shutdown();
// Block here until every task is done (or 10s passes).
pool.awaitTermination(10, TimeUnit.SECONDS);
System.out.println("All tasks finished. Pool closed.");
}
}Task 1 ran on pool-1-thread-1
Task 2 ran on pool-1-thread-2
Task 3 ran on pool-1-thread-3
Task 4 ran on pool-1-thread-1
Task 5 ran on pool-1-thread-2
Task 6 ran on pool-1-thread-3
All tasks finished. Pool closed.2️⃣ Getting a Result Back: Callable & Future
A Runnable returns nothing. When a task must compute and return a value, use a Callable instead — its body ends with a return.
Submitting a Callable gives you a Future<T> — a handle to a result that may not exist yet. Your main thread keeps running. When you actually need the answer, call future.get(), which blocks until the task finishes and then returns the value.
future.get(5, TimeUnit.SECONDS). The no-argument get() waits forever — a single stuck task can freeze your whole program.import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
public class Main {
public static void main(String[] args) throws Exception {
ExecutorService pool = Executors.newFixedThreadPool(2);
// A Runnable returns nothing. A Callable RETURNS a value.
Callable<Integer> job = () -> {
int sum = 0;
for (int i = 1; i <= 100; i++) sum += i; // 1 + 2 + ... + 100
return sum; // hand back the answer
};
// submit(Callable) gives you a Future: a handle to a result
// that may not exist YET. The main thread keeps running.
Future<Integer> future = pool.submit(job);
System.out.println("Submitted — main thread is free to do other work");
// future.get() BLOCKS until the result is ready, then returns it.
// Use the timeout version so a stuck task can't hang you forever.
int result = future.get(5, TimeUnit.SECONDS);
System.out.println("Sum of 1..100 = " + result);
pool.shutdown();
pool.awaitTermination(5, TimeUnit.SECONDS);
}
}Submitted — main thread is free to do other work
Sum of 1..100 = 50503️⃣ Choosing the Right Pool
The Executors class gives you four pools for four jobs. Pick by the shape of your workload:
| Factory method | Threads | Best for | Watch out for |
|---|---|---|---|
| newFixedThreadPool(n) | Exactly n | Steady CPU-bound work | Unbounded queue can OOM |
| newCachedThreadPool() | Grows/shrinks | Many short, bursty tasks | No ceiling — spikes make too many |
| newSingleThreadExecutor() | Exactly 1 | Tasks that must run in order | No parallelism at all |
| newScheduledThreadPool(n) | Exactly n | Delayed / repeating tasks | A thrown exception stops future runs |
🧠 Rule of thumb:
For CPU-bound work, size a fixed pool to Runtime.getRuntime().availableProcessors() (the number of cores). More threads than cores just adds context-switching overhead without doing more real work.
🎯 Your Turn #1 — Create and close a pool
Fill in the two blanks: create a fixed pool of 4 threads, and shut it down so the program can exit. Run it and check your output against the comment.
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public class Main {
public static void main(String[] args) throws InterruptedException {
// 🎯 YOUR TURN — fill in the blanks marked with ___
// 1) Create a pool that REUSES exactly 4 threads.
ExecutorService pool = ___; // 👉 Executors.newFixedThreadPool(4)
// 2) Submit 4 print tasks (one per loop turn).
for (int i = 1; i <= 4; i++) {
int n = i;
pool.submit(() -> System.out.println("Working on item " + n));
}
// 3) Stop accepting new tasks (queued ones still finish).
pool.___; // 👉 shutdown()
// 4) Wait up to 5 seconds for everything to complete.
pool.awaitTermination(5, TimeUnit.SECONDS);
System.out.println("Done");
// ✅ Expected output (order of "Working on item" lines may vary):
// Working on item 1
// Working on item 2
// Working on item 3
// Working on item 4
// Done
}
}🎯 Your Turn #2 — Read a Future
Submit a Callable that returns a word's length, then pull the value out of the Future. Two blanks to fill.
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
public class Main {
public static void main(String[] args) throws Exception {
// 🎯 YOUR TURN — submit a Callable and read its result
ExecutorService pool = Executors.newSingleThreadExecutor();
// 1) Submit a Callable that returns the length of a word.
// submit() with a value-returning lambda gives a Future<Integer>.
Future<Integer> future = pool.submit(() -> ___); // 👉 "executor".length()
// 2) Get the value out of the Future (blocks until ready).
int length = future.___; // 👉 get()
System.out.println("Word length: " + length);
pool.shutdown();
pool.awaitTermination(5, TimeUnit.SECONDS);
// ✅ Expected output:
// Word length: 8
}
}4️⃣ Tuning a ThreadPoolExecutor
Every factory pool is secretly a ThreadPoolExecutor underneath. Building one yourself unlocks the two settings that keep a production server alive: a bounded queue and a rejection policy.
The constructor takes a core size (threads always kept), a max size (hard ceiling), a keep-alive (how long idle extra threads survive), the queue, and a rejection policy for when the queue is full. CallerRunsPolicy makes the submitting thread run the overflow task itself — that slows the producer down and creates natural backpressure.
import java.util.concurrent.*;
public class Main {
public static void main(String[] args) throws InterruptedException {
// The factory methods are shortcuts. THIS is what they build —
// and building it yourself lets you set a BOUNDED queue + a
// rejection policy, the two settings that keep a server alive.
ThreadPoolExecutor pool = new ThreadPoolExecutor(
2, // corePoolSize: threads kept alive
4, // maximumPoolSize: hard ceiling
30L, TimeUnit.SECONDS, // keepAlive: idle extra threads die after 30s
new ArrayBlockingQueue<>(100), // BOUNDED queue — refuses to grow forever
new ThreadPoolExecutor.CallerRunsPolicy()); // overflow: caller runs it (backpressure)
for (int i = 1; i <= 5; i++) {
int n = i;
pool.submit(() -> System.out.println("Job " + n + " on " + Thread.currentThread().getName()));
}
System.out.println("core=" + pool.getCorePoolSize() + " max=" + pool.getMaximumPoolSize());
pool.shutdown();
pool.awaitTermination(10, TimeUnit.SECONDS);
}
}Job 1 on pool-1-thread-1
Job 2 on pool-1-thread-2
Job 3 on pool-1-thread-1
Job 4 on pool-1-thread-2
Job 5 on pool-1-thread-1
core=2 max=4Mini-Challenge — Square in Parallel
Now with the scaffolding removed. Read the comment outline, then write it yourself: square four numbers using a pool, collect the Futures, and print each result. The expected output is in the comment so you can self-check.
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.*;
public class Main {
public static void main(String[] args) throws Exception {
// 🎯 MINI-CHALLENGE: square four numbers in parallel
// 1. Create a fixed thread pool of 2 threads.
// 2. For each number in {2, 3, 4, 5}, submit a Callable<Integer>
// that returns number * number. Keep every Future in a List.
// 3. Loop the Futures and print each result with future.get().
// 4. shutdown() the pool and awaitTermination().
//
// ✅ Expected output (order matches submission):
// 4
// 9
// 16
// 25
// your code here
}
}Common Errors (and the fix)
- ❌ Program never exits (no shutdown): if you forget
pool.shutdown(), the pool's non-daemon threads keep the JVM alive forever even after every task finished. Fix: callshutdown()thenawaitTermination(...), ideally in afinallyblock. - ❌ OutOfMemoryError under load (unbounded queue):
newFixedThreadPooluses an unbounded queue. If tasks arrive faster than they finish, the queue grows until memory runs out. Fix: build aThreadPoolExecutorwith a boundedArrayBlockingQueueand a rejection policy. - ❌ The whole pool freezes (blocking the pool): if every thread in a fixed pool calls
future.get()on a task that itself needs a free pool thread, you deadlock. Fix: never block a pool thread waiting on the same pool; use a timeout and keep dependent work off the pool. - ❌ Silent failures (ignoring Future exceptions): an exception thrown inside a
Callableis stored, not printed — it stays hidden until you callget(). Skipget()and the bug vanishes silently. Fix: always callfuture.get()and handle theExecutionExceptionit rethrows. - ❌
RejectedExecutionException: you calledsubmit()aftershutdown(), or a bounded queue is full with an abort policy. Fix: submit all work before shutting down, or useCallerRunsPolicyto absorb overflow.
📋 Quick Reference
| Goal | Code | Notes |
|---|---|---|
| Fixed pool | Executors.newFixedThreadPool(4) | n reusable threads |
| Cached pool | Executors.newCachedThreadPool() | Grows on demand, no ceiling |
| Single thread | Executors.newSingleThreadExecutor() | Runs tasks in order |
| Scheduled | Executors.newScheduledThreadPool(2) | Delayed / periodic |
| Fire-and-forget | pool.submit(() -> {...}) | Runnable, no result |
| Get a result | Future<T> f = pool.submit(callable) | Callable returns a value |
| Read result | f.get(5, TimeUnit.SECONDS) | Blocks; use a timeout |
| Stop (graceful) | pool.shutdown() | No new tasks; finish queue |
| Wait to finish | pool.awaitTermination(10, SECONDS) | Blocks until done/timeout |
| Force stop | pool.shutdownNow() | Returns pending tasks |
Frequently Asked Questions
What is the difference between a thread pool and creating threads with new Thread()?
A raw 'new Thread()' is created, used once, then thrown away — and each one costs about 1MB of stack memory plus OS setup time. A thread pool creates a small set of threads once and reuses them for thousands of tasks. You submit work; the pool decides which idle thread runs it. Pools cap how many threads exist, queue extra work, and stay stable under load, where raw threads would exhaust memory.
When should I use newFixedThreadPool vs newCachedThreadPool vs newSingleThreadExecutor?
Use newFixedThreadPool(n) for steady, CPU-bound work where you want a known concurrency limit (a common choice is the number of CPU cores). Use newCachedThreadPool() for many short, bursty tasks — it spins up threads on demand and lets idle ones die, but it has no upper bound, so it can create too many threads under a spike. Use newSingleThreadExecutor() when tasks must run one at a time, in order. Use newScheduledThreadPool(n) for delayed or repeating tasks.
What is the difference between Runnable and Callable?
A Runnable's run() method returns nothing and cannot throw checked exceptions. A Callable's call() method RETURNS a value and may throw checked exceptions. Submit a Callable when you need the answer back: pool.submit(callable) gives you a Future, and future.get() returns the value (or rethrows the exception the task threw). Use a Runnable for fire-and-forget work where there is no result to collect.
Why does my program never exit after the tasks finish?
An ExecutorService's worker threads are non-daemon by default, so the JVM keeps running as long as the pool is alive — even after every task is done. You must call pool.shutdown() to tell it no more tasks are coming. Then call awaitTermination(...) to block until the queued tasks finish. Put shutdown() in a finally block (or use try-with-resources on Java 19+) so it runs even when a task throws.
What does future.get() do, and why might it hang?
future.get() blocks the calling thread until the task's result is ready, then returns it. If the task is slow or stuck, get() waits indefinitely — which can freeze your program. Always prefer the timeout overload, future.get(5, TimeUnit.SECONDS), which throws TimeoutException instead of hanging forever. If the task threw an exception, get() rethrows it wrapped in an ExecutionException, so exceptions are never silently lost as long as you call get().
Why should I bound the queue with ThreadPoolExecutor instead of using the factory methods?
Executors.newFixedThreadPool uses an UNBOUNDED LinkedBlockingQueue. If tasks arrive faster than they finish, that queue grows without limit until you get an OutOfMemoryError. Building a ThreadPoolExecutor yourself lets you pass a bounded queue (e.g. new ArrayBlockingQueue<>(100)) plus a rejection policy like CallerRunsPolicy, which makes the submitting thread run overflow work. That creates natural backpressure and keeps a busy server alive.
🎉 Lesson Complete!
Great work! You now hand tasks to an ExecutorService instead of spawning raw threads, pick the right Executors pool for the job, return values with Callable and Future, shut pools down cleanly, and tune a ThreadPoolExecutor with a bounded queue and backpressure.
Next up: Performance Profiling — find and fix the bottlenecks in your concurrent code with JFR, JMH, and flame graphs.
Sign up for free to track which lessons you've completed and get learning reminders.