Thread Pools

Reviewed & published by Brayan K

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.

Part of the free Java course at LearnCodingFast — hands-on lessons with worked examples and the output they print, 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.

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

public class Main {
    public static void main(String[] args) throws InterruptedException {
        // Create a pool that REUSES exactly 3 threads for every task.
        // You hand it work; it decides which thread runs each piece.
        ExecutorService pool = Executors.newFixedThreadPool(3);

        // Submit 6 tasks. Only 3 run at once; the rest WAIT in a queue.
        for (int i = 1; i <= 6; i++) {
            int taskId = i;                       // capture i for the lambda
            pool.submit(() -> {                   // submit() hands the pool a Runnable
                String worker = Thread.currentThread().getName();
                System.out.println("Task " + taskId + " ran on " + worker);
            });
        }

        // shutdown() = "no new tasks", but let queued ones finish.
        pool.shutdown();
        // Block here until every task is done (or 10s passes).
        pool.awaitTermination(10, TimeUnit.SECONDS);
        System.out.println("All tasks finished. Pool closed.");
    }
}

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.

import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;

public class Main {
    public static void main(String[] args) throws Exception {
        ExecutorService pool = Executors.newFixedThreadPool(2);

        // A Runnable returns nothing. A Callable RETURNS a value.
        Callable<Integer> job = () -> {
            int sum = 0;
            for (int i = 1; i <= 100; i++) sum += i;   // 1 + 2 + ... + 100
            return sum;                                 // hand back the answer
        };

        // submit(Callable) gives you a Future: a handle to a result
        // that may not exist YET. The main thread keeps running.
        Future<Integer> future = pool.submit(job);
        System.out.println("Submitted — main thread is free to do other work");

        // future.get() BLOCKS until the result is ready, then returns it.
        // Use the timeout version so a stuck task can't hang you forever.
        int result = future.get(5, TimeUnit.SECONDS);
        System.out.println("Sum of 1..100 = " + result);

        pool.shutdown();
        pool.awaitTermination(5, TimeUnit.SECONDS);
    }
}

3️⃣ Choosing the Right Pool

The Executors class gives you four pools for four jobs. Pick by the shape of your workload:

Factory methodThreadsBest forWatch out for
newFixedThreadPool(n)Exactly nSteady CPU-bound workUnbounded queue can OOM
newCachedThreadPool()Grows/shrinksMany short, bursty tasksNo ceiling — spikes make too many
newSingleThreadExecutor()Exactly 1Tasks that must run in orderNo parallelism at all
newScheduledThreadPool(n)Exactly nDelayed / repeating tasksA 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.

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

public class Main {
    public static void main(String[] args) throws InterruptedException {
        // 🎯 YOUR TURN — fill in the blanks marked with ___

        // 1) Create a pool that REUSES exactly 4 threads.
        ExecutorService pool = ___;   // 👉 Executors.newFixedThreadPool(4)

        // 2) Submit 4 print tasks (one per loop turn).
        for (int i = 1; i <= 4; i++) {
            int n = i;
            pool.submit(() -> System.out.println("Working on item " + n));
        }

        // 3) Stop accepting new tasks (queued ones still finish).
        pool.___;                     // 👉 shutdown()

        // 4) Wait up to 5 seconds for everything to complete.
        pool.awaitTermination(5, TimeUnit.SECONDS);
        System.out.println("Done");

        // ✅ Expected output (order of "Working on item" lines may vary):
        // Working on item 1
        // Working on item 2
        // Working on item 3
        // Working on item 4
        // Done
    }
}

🎯 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.

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;

public class Main {
    public static void main(String[] args) throws Exception {
        // 🎯 YOUR TURN — submit a Callable and read its result

        ExecutorService pool = Executors.newSingleThreadExecutor();

        // 1) Submit a Callable that returns the length of a word.
        //    submit() with a value-returning lambda gives a Future<Integer>.
        Future<Integer> future = pool.submit(() -> ___);   // 👉 "executor".length()

        // 2) Get the value out of the Future (blocks until ready).
        int length = future.___;        // 👉 get()

        System.out.println("Word length: " + length);

        pool.shutdown();
        pool.awaitTermination(5, TimeUnit.SECONDS);

        // ✅ Expected output:
        // Word length: 8
    }
}

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.

import java.util.concurrent.*;

