Layers of Logic

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.

Core21 min read4 exercises
01

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.

02

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.

03

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 ready

Future 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 cause

That 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:

FactoryWhat you get
newFixedThreadPool(n)n threads foreverand an unbounded queue
newCachedThreadPool()grows without limitreuses idle threads, kills them after 60s
newSingleThreadExecutor()one threadtasks run in order. Useful more often than it sounds
newScheduledThreadPool(n)delays and repeatsthe replacement for Timer
newVirtualThreadPerTaskExecutor()a virtual thread per taskJava 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 queue

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

04

Under the hood

Going deeper

Every 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 full

And the queue is where newFixedThreadPool goes wrong. Asking it directly:

newFixedThreadPool queue: LinkedBlockingQueue, remaining capacity 2147483647

Two 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 fullWhat happens
AbortPolicythe defaultthrows RejectedExecutionException. The caller finds out
CallerRunsPolicythe caller runs itslows the producer down, which is usually right
DiscardPolicysilently droppedonly ever right for things you can lose
DiscardOldestPolicydrops the oldest queued taskfor 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.

05

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.

06

Check yourself

Answer from memory before opening the explanation. Then compare your rule with the answer and revisit the example if they differ.

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

  2. `execute` and `submit` both run a task. What happens when the task throws?

    Show the answer

    execute lets it reach the thread's uncaught exception handler, so the stack trace is printed. Loud, and easy to notice.

    submit catches it and stores it in the Future. Nothing is printed and nothing is logged. Verified: a failing task submitted with submit produced no output at all until get() was called, which then threw ExecutionException wrapping the original.

    So a submitted task that fails and whose Future nobody looks at fails completely silently. That is the most common way an executor loses work.

  3. 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 LinkedBlockingQueue with 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 OutOfMemoryError rather 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 ThreadPoolExecutor directly lets you set a bounded queue and say what happens when it fills.

07

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

The Layers of Logic VS Code extension runs the checks for exercises marked checked. For a manual exercise, run the program and compare its behaviour with the stated requirements and sample output.
A

Fifty Tasks, Two Threads

Warm up·20 min·20 points

ex-14-10-a

Print 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
Reused.java
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
Use a concurrent set, such as ConcurrentHashMap.newKeySet(), or you will be measuring a race instead of the pool.
Hint 2
Expect exactly two names from the pool and five thousand from the thread per task version, along with a large difference in time.
Hint 3almost the answer
A pool's threads are not daemons, so a pool you never shut down keeps the JVM alive after main returns. It looks like a hang. From Java 19 you can use it in a try-with-resources instead.
What this is really testing

Whether a pool is an abstraction or something you have watched reuse threads. Fifty jobs on two threads is the whole idea, and you can print it.

B

The Exception in the Envelope

Real work·25 min·25 points

ex-14-10-b

Two 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
Envelope.java
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
Checking every Future works and does not scale, because you have to remember. Wrapping the task body in a try that logs is the version that keeps working when somebody adds a new call site.
What this is really testing

Whether you know where a failed task's exception goes. submit and execute treat it completely differently, and one of them loses it in silence.

C

Two Billion Waiting Tasks

Hard·30 min·30 points

ex-14-10-c

Ask 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
Unbounded.java
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
It is a LinkedBlockingQueue with remaining capacity 2,147,483,647. Cast the ExecutorService to ThreadPoolExecutor and call getQueue().
Hint 2
With a small heap the unbounded version dies with OutOfMemoryError, and the stack trace points at the queue rather than at anything you did wrong.
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.
What this is really testing

Whether the convenient factory methods are safe. newFixedThreadPool hands you an unbounded queue, which turns an overload into an OutOfMemoryError instead of a rejection.

D

Deadlock a Pool With One Task

Hard·25 min·30 points

ex-14-10-d

No 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
PoolLock.java
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
The outer task holds the only thread and waits for the inner task. The inner task is in the queue and needs a thread. Neither can move, and no lock is involved anywhere.
Hint 2
getActiveCount() and getQueue().size() from a ThreadPoolExecutor will show one thread busy and one task queued forever.
Hint 3almost the answer
Adding threads only moves the boundary: deeper nesting reaches it again. A separate pool for the inner work removes the cycle, and not blocking inside a pooled task removes it completely.
What this is really testing

Whether you can see the new deadlock a pool creates. A task that waits for another task in the same pool can use up every thread with waiters, and no lock is involved.

08

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 nothing

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