Layers of Logic

14.11

Futures That Do Not Block, and Threads That Are Nearly Free

The machine gave up at 2,027 platform threads. It ran a million virtual ones in three seconds. The last section of the course is also the one that quietly removes the reason for the previous one.

Core22 min read4 exercises
01

Previously on

Section 14.10 fixed four problems with threads and left one line doing damage:

render(user.get(), orders.get());

You moved the work onto a pool, and then stopped a thread to wait for it. Future has no way to say when this finishes, do that.

02

The problem

A page needing a user, their orders, and a recommendation based on the user.

Future<User> userF = pool.submit(() -> fetchUser(id));
Future<Orders> ordersF = pool.submit(() -> fetchOrders(id));

User user = userF.get();                    // this thread stops here
Orders orders = ordersF.get();              // and here
Future<Recs> recsF = pool.submit(() -> fetchRecs(user));
Recs recs = recsF.get();                    // and here

Three calls that could overlap, and a thread parked at every step. The recommendation genuinely needs the user first, and the orders did not.

Future has no way to attach anything. It offers get, isDone and cancel. To react to a result you either block, or poll isDone in a loop, which is worse.

Then the second problem, older and quieter. How does a request id reach code five layers down?

void handle(Request r) {
    service.process(r.getData());        // needs the request id for logging
}

Threading it through every signature means changing methods that have no interest in it. A static field is shared by every thread at once, so under concurrent requests it holds whichever id arrived last.

And the third, which has been running under your code since Phase XI. Every parallelStream you wrote used a pool you never created. Its threads take work from each other when they run out, and none of Section 14.10 explained how.

03

The idea

CompletableFuture is a Future you attach to instead of waiting on.

CompletableFuture.supplyAsync(() -> fetchUser(id))
    .thenApply(User::name)
    .thenAccept(System.out::println);

Nothing blocks. Each step runs when the one before it finishes.

MethodWhat it takes and does
thenApplya Functiontransform the result when it arrives
thenAccepta Consumerdo something with it, return nothing
thenComposea Function returning another futurechain a second async call. This is flatMap
thenCombineanother future and a BiFunctionwait for both, then join them
exceptionallya Function on the exceptionsupply a fallback if it failed
allOf / anyOfmany futureswhen all finish, or when the first does

Those are the interfaces from Section 11.2. thenApply is map, thenCompose is flatMap, and a chain of futures reads like a stream because it is built from the same four shapes.

The page, rewritten:

CompletableFuture<User> user = CompletableFuture.supplyAsync(() -> fetchUser(id));
CompletableFuture<Orders> orders = CompletableFuture.supplyAsync(() -> fetchOrders(id));

user.thenCompose(u -> CompletableFuture.supplyAsync(() -> fetchRecs(u)))
    .thenCombine(orders, (recs, ord) -> render(recs, ord))
    .exceptionally(e -> renderError(e))
    .thenAccept(this::send);

The user and orders calls start together. Recommendations start the moment the user arrives, not after the orders. No thread is parked at any point, and the failure path is part of the chain instead of a try around it.

ThreadLocal gives each thread its own copy.

private static final ThreadLocal<String> requestId = new ThreadLocal<>();

requestId.set(id);          // at the start of the request
...
requestId.get();            // anywhere, five layers down, on the same thread

One variable, one value per thread, no sharing and no locking. It is how logging frameworks carry a request id and how transactions are tracked. It works because a thread’s own storage is not shared, which is the split from Section 14.1.

The trap is a pool. Threads are reused, so a value left behind is still there for the next task, which is somebody else’s request. remove() in a finally is not optional.

ForkJoinPool is the one that has been running your parallel streams.

ForkJoin common parallelism: 7

Seven on an eight core machine, because the calling thread joins in. It is built for work that splits into smaller work, and its trick is work stealing: every thread has its own queue, and a thread that runs out takes from the back of somebody else’s.

That is why a parallelStream over an uneven workload still finishes reasonably. Threads that get easy pieces go and help.

And now the thing that changes the shape of everything above.

04

Under the hood

Going deeper

How many threads can this machine start? Each one sleeping, doing nothing:

[warning][os,thread] Failed to start thread - pthread_create failed (EAGAIN)
                     for attributes: stacksize: 2048k
  failed at 2027 with OutOfMemoryError

Two thousand and twenty seven. The failure is pthread_create refusing, and the message names the cause: two megabytes of stack each, reserved before any of them did anything.