public class Main {
    public static void main(String[] args) throws InterruptedException {
        // The factory methods are shortcuts. THIS is what they build —
        // and building it yourself lets you set a BOUNDED queue + a
        // rejection policy, the two settings that keep a server alive.
        ThreadPoolExecutor pool = new ThreadPoolExecutor(
                2,                                   // corePoolSize: threads kept alive
                4,                                   // maximumPoolSize: hard ceiling
                30L, TimeUnit.SECONDS,               // keepAlive: idle extra threads die after 30s
                new ArrayBlockingQueue<>(100),       // BOUNDED queue — refuses to grow forever
                new ThreadPoolExecutor.CallerRunsPolicy()); // overflow: caller runs it (backpressure)

        for (int i = 1; i <= 5; i++) {
            int n = i;
            pool.submit(() -> System.out.println("Job " + n + " on " + Thread.currentThread().getName()));
        }

        System.out.println("core=" + pool.getCorePoolSize() + " max=" + pool.getMaximumPoolSize());

        pool.shutdown();
        pool.awaitTermination(10, TimeUnit.SECONDS);
    }
}

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 Futures, and print each result. The expected output is in the comment so you can self-check.

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.*;

public class Main {
    public static void main(String[] args) throws Exception {
        // 🎯 MINI-CHALLENGE: square four numbers in parallel
        // 1. Create a fixed thread pool of 2 threads.
        // 2. For each number in {2, 3, 4, 5}, submit a Callable<Integer>
        //    that returns number * number. Keep every Future in a List.
        // 3. Loop the Futures and print each result with future.get().
        // 4. shutdown() the pool and awaitTermination().
        //
        // ✅ Expected output (order matches submission):
        // 4
        // 9
        // 16
        // 25

        // your code here
    }
}

Common Errors (and the fix)

📋 Quick Reference

GoalCodeNotes
Fixed poolExecutors.newFixedThreadPool(4)n reusable threads
Cached poolExecutors.newCachedThreadPool()Grows on demand, no ceiling
Single threadExecutors.newSingleThreadExecutor()Runs tasks in order
ScheduledExecutors.newScheduledThreadPool(2)Delayed / periodic
Fire-and-forgetpool.submit(() -> {...})Runnable, no result
Get a resultFuture<T> f = pool.submit(callable)Callable returns a value
Read resultf.get(5, TimeUnit.SECONDS)Blocks; use a timeout
Stop (graceful)pool.shutdown()No new tasks; finish queue
Wait to finishpool.awaitTermination(10, SECONDS)Blocks until done/timeout
Force stoppool.shutdownNow()Returns pending tasks

🎉 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.

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

Frequently asked questions

What is the difference between a thread pool and creating threads with new Thread()?

A raw 'new Thread()' is created, used once, then thrown away — and each one costs about 1MB of stack memory plus OS setup time. A thread pool creates a small set of threads once and reuses them for thousands of tasks. You submit work; the pool decides which idle thread runs it. Pools cap how many threads exist, queue extra work, and stay stable under load, where raw threads would exhaust memory.

When should I use newFixedThreadPool vs newCachedThreadPool vs newSingleThreadExecutor?

Use newFixedThreadPool(n) for steady, CPU-bound work where you want a known concurrency limit (a common choice is the number of CPU cores). Use newCachedThreadPool() for many short, bursty tasks — it spins up threads on demand and lets idle ones die, but it has no upper bound, so it can create too many threads under a spike. Use newSingleThreadExecutor() when tasks must run one at a time, in order. Use newScheduledThreadPool(n) for delayed or repeating tasks.

What is the difference between Runnable and Callable?

A Runnable's run() method returns nothing and cannot throw checked exceptions. A Callable's call() method RETURNS a value and may throw checked exceptions. Submit a Callable when you need the answer back: pool.submit(callable) gives you a Future, and future.get() returns the value (or rethrows the exception the task threw). Use a Runnable for fire-and-forget work where there is no result to collect.

Why does my program never exit after the tasks finish?

An ExecutorService's worker threads are non-daemon by default, so the JVM keeps running as long as the pool is alive — even after every task is done. You must call pool.shutdown() to tell it no more tasks are coming. Then call awaitTermination(...) to block until the queued tasks finish. Put shutdown() in a finally block (or use try-with-resources on Java 19+) so it runs even when a task throws.

What does future.get() do, and why might it hang?

future.get() blocks the calling thread until the task's result is ready, then returns it. If the task is slow or stuck, get() waits indefinitely — which can freeze your program. Always prefer the timeout overload, future.get(5, TimeUnit.SECONDS), which throws TimeoutException instead of hanging forever. If the task threw an exception, get() rethrows it wrapped in an ExecutionException, so exceptions are never silently lost as long as you call get().

Why should I bound the queue with ThreadPoolExecutor instead of using the factory methods?

Executors.newFixedThreadPool uses an UNBOUNDED LinkedBlockingQueue. If tasks arrive faster than they finish, that queue grows without limit until you get an OutOfMemoryError. Building a ThreadPoolExecutor yourself lets you pass a bounded queue (e.g. new ArrayBlockingQueue<>(100)) plus a rejection policy like CallerRunsPolicy, which makes the submitting thread run overflow work. That creates natural backpressure and keeps a busy server alive.

Related lessons