Thread Pools
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.
Learn Thread Pools in our free Java course — a beginner-friendly interactive lesson with worked examples, a practice exercise and a quick reference.
Part of the free Java course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.
What You'll Learn in This Lesson
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.
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.
3️⃣ 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)
Delayed / repeating tasks
A thrown exception stops future runs
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.
🎯 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.
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 .
Mini-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 Future s, and print each result. The expected output is in the comment so you can self-check.
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: call shutdown() then awaitTermination(...) , ideally in a finally block.
- ❌ OutOfMemoryError under load (unbounded queue): newFixedThreadPool uses an unbounded queue. If tasks arrive faster than they finish, the queue grows until memory runs out. Fix: build a ThreadPoolExecutor with a bounded ArrayBlockingQueue and 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 Callable is stored, not printed — it stays hidden until you call get() . Skip get() and the bug vanishes silently. Fix: always call future.get() and handle the ExecutionException it rethrows.
- ❌ RejectedExecutionException : you called submit() after shutdown() , or a bounded queue is full with an abort policy. Fix: submit all work before shutting down, or use CallerRunsPolicy to 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
🎉 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.
Practice quiz
Why use a thread pool instead of new Thread() for every task?
- Raw threads run faster
- new Thread() is deprecated
- A pool reuses a small set of threads, caps how many exist, and queues extra work
- Pools cannot return results
Answer: A pool reuses a small set of threads, caps how many exist, and queues extra work. Each raw thread costs about 1MB plus setup and is thrown away after one use. A pool reuses a few threads for thousands of tasks and stays stable under load.
If you submit 6 tasks to Executors.newFixedThreadPool(3), what happens?
- Only 3 run at a time; the rest wait in a queue until a thread is free
- All 6 run at once
- 3 tasks are dropped
- It throws an exception
Answer: Only 3 run at a time; the rest wait in a queue until a thread is free. A fixed pool keeps exactly 3 threads. The extra tasks queue and run as threads become free, so threads are reused.
What is the difference between Runnable and Callable?
- Runnable returns a value; Callable returns nothing
- They are identical
- Callable cannot be submitted to a pool
- Callable's call() returns a value and may throw checked exceptions; Runnable's run() returns nothing
Answer: Callable's call() returns a value and may throw checked exceptions; Runnable's run() returns nothing. Submit a Callable when you need a result: pool.submit(callable) gives a Future and future.get() returns the value.
What does pool.submit(() -> { int s=0; for(int i=1;i<=100;i++) s+=i; return s; }).get() return?
- 100
- 5050
- 10000
- 0
Answer: 5050. The Callable sums 1..100 = 5050, and future.get() blocks until the result is ready and returns it.
Why might your program never exit after the tasks finish?
- An ExecutorService's worker threads are non-daemon, so you must call shutdown()
- The JVM has a bug
- Futures keep it alive forever
- awaitTermination loops infinitely
Answer: An ExecutorService's worker threads are non-daemon, so you must call shutdown(). Pool threads are non-daemon by default, keeping the JVM alive. Call shutdown() then awaitTermination() to let it exit cleanly.
Which factory method runs tasks one at a time, in order?
- newFixedThreadPool(1)
- newCachedThreadPool()
- newSingleThreadExecutor()
- newScheduledThreadPool(2)
Answer: newSingleThreadExecutor(). newSingleThreadExecutor() uses exactly one thread, running tasks sequentially in submission order.
Why prefer future.get(5, TimeUnit.SECONDS) over the no-argument get()?
- It is faster
- The no-arg get() waits forever, so a stuck task can freeze your program
- The timeout version returns null
- There is no difference
Answer: The no-arg get() waits forever, so a stuck task can freeze your program. future.get() with no timeout blocks indefinitely. The timeout overload throws TimeoutException instead of hanging forever.
Why build a ThreadPoolExecutor instead of using newFixedThreadPool?
- It is shorter to write
- It runs without threads
- It disables the queue
- It lets you set a bounded queue and a rejection policy to prevent OutOfMemoryError
Answer: It lets you set a bounded queue and a rejection policy to prevent OutOfMemoryError. newFixedThreadPool uses an UNBOUNDED queue that can grow until OOM. A custom ThreadPoolExecutor lets you bound the queue and add a policy like CallerRunsPolicy.
What does CallerRunsPolicy do when the queue is full?
- Drops the task silently
- Makes the submitting thread run the overflow task, creating backpressure
- Throws immediately
- Doubles the pool size
Answer: Makes the submitting thread run the overflow task, creating backpressure. CallerRunsPolicy runs the overflow task on the caller's thread, slowing the producer and creating natural backpressure that keeps a busy server alive.
For CPU-bound work, how should you size a fixed thread pool?
- Always 1000 threads
- As many as possible
- Roughly the number of CPU cores (availableProcessors())
- Exactly 2
Answer: Roughly the number of CPU cores (availableProcessors()). Size to Runtime.getRuntime().availableProcessors(). More threads than cores just adds context-switching overhead without doing more real work.
Continue this course
- Previous: Previous Lesson
- Next: Next Lesson