Now the same machine with virtual threads:

1,000,000 virtual threads each sleeping 100ms: 2953 ms

A million, in under three seconds.

On this machine
Platform threadsan operating system thread eachfailed at 2,027
Virtual threadsa Java object on the heap1,000,000 in 2,953 ms

A virtual thread is not an operating system thread. It is an object on the heap with a small stack that grows as needed. It runs on a platform thread borrowed from a ForkJoinPool, called a carrier.

What happens when a virtual thread blocks

  1. It calls something blockingThread.sleep, a socket read, a lock. On a platform thread this parks the operating system thread.
  2. Its stack is copied to the heapThe frames are moved off the carrier and stored. This is called unmounting.
  3. The carrier runs somebody elseThat platform thread is now free, and it picks up another virtual thread. Nothing is idle.
  4. The blocking call finishesThe stack is copied back onto a carrier, possibly a different one, and it continues on the line after the call.

So a million virtual threads waiting on network calls occupy no operating system threads at all. Waiting stops costing anything.

Which removes the reason for pooling. A pool exists to reuse something expensive to make. A virtual thread costs a few hundred bytes and no system call.

try (var ex = Executors.newVirtualThreadPerTaskExecutor()) {
    for (Request r : requests) ex.submit(() -> handle(r));
}

That is not a pool. It makes a new virtual thread per task and discards it. The blocking, one thread per request style from before Section 14.10 is correct again, and it now scales to a million.

And ThreadLocal becomes a problem again. Values are per thread, and with a virtual thread per task there are now a million threads and a million copies. Java 21 added ScopedValue for this: an immutable value bound for the duration of a call, with no remove to forget.

05

What it costs

CompletableFuture is a large surface. Around fifty methods, most in three variants, and the Async suffix decides which pool a step runs on in a way that is easy to get wrong. Debugging a chain is worse than debugging a loop, because the stack trace shows the machinery rather than your path through it.

Its failure handling is also easy to leave incomplete. A chain with no exceptionally swallows the failure into the future, and unless somebody joins it, nothing is reported. This is the submit problem from Section 14.10 with more places to hide.

ThreadLocal on a pool leaks in two directions. A value not removed is visible to the next task on that thread, which is a data leak between requests. It also keeps the object alive for the life of the pool, which is the memory leak from Section 13.2.

Virtual threads look free and are not. Every one is heap, so a million of them is real memory, and a million doing computation is a million contending for eight cores. synchronized pins the carrier, and a library full of it can starve the pool in a way that is hard to see.

And the pinning problem is the hardest part to plan for. Whether a blocking call pins depends on what is underneath it, which for a third party library means reading its source or measuring. Java 21 has a flag that reports pinning, and that is the honest way to find out.

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 can `CompletableFuture` do that `Future` cannot?

    Show the answer

    Say what happens next, instead of waiting for the answer.

    Future.get() blocks. The only way to use the result is to stop a thread until it exists, which undoes much of the point of having moved the work elsewhere.

    CompletableFuture takes a callback. thenApply transforms the result when it arrives, thenCompose chains another asynchronous call, thenCombine waits for two and joins them, and exceptionally handles a failure. Nothing blocks anywhere.

    The methods are the ones from Phase XI. thenApply takes a Function and thenAccept takes a Consumer, so a chain of futures reads like a stream.

  2. Why can a machine run a million virtual threads and only two thousand platform ones?

    Show the answer

    Because of the stack. A platform thread is an operating system thread and reserves around a megabyte before doing anything, from Section 13.1. Verified: this machine failed at 2,027 with an OutOfMemoryError, because pthread_create refused.

    A virtual thread is a Java object on the heap with a small stack that grows as needed. It is not an operating system thread at all. When it blocks, its stack is put aside and the platform thread underneath goes and runs another one.

    Verified: one million virtual threads, each sleeping 100 ms, finished in 2,953 ms on the same machine.

    So the thing that made threads expensive was never the thread. It was the stack the operating system insisted on reserving.

  3. Virtual threads make thread pools unnecessary. Why?

    Show the answer

    Because a pool exists to reuse something expensive, and a virtual thread is not expensive.

    Pooling was the answer to two facts: a thread costs a megabyte, and a thread that has finished cannot be started again. Neither is true of a virtual thread, so making one per task is the right design again.

    Executors.newVirtualThreadPerTaskExecutor() is not a pool. It makes a new virtual thread for every task and throws it away afterwards.

    Pools still matter for work that computes rather than waits, because there you are limiting how many things run at once, and that limit is your cores. Virtual threads do not add cores.

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 exercises115 pointsabout 110 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

