Command Palette

Search for a command to run...

PHASE 13Advanced Java 5+ ~19 min· topic 6 of 11

Topic 13.6

Executors and Thread Pools

In one line

Instead of creating a thread per task, you hand tasks to an ExecutorService, which runs them on a reusable thread pool and gives back Futures for their results. Pools bound how many threads run at once, reuse threads, and separate *what* to do from *how* it's scheduled.

Think of it like this

A restaurant kitchen doesn't hire a new cook for every order and fire them afterwards. It has a fixed team of cooks and a ticket rail: orders go on the rail, and whichever cook is free takes the next ticket. The team is the thread pool, the rail is the task queue, and the slip you get back to collect your meal later is a Future.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Thread pool
A fixed or growing group of threads that are reused to run many tasks.
ExecutorService
The Java interface for submitting tasks to be run by a pool, and shutting it down.
Task
A piece of work to run: a Runnable (no result) or a Callable (returns a result).
Callable
Like Runnable, but its call method returns a value and may throw a checked exception.
Future
A handle to a result that will be ready later: you can wait for it, check it, or cancel the task.
Shutdown
Telling a pool to stop taking new tasks and let its threads end.
Rejection policy
What a pool does when its queue is full: throw, run the task in the caller, or drop it.

Step by step

01Submit tasks, get Futures

ExecutorService pool = Executors.newFixedThreadPool(3); creates three worker threads. Future<Integer> f = pool.submit(() -> compute()); queues the task and returns at once. f.get() blocks until the result is ready.

Submitting is cheap; the pool's threads pull tasks from the queue as they become free.

Submit tasks, get Futuresdiagram
Rendering diagram…

02Results in a fixed order

Keep the Futures in a list in the order you submitted them and call get() on each in turn. Tasks may finish in any order, but you read results in your order, so the output is deterministic.

invokeAll does this for you: it waits for all tasks and returns Futures in the same order as the input list.

03Exceptions travel inside the Future

If a task throws, the pool thread survives and the exception is stored. future.get() throws ExecutionException whose getCause() is the original exception. If nobody calls get(), the failure goes unnoticed: a common production mystery.

With execute(runnable) instead of submit, an exception goes to the thread's uncaught-exception handler and is printed, and the pool replaces the dead worker.

04Always shut down

A fixed pool's threads are non-daemon: if you forget shutdown(), the program never exits. The safe pattern is pool.shutdown(); pool.awaitTermination(...), or try-with-resources on Java 19+.

Main.javawhole filejava
// Java 19+: close() calls shutdown() and waits for tasks to finish
try (ExecutorService pool = Executors.newFixedThreadPool(4)) {
    pool.submit(task1);
    pool.submit(task2);
}   // all tasks done here

05Bounded queues and back-pressure

newFixedThreadPool uses an unbounded LinkedBlockingQueue: if tasks arrive faster than they're processed, the queue grows until the heap runs out. A ThreadPoolExecutor with an ArrayBlockingQueue of fixed size plus CallerRunsPolicy makes the submitting thread run the task itself when the queue is full, naturally slowing producers down.

Main.javawhole filejava
ExecutorService pool = new ThreadPoolExecutor(
        4, 4,                                   // core and max threads
        0L, TimeUnit.MILLISECONDS,
        new ArrayBlockingQueue<>(100),          // at most 100 waiting tasks
        new ThreadPoolExecutor.CallerRunsPolicy());

06Scheduled tasks

ScheduledExecutorService runs tasks after a delay (schedule) or repeatedly (scheduleAtFixedRate, scheduleWithFixedDelay). It replaces the old java.util.Timer, which used one thread and died entirely if a task threw.

Try it yourself

  1. 1

    Forget shutdown

    In the first example, delete pool.shutdown(); and the awaitTermination part of the last line. On your own JDK the program prints the results and then never exits, because the pool's threads are still alive. (In the browser it will hit the time limit.)

  2. 2

    Never call get

    In "invokeAll and a failing task", replace the loop with System.out.println(results.size() + " tasks finished");. The exception is never seen. That's how failures disappear in real systems.

Code & diagrams

Futures collected in submission order Java 5+ New tab
Sign in to run this example in your browser.

Expected output

part 0: 31375
part 1: 93875
part 2: 156375
part 3: 218875
total: 500500, pool finished: true
invokeAll and a failing task Java 9+ New tab

The failed task didn't kill the pool or the other tasks; its exception waited inside its Future until get() was called.

Sign in to run this example in your browser.

Expected output

ok: fetched users
failed: orders service down
ok: fetched prices
A scheduled task with a fixed delay Java 5+ New tab
Sign in to run this example in your browser.

