14.10
Stop Making Threads
A thread costs a megabyte, runs once, returns nothing and loses its failures. A pool fixes all four, and the factory method everyone uses to build one has a queue that will accept two billion tasks before it complains.
Previously on
Nine sections in, and this is still how you start work:
Thread t = new Thread(job);
t.start();It reserves about a megabyte of stack before doing anything, from Section 13.1. It cannot be restarted, from Section 14.2. Runnable.run() returns void and cannot throw a checked exception. And when it fails, the failure goes nowhere.
The problem
A server handling requests, one thread each.
while (true) {
Socket socket = serverSocket.accept();
new Thread(() -> handle(socket)).start();
}This works until it does not, and then it does not gracefully. At ten requests a second with each taking a second, you have ten threads. At a thousand, a thousand threads and most of a gigabyte of stacks, and the machine spends its time context switching rather than working.
There is no limit anywhere. Load goes up and the program fails, rather than getting slower.
Every thread is also thrown away. Each one is created, does one job, and dies. You pay the operating system for a thread per request and use it once.
And you cannot get anything back.
Thread t = new Thread(() -> {
int result = expensiveCalculation(); // now what?
});run() returns void. To get the answer out you write to a shared field, which is Section 14.4 all over again. And you have no way to know when it is ready other than join().
Nor can you report a failure. Runnable.run() declares no checked exceptions, and an uncaught one dies with the thread while the rest of the program carries on.
Four separate problems, one cause: a Thread is a thread. It is not a way of running work.
The idea
Separate the work from the thread that runs it.
ExecutorService pool = Executors.newFixedThreadPool(4);
pool.submit(() -> handle(socket));
pool.shutdown();You hand over tasks. The pool decides which thread runs each one, and reuses them.
Verified, fifty tasks into a pool of two:
50 tasks ran on 2 threads: [pool-1-thread-3, pool-1-thread-2]Two threads created, fifty jobs done.
Callable is Runnable that can answer.
public interface Runnable { void run(); }
public interface Callable<V> { V call() throws Exception; }Two differences and both matter. Callable returns a value, and it can throw a checked exception. Both are only possible because somebody is waiting for the result, which is what Future is.
Future<Integer> f = pool.submit(() -> expensiveCalculation());
Integer answer = f.get(); // waits until it is readyFuture is a receipt. get() blocks until the answer exists, get(timeout) gives up, cancel() asks to stop, isDone() asks without waiting.
And this is where a worker’s exception finally reaches you.
Future<?> f = pool.submit(() -> { throw new RuntimeException("boom"); });
f.get(); // throws ExecutionException, with the original as its causeThat is the answer to Section 14.2, where a thread’s exception had nowhere to go. Somebody is holding a receipt, so there is now a place to put it.
The pools you get from Executors:
| Factory | What you get | |
|---|---|---|
| newFixedThreadPool(n) | n threads forever | and an unbounded queue |
| newCachedThreadPool() | grows without limit | reuses idle threads, kills them after 60s |
| newSingleThreadExecutor() | one thread | tasks run in order. Useful more often than it sounds |
| newScheduledThreadPool(n) | delays and repeats | the replacement for Timer |
| newVirtualThreadPerTaskExecutor() | a virtual thread per task | Java 21. Section 14.11 |
Shutting one down has two levels:
pool.shutdown(); // no new tasks. Finish what is queued
pool.awaitTermination(30, TimeUnit.SECONDS); // wait for that
pool.shutdownNow(); // interrupt the running ones, drop the queueshutdownNow interrupts every worker, so it only works if your tasks respond to interruption, which is everything from Section 14.3. A task that swallows InterruptedException cannot be stopped by it.
Submitting after shutdown throws RejectedExecutionException, verified.
Under the hood
Going deeperEvery one of those factories builds a ThreadPoolExecutor. The real constructor is what they are hiding:
new ThreadPoolExecutor(
corePoolSize, // threads kept even when idle
maximumPoolSize, // the ceiling
keepAliveTime, unit, // how long an extra thread waits before dying
workQueue, // where tasks wait
threadFactory, // how threads are made and named
handler); // what to do when the queue is fullAnd the queue is where newFixedThreadPool goes wrong. Asking it directly:
newFixedThreadPool queue: LinkedBlockingQueue, remaining capacity 2147483647Two billion. Effectively unbounded.
Tasks arriving faster than the pool can finish them pile up, the queue grows, the heap fills, and you get an OutOfMemoryError from Phase XIII. Not a message saying the system is overloaded: a crash.
What you actually want is backpressure. A bounded queue plus a policy:
new ThreadPoolExecutor(4, 4, 0L, TimeUnit.MILLISECONDS,
new ArrayBlockingQueue<>(100),
new ThreadPoolExecutor.CallerRunsPolicy());| Policy when the queue is full | What happens | |
|---|---|---|
| AbortPolicy | the default | throws RejectedExecutionException. The caller finds out |
| CallerRunsPolicy | the caller runs it | slows the producer down, which is usually right |
| DiscardPolicy | silently dropped | only ever right for things you can lose |
| DiscardOldestPolicy | drops the oldest queued task | for feeds where new beats old |
CallerRunsPolicy is the one worth knowing. When the pool is full, the thread that submitted the task runs it itself, so it cannot submit anything else meanwhile. The producer is throttled by being made to do the work, with no signalling required.
Sizing follows from Section 14.1, where waiting and computing behaved differently.
Computing work: about the number of cores. More threads only add switching.
Waiting work: many more than the number of cores, because a waiting thread is not using one. The usual starting point is cores times one plus the ratio of waiting to working. A task that waits nine times longer than it computes wants roughly ten times the cores.
Both are starting points to measure from, not answers.
Name your threads. The default is pool-1-thread-3, which in a production thread dump tells you nothing. A ThreadFactory that names them after the pool’s job is a few lines and pays for itself the first time something goes wrong.
What it costs
The silent failure is the thing to watch for. submit and then never looking at the Future is normal-looking code that throws away every exception, and there is no warning from anywhere.
Sizing is a guess that has to be measured, and the wrong answer is bad in both directions. Too few threads and work queues up behind waiting. Too many and the machine spends its time switching.
A pool also holds its threads until told otherwise, so forgetting shutdown leaves a program that will not exit. It looks like a hang and it is a pool waiting politely for more work.
Deadlock takes a new form here. A task that submits another task to the same pool and then waits for its result can fill every thread with waiters. Nobody is left to run the work they are waiting for, and a pool of one is enough for that.
And the convenient factories are convenient in the wrong direction. newFixedThreadPool and newSingleThreadExecutor both take unbounded queues, and newCachedThreadPool has no thread limit at all. All three trade a clear failure under load for a crash.
Check yourself
Answer from memory before opening the explanation. Then compare your rule with the answer and revisit the example if they differ.
What does a thread pool actually save you?
Show the answer
Creation, and the memory that goes with it. A thread reserves a stack of around a megabyte before it does any work, from Section 13.1, and it cannot be restarted once it finishes.
A pool makes its threads once and feeds them tasks. Verified: fifty tasks submitted to a pool of two ran on exactly two threads.
It also gives you a bound. Without one, a thread per request means a thousand requests is a thousand threads and most of a gigabyte of stacks, and the machine falls over rather than getting slower.
`execute` and `submit` both run a task. What happens when the task throws?
Show the answer
executelets it reach the thread's uncaught exception handler, so the stack trace is printed. Loud, and easy to notice.submitcatches it and stores it in theFuture. Nothing is printed and nothing is logged. Verified: a failing task submitted withsubmitproduced no output at all untilget()was called, which then threwExecutionExceptionwrapping the original.So a submitted task that fails and whose
Futurenobody looks at fails completely silently. That is the most common way an executor loses work.Why is `Executors.newFixedThreadPool(10)` the wrong way to build a pool?
Show the answer
Because of the queue it gives you. Verified: it is a
LinkedBlockingQueuewith a remaining capacity of 2,147,483,647.Effectively unbounded. If tasks arrive faster than ten threads can finish them, the queue grows until the heap runs out, and you get an
OutOfMemoryErrorrather than any sign that the system is overloaded.Backpressure is the thing you actually want: when the queue is full, the caller should be told, so it can slow down, shed load or fail fast. An unbounded queue removes that signal and turns a capacity problem into a crash.
Building a
ThreadPoolExecutordirectly lets you set a bounded queue and say what happens when it fills.
Exercises
Write each solution in VS Code, predict its result, and then run the checks. Reading an example does not replace implementing it.
4 exercises105 pointsabout 100 minutes
Fifty Tasks, Two Threads
ex-14-10-aPrint the thread names and the abstraction becomes concrete.
Fifty tasks, two names. Five thousand tasks, still two names. That is the entire value of a pool stated in one line of output, and it explains the timing difference without any further argument.
The forgotten shutdown at the end is the mistake everybody makes once. The program finishes its work and then sits there, and nothing on screen says why.
What your program must do
- Submit fifty tasks to a pool of two and print the distinct thread names
- Do the same with a thread per task and compare the counts
- Time both approaches at fifty tasks and at five thousand
- Show what happens when you forget to shut the pool down
import java.util.*;
import java.util.concurrent.*;
public class Reused {
public static void main(String[] args) throws Exception {
// TODO: a pool of two. Submit fifty tasks that each record the name of
// the thread they ran on. Print the set of distinct names.
// TODO: do the same with new Thread() per task and count the names
// TODO: time both at 50 tasks and at 5000 tasks
// TODO: forget to shut the pool down. Does the program exit?
}
}
Hint 1
ConcurrentHashMap.newKeySet(), or you will be measuring a race instead of the pool.Hint 2
Hint 3almost the answer
The Exception in the Envelope
ex-14-10-bTwo methods, one failing task, and only one of them tells you.
submit is the one nearly everybody uses, and it is the quiet one. The exception is real, it is stored, and if nobody opens the envelope it might as well not have happened.
The twenty task version is what this looks like in production. Three of your jobs are not being done, throughput is slightly down, and there is nothing in the logs at all.
What your program must do
- Compare what execute and submit do with a failing task
- Call get on the failed Future and describe the exception you get
- Submit twenty tasks with three failures and show that nothing reports them
- Fix it two ways and say which scales better
import java.util.concurrent.*;
public class Envelope {
public static void main(String[] args) throws Exception {
// TODO: submit a failing task with execute(). What appears on screen?
// TODO: submit the same failing task with submit(). Now what appears?
// TODO: call get() on that Future. What comes out, and what is inside it?
// TODO: submit twenty tasks where three fail, ignoring all the Futures.
// How would you ever find out?
// TODO: fix it two ways: check every Future, and wrap the task body
}
}
Hint 1
execute lets the exception reach the thread's uncaught handler, so the trace prints. submit catches it and stores it in the Future, and prints nothing at all.Hint 2
get() throws ExecutionException. The real failure is e.getCause(), which is where your original exception and its trace are.Hint 3almost the answer
Two Billion Waiting Tasks
ex-14-10-cAsk the pool what it is hiding before you break it.
Two billion is not a limit, it is the absence of one. Any load your pool cannot keep up with becomes memory, and the failure arrives as an OutOfMemoryError from the heap rather than as a message saying you are overloaded.
The bounded version fails on purpose and much earlier, which is what you want. Then try CallerRunsPolicy and watch a producer slow itself down without any code being written to make it.
What your program must do
- Report the queue type and capacity of newFixedThreadPool
- Fill an unbounded pool on a small heap and record the failure
- Replace it with a bounded queue and an abort policy, and compare
- Try CallerRunsPolicy and describe the effect on the submitting thread
import java.util.concurrent.*;
public class Unbounded {
public static void main(String[] args) throws Exception {
// TODO: ask newFixedThreadPool(1) what queue it uses and what its
// remaining capacity is. Predict first.
// TODO: with -Xmx128m, submit slow tasks in a tight loop and watch it die
// TODO: build a ThreadPoolExecutor with an ArrayBlockingQueue(100)
// and AbortPolicy. Submit the same load. What happens instead?
// TODO: try CallerRunsPolicy and describe how the producer behaves
// TODO: say which failure you would rather have at 3am
}
}
Hint 1
Hint 2
Hint 3almost the answer
CallerRunsPolicy makes the submitting thread run the task itself when the queue is full, so it cannot submit anything else meanwhile. The producer throttles itself with no signalling required.Deadlock a Pool With One Task
ex-14-10-dNo locks, no shared fields, and the pool is stuck forever.
Do it with a pool of one first, because it takes two lines and is unmistakable. Then try a pool of two with two levels of nesting, and notice that the failure came back at a different depth.
That is why the third fix is the real one. More threads changes where the wall is, and not waiting inside a pooled task removes the wall entirely.
What your program must do
- Deadlock a pool of one with a nested submit and get
- Do the same to a pool of two with deeper nesting
- Inspect the pool while it is stuck and describe what you see
- Compare three fixes and say which one removes the problem
import java.util.concurrent.*;
public class PoolLock {
public static void main(String[] args) throws Exception {
// TODO: a pool of ONE. Submit a task that itself submits a second task
// to the same pool and calls get() on it. Predict what happens.
// TODO: do the same with a pool of two and two levels of nesting
// TODO: print the pool's active count and queue size while it is stuck
// TODO: fix it three ways: a separate pool, more threads, or no nested get
// TODO: say which fix actually removes the problem rather than moving it
}
}
Hint 1
Hint 2
getActiveCount() and getQueue().size() from a ThreadPoolExecutor will show one thread busy and one task queued forever.Hint 3almost the answer
After the credits
Future.get() blocks. That is the whole problem with it.
Future<User> user = pool.submit(() -> fetchUser(id));
Future<Orders> orders = pool.submit(() -> fetchOrders(id));
render(user.get(), orders.get()); // this thread now waits, doing nothingYou moved the work to a pool and then stopped a thread to wait for it. To do something when it finishes, without waiting, Future offers nothing at all.
Section 14.11 is the last section of the course. CompletableFuture lets you say what happens next instead of waiting: chain steps, run two things at once and combine them, handle failures in the chain. ForkJoinPool is the work-stealing pool that has been running every parallel stream you wrote in Phase XI. ThreadLocal gives each thread its own copy of a variable, which is a different answer to sharing entirely.
And virtual threads, from Java 21, remove the reason this section existed. A thread that costs a few hundred bytes instead of a megabyte means you can go back to one per task, and the pool becomes unnecessary.
Executor and Future will return in 14.11 - Futures That Do Not Block, and Threads That Are Nearly Free