Two Thousand, Then a Million

Real work·25 min·25 points

ex-14-11-a

Find your machine’s ceiling, then step straight over it.

The failure message is worth reading properly. It names pthread_create and it names the stack size, which together tell you the whole story before you have thought about it.

Then change -Xss and watch the ceiling move. That single experiment is what makes virtual threads make sense: the thing that was expensive was never the thread.

What your program must do

  • Find the platform thread ceiling on your machine and record it
  • Read the failure message and say what it names
  • Run a million virtual threads and time it
  • Change the stack size and show the ceiling move
Ceiling.java
import java.util.concurrent.*;

public class Ceiling {
    public static void main(String[] args) throws Exception {
        // TODO: start platform threads in a loop, each sleeping, keeping them alive.
        //       Find the point where it fails. Catch the error and print the count.
        // TODO: read the failure message. What does it name as the cause?
        // TODO: now start a MILLION virtual threads, each sleeping 100 ms. Time it.
        // TODO: try -Xss256k on the platform version. Does the ceiling move?
        // TODO: say what the ceiling was actually made of
    }
}
Hint 1
One machine failed at 2,027 with an OutOfMemoryError, and the warning above it said pthread_create failed with a stacksize of 2048k.
Hint 2
A million virtual threads each sleeping 100 ms finished in about 2,953 ms on the same machine.
Hint 3almost the answer
Changing -Xss moves the platform ceiling, which tells you exactly what it was made of. The limit was never the thread, it was the stack the operating system insisted on reserving.
What this is really testing

Whether the cost of a thread is a number you have hit. The machine has a hard ceiling on platform threads and effectively none on virtual ones, and the difference is one design decision.

B

Stop Waiting for Futures

Real work·30 min·30 points

ex-14-11-b

Build the blocking version first and mark every place a thread stops.

Then rebuild it so nothing stops. The timing improves, and the more interesting change is which work can start when: recommendations only ever needed the user, and the blocking version made them wait for the orders too.

Adding exceptionally at the end shows the last difference. Failure becomes part of the chain rather than something wrapped around it, which is the same move Optional made in Phase XI.

What your program must do

  • Build it with Future and get, and time the result
  • Rebuild it with CompletableFuture so nothing blocks, and time that
  • Add failure handling into the chain and test it
  • Name which of the methods correspond to map and flatMap
Pipeline.java
import java.util.concurrent.*;

public class Pipeline {
    static String fetchUser(int id)      { sleep(200); return "user" + id; }
    static String fetchOrders(int id)    { sleep(300); return "orders" + id; }
    static String fetchRecs(String user) { sleep(200); return "recs for " + user; }
    static void sleep(int ms) { try { Thread.sleep(ms); } catch (Exception e) { } }

    public static void main(String[] args) throws Exception {
        // TODO: do it with Future and get(). Time it. Note where threads park.
        // TODO: do it with CompletableFuture: user and orders in parallel,
        //       recs chained off user, then combine. Time it.
        // TODO: add exceptionally and make fetchOrders throw. What happens?
        // TODO: which methods here are map, and which is flatMap?
    }
}
Hint 1
The blocking version parks a thread at every get(). Recommendations cannot start until orders have finished, even though they only need the user.
Hint 2
thenCompose chains an asynchronous call, so recs start the moment the user arrives. thenCombine waits for two futures and joins them.
Hint 3almost the answer
thenApply is map and thenCompose is flatMap, exactly as in Phase XI. A chain of futures is a stream over values that have not arrived yet.
What this is really testing

Whether you can build a pipeline that never blocks. Future.get is where concurrency goes to be undone, and thenCompose is the shape that replaces it.

C

The Value Left Behind

Hard·25 min·30 points

ex-14-11-c

Print the value before you set it, and watch somebody else’s request appear.

This is worse than a wrong number. It is one request reading another request’s data, on a mechanism specifically designed to keep them apart, and it happens because the thread outlived the task.

The memory half is the same bug wearing different clothes. The value is reachable from a thread that lives as long as the pool, which is the leak from Phase XIII with a new cause.

What your program must do

  • Show a pooled task reading the previous request's ThreadLocal value
  • Fix it with remove in a finally
  • Explain what the unfixed version does to memory as well as to correctness
  • Say why virtual threads change the picture for ThreadLocal
LeftBehind.java
import java.util.concurrent.*;

public class LeftBehind {
    static final ThreadLocal<String> requestId = new ThreadLocal<>();

    public static void main(String[] args) throws Exception {
        // TODO: a pool of two. Submit ten "requests", each setting requestId
        //       at the start and NOT removing it.
        // TODO: have each task print the requestId it saw BEFORE setting its own
        // TODO: show a task seeing somebody else's id
        // TODO: fix it with remove() in a finally, and confirm
        // TODO: say what the same bug does to memory, and why Section 13.2 applies
    }
}
Hint 1
Print the value at the start of the task, before setting it. On a reused thread you will see the previous task's id.
Hint 2
It is also a memory leak. The value is reachable from the thread, and a pool's threads live for the life of the pool, which is exactly the shape from Section 13.2.
Hint 3almost the answer
With a virtual thread per task there are a million threads and a million copies, so ThreadLocal stops being cheap. Java 21 added ScopedValue, which is bound for the duration of a call and has no remove to forget.
What this is really testing

Whether ThreadLocal is safe on a pool. Threads are reused, so a value you forget to remove belongs to the next request, and that is a data leak rather than a slow program.

D

Pin the Carrier

Hard·30 min·30 points

ex-14-11-d

Two versions of the same waiting code, one keyword apart.

The synchronized version pins its carrier, so the pool underneath runs out of real threads and the batch slows to a crawl. The lock version unmounts cleanly and finishes. Nothing in either version looks wrong.

Then turn the tracing flag on, because that is how you would find this in a library you did not write. The computation test at the end is the other half of the honesty: virtual threads made waiting free and did nothing at all about arithmetic.

What your program must do

  • Compare a batch of virtual threads blocking inside synchronized against a ReentrantLock
  • Turn on pinned thread tracing and read what it reports
  • Run computation rather than waiting on many virtual threads and compare with a small pool
  • Say what virtual threads make free and what they do not
Pinned.java
import java.util.concurrent.*;
import java.util.concurrent.locks.*;

public class Pinned {
    static final Object monitor = new Object();
    static final ReentrantLock lock = new ReentrantLock();

    public static void main(String[] args) throws Exception {
        // TODO: many virtual threads that each take `monitor` with synchronized
        //       and then sleep. Time the whole batch.
        // TODO: the same batch using the ReentrantLock instead. Time it.
        // TODO: run with -Djdk.tracePinnedThreads=full and read the output
        // TODO: now run a CPU-heavy task on a million virtual threads.
        //       Is it faster than eight platform threads? Why not?
    }
}
Hint 1
A virtual thread inside synchronized cannot unmount in Java 21. It holds its carrier, and with enough of them the carrier pool starves.
Hint 2
-Djdk.tracePinnedThreads=full prints a stack every time a virtual thread pins, which is how you find this in code you did not write.
Hint 3almost the answer
Virtual threads do not add cores. A million of them computing still share eight processors, plus a million stacks on the heap, so a bounded pool is still right for work that keeps a processor busy.
What this is really testing

Whether virtual threads are free. They make waiting free, not computing, and one keyword can hold a carrier thread hostage in a way that starves the whole pool.

08

After the credits

That is the last section. Fifty-seven of them, from what a JVM is to a million threads.

The thing worth noticing is how little of this phase was new. Runnable was a functional interface from Phase VIII. thenApply and thenCompose were map and flatMap from Phase XI. Every lock was built on one instruction, and every one of them fixed a problem you first met as 40787 in a parallel stream. The stack and the heap from Phase XIII explained why a thread is expensive, and virtual threads are the answer to that one fact.

Nothing in this course was taught and dropped. hashCode from Phase VIII decided whether distinct worked in Phase XI. Two’s complement from Phase II is why a - b in a comparator is wrong. The Iterator from Phase X is what a stream pulls from, and what a Spliterator splits.

Two things are worth doing now, and neither is another course.

Go back to Section 4.2 and read it again. Arrays, indexed access, and implementation-specific layout. It reads differently once you know what the heap is, why ArrayList often beats LinkedList, and what a cache line can do to eight threads sharing a counter.

Then measure something you believe. Nearly every number in this course started as folklore that turned out to be wrong: ArrayList losing at appends, EnumMap being faster, ReadWriteLock helping, atomics beating locks. Each one took about twenty lines and a JVM. That habit is worth more than any of the answers it produced.