Lesson 45 • Advanced
Thread Pools & Executors
Manage concurrent tasks efficiently with ExecutorService, thread pools, and Java 21's virtual threads.
Before You Start
You should be comfortable with Multithreading (creating threads), Concurrency Utilities (locks and synchronization), and CompletableFuture (async programming patterns).
What You'll Learn
- ✅ Why thread pools over raw Thread creation
- ✅ ExecutorService: Fixed, Cached, Scheduled pools
- ✅ Callable and Future for results from threads
- ✅ ForkJoinPool for divide-and-conquer tasks
- ✅ Virtual Threads (Java 21 Project Loom)
- ✅ Choosing the right pool for your workload
1️⃣ Why Thread Pools?
Analogy: Imagine a restaurant kitchen. Creating a new thread for every task is like hiring a new chef for every dish — expensive, slow, and chaotic. A thread pool is like having a fixed team of chefs: orders queue up, and available chefs pick up the next dish. Efficient, controlled, and predictable.
Creating a thread costs ~1MB of stack memory and involves OS-level context switching. With 10,000 requests, you'd run out of memory. Thread pools reuse a fixed number of threads, keeping your application stable under load.
| Pool | Factory Method | Best For | Gotcha |
|---|---|---|---|
| Fixed | newFixedThreadPool(n) | CPU-bound, known concurrency | Unbounded queue can OOM |
| Cached | newCachedThreadPool() | Short-lived tasks, variable load | Unbounded threads under spike |
| Scheduled | newScheduledThreadPool(n) | Delayed/periodic tasks | Task exceptions kill future runs |
| ForkJoin | new ForkJoinPool() | Recursive divide-and-conquer | Work-stealing overhead |
| Virtual | newVirtualThreadPerTaskExecutor() | IO-bound, massive concurrency | Don't pool virtuals! |
Try It: Thread Pool Simulation
// 💡 Try modifying this code and see what happens!
// Thread Pool Simulator
console.log("=== Thread Pool Simulator ===\n");
class ThreadPool {
constructor(name, size) {
this.name = name;
this.size = size;
this.queue = [];
this.active = 0;
this.completed = 0;
this.log = [];
}
submit(task) {
if (this.active < this.size) {
this.active++;
this.log.push("🟢 Running: " + task + " (active: " + this.active + "/" + this.size + ")");
this.completed++;
...2️⃣ Custom Pools & Virtual Threads
The factory methods are convenient, but ThreadPoolExecutor gives you full control — core size, max size, queue capacity, and rejection policies. For IO-bound work on Java 21+, virtual threads are a game-changer: each uses only ~1KB of memory versus ~1MB for platform threads.
Key insight: Virtual threads are cheap enough to create one per task — never pool them. They mount and unmount on carrier (platform) threads automatically. Think of them as coroutines managed by the JVM.
Try It: Custom Pool & Virtual Threads
// 💡 Try modifying this code and see what happens!
// Custom ThreadPoolExecutor & Virtual Threads
console.log("=== Custom Pools & Virtual Threads ===\n");
// 1. Custom ThreadPoolExecutor
console.log("1. CUSTOM THREAD POOL:");
console.log(` ThreadPoolExecutor pool = new ThreadPoolExecutor(
4, // core pool size
8, // max pool size
60L, TimeUnit.SECONDS, // idle thread keepalive
new ArrayBlockingQueue<>(10
...Try It: ForkJoinPool & Scheduled Tasks
// 💡 Try modifying this code and see what happens!
// ForkJoinPool and ScheduledExecutorService
console.log("=== ForkJoin & Scheduled Executors ===\n");
// 1. ForkJoinPool — divide-and-conquer
console.log("1. FORKJOINPOOL (Divide & Conquer):");
function mergeSort(arr) {
if (arr.length <= 1) return arr;
let mid = Math.floor(arr.length / 2);
let left = mergeSort(arr.slice(0, mid)); // fork
let right = mergeSort(arr.slice(mid)); // fork
// join (merge)
let result = [], i = 0
...Common Beginner Mistakes
- ❌ Forgetting shutdown() — the JVM won't exit if a non-daemon thread pool is still running. Always call
pool.shutdown()in a finally block - ❌ Using CachedThreadPool for unbounded work — creates unlimited threads under load spikes. Use FixedThreadPool or a custom pool with bounds
- ❌ Ignoring Future exceptions — exceptions inside a Callable are swallowed until you call
future.get(). Always check results! - ❌ Pooling virtual threads — virtual threads are ultra-cheap. Don't put them in a pool — create one per task with
newVirtualThreadPerTaskExecutor() - ❌ Using newFixedThreadPool(1) for ordering — it works but
newSingleThreadExecutor()is clearer and wraps to prevent reconfiguration
Pro Tips
- 💡 Name your threads —
new ThreadFactorywith descriptive names makes debugging 10× easier. Thread dumps show "order-processor-3" instead of "pool-1-thread-3" - 💡 CallerRunsPolicy — best rejection policy for most apps. When the queue is full, the calling thread runs the task itself, creating natural backpressure
- 💡 Monitor pool health — log
getActiveCount(),getQueue().size(), andgetCompletedTaskCount()periodically via JMX - 💡 Virtual threads + synchronized — avoid long
synchronizedblocks with virtual threads (pins the carrier thread). UseReentrantLockinstead
📋 Quick Reference
| API | Method | Returns |
|---|---|---|
| Submit task | pool.submit(callable) | Future<T> |
| Get result | future.get(timeout, unit) | T (blocks) |
| Schedule | pool.schedule(task, delay, unit) | ScheduledFuture |
| Shutdown | pool.shutdown() | void (graceful) |
| Force stop | pool.shutdownNow() | List<Runnable> (pending) |
| Virtual | Thread.startVirtualThread() | Thread (lightweight) |
🎉 Lesson Complete!
You've mastered thread pools, ExecutorService, ForkJoinPool, and virtual threads! You know how to choose the right pool for CPU-bound vs IO-bound work. Next: Performance Profiling — finding and fixing bottlenecks with JFR, JMH, and flame graphs.
Sign up for free to track which lessons you've completed and get learning reminders.