Expected output

heartbeat 1
heartbeat 2
heartbeat 3
stopped after 3 heartbeats

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Submit after shutdown

Call pool.submit(() -> 1); after pool.shutdown();.

terminal
$ java Main.java
── what you'll see ──
Exception in thread "main" java.util.concurrent.RejectedExecutionException: Task java.util.concurrent.FutureTask@... rejected from java.util.concurrent.ThreadPoolExecutor@...[Shutting down, pool size = 3, active threads = 0, queued tasks = 0, completed tasks = 4]

Myth vs fact

Myth

More threads means faster.

Fact

For CPU-bound work, more threads than cores just adds switching overhead. Size pools to the work.

Myth

If a task throws, you'll see a stack trace.

Fact

With submit, the exception is stored in the Future and only appears when you call get().

Myth

Executors.newFixedThreadPool is production-ready as is.

Fact

Its queue is unbounded, so overload turns into an OutOfMemoryError. Use a bounded ThreadPoolExecutor for untrusted load.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    ThreadPoolExecutor starts core threads first, then queues tasks, and only creates threads beyond the core size when the queue is full. With an unbounded queue, maximumPoolSize is never used, a surprise for many readers of the configuration.

  • ▸

    ForkJoinPool (used by parallel streams and CompletableFuture by default) uses work-stealing deques: idle workers steal tasks from the tails of busy workers' queues, which suits many small recursive tasks.

  • ▸

    Give pool threads names with a ThreadFactory (Thread.ofPlatform().name("orders-", 0).factory() in Java 21) so thread dumps and logs show which pool is busy (Topic 14.5).

  • ▸

    Context such as ThreadLocal values doesn't follow a task into a pool thread, and leftover ThreadLocals in pooled threads leak between tasks; scoped values (Topic 13.11) are the modern answer.

Remember this

  1. 1

    Creating a platform thread is expensive (an OS thread with its own stack, often around 1 MB of reserved memory, Topic 13.1), and thousands of them overwhelm the machine. An **ExecutorService** keeps a pool of threads and a queue of tasks: you submit work, and pool threads run it.

  2. 2

    Executors has factory methods: newFixedThreadPool(n) (n threads, unbounded queue), newSingleThreadExecutor() (tasks run one at a time, in order), newCachedThreadPool() (grows as needed, reuses idle threads), newScheduledThreadPool(n) (delayed and periodic tasks), and newVirtualThreadPerTaskExecutor() (Java 21, Topic 13.10). For production, new ThreadPoolExecutor(...) lets you set a bounded queue and a rejection policy.

  3. 3

    **Runnable returns nothing; Callable<V>** returns a value and may throw a checked exception. submit returns a **Future<V>**: get() waits for the result (and rethrows a task's exception wrapped in ExecutionException), get(timeout) waits at most that long, cancel(true) interrupts the task, isDone() checks without waiting.

  4. 4

    invokeAll(tasks) runs a collection of Callables and returns their Futures in the same order; invokeAny(tasks) returns the first successful result. Collecting results through Futures in submission order gives deterministic output even though tasks finish in any order.

  5. 5

    A pool must be shut down, or its non-daemon threads keep the JVM alive after main ends. shutdown() stops accepting tasks and lets queued ones finish; awaitTermination waits; shutdownNow() interrupts running tasks. Since Java 19, ExecutorService is AutoCloseable, so try (var pool = ...) { ... } shuts it down and waits automatically.

  6. 6

    Size pools for the work: for CPU-bound tasks about the number of cores (Runtime.getRuntime().availableProcessors()); for tasks that mostly wait on I/O, more threads (or virtual threads). Watch out for unbounded queues (memory grows without limit under load) and for swallowed exceptions: an exception in a submitted task is stored in its Future, invisible until someone calls get().

Explain it without notes

01

Why use a thread pool instead of creating a thread per task?

02

What happens to an exception thrown inside a task submitted with submit()?

03

How do you shut down an executor correctly?

Practice

01

Use a pool of 4 threads to compute the squares of 1..8 as Callables and print them in order.

02

Use invokeAny to return whichever of two tasks finishes first.

Trade-offs

  • ↔

    Fixed pools bound resource use but can queue up delays; cached pools adapt but can create too many threads under load.

  • ↔

    Bounded queues protect memory and push back on callers, but you must decide what happens when they're full.

Done when you can

  • Done when you can submit Runnables and Callables and read results through Futures.

  • Done when you can collect results deterministically with ordered Futures or invokeAll.

  • Done when you know where task exceptions go and always shut pools down.

  • Done when you can explain pool sizing and the danger of unbounded queues.