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.
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.
💡 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.
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.
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.
The Executors class gives you four pools for four jobs. Pick by the shape of your workload:
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.
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.
Submit a Callable that returns a word's length, then pull the value out of the Future . Two blanks to fill.
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 .
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.
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.