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)

📋 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

Related lessons