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.