Imagine a single web request that has to call three downstream services — a user service, an orders service, and a recommendations service — and combine their answers. Run them one after another and your user waits for the sum of all three. Run them at the same time and the request is only as slow as the slowest call. The catch is that “run them at the same time” has, for twenty years, been where Java code quietly turned fragile: threads leak, a failure in one call doesn’t stop the others, and a thread dump tells you nothing about which task started which.
Structured concurrency is the feature that fixes this. It treats a group of related tasks running in different threads as a single unit of work — they start together, they finish together, and if one fails the rest are cancelled automatically. This post starts from zero (if you’ve only ever written single-threaded Java, you’re fine) and ends at reference-grade detail: custom joiners, timeouts, observability, and the API exactly as JDK 28 finalises it. Every code block links to the file it came from, and every output block is a real run.
Status (26 October 2026): JEP 543 finalises Structured Concurrency in JDK 28. Its status on openjdk.org is Integrated, and the JDK 28 page lists general availability as proposed for 9 March 2027, so there is no GA JDK with the final API yet. JDK 27 (GA 15 September 2026) shipped it as the seventh preview, JEP 533, and JEP 543 proposes to finalise that API “without further change”. The first version of this post expected finalisation around JDK 27; that did not happen.
Tested on: Temurin28-beta+21-ea(early access, build of 2026-10-22; no flags needed), Temurin 27.0.1.0.2+1 (with--enable-preview), and Temurin 26.0.2.1+1 and 25.0.5+8 for the older API.javapprints identical signatures on 27 and on 28-ea+21 (09-api-diff.txt), so everything you learn on 27 carries over. The JDK 28 binary is an early-access build; the claims about the final release rest on JEP 543, and those about the API on the binary I ran.
The problem: “unstructured” concurrency
Here is the classic way to fan out two calls with an ExecutorService, the API we’ve had since Java 5 (Snippets.java):
Response handle() throws ExecutionException, InterruptedException {
Future<String> user = executor.submit(() -> findUser());
Future<Integer> order = executor.submit(() -> fetchOrder());
String theUser = user.get(); // join findUser
int theOrder = order.get(); // join fetchOrder
return new Response(theUser, theOrder);
}
It looks innocent, but think through what happens when things go wrong:
- If
findUser()throws,user.get()throws — butfetchOrder()keeps running in its own thread. That’s a thread leak: at best it wastes resources, at worst it interferes with later work. - If the thread running
handle()is interrupted, that interruption is not propagated to the two subtasks. Both keep running. - If
findUser()is slow andfetchOrder()has already failed, you still block onuser.get()and learn about the failure late.
The first bullet is easy to doubt until you watch it. Unstructured.java submits the same two subtasks, makes findUser() throw after 50 ms and lets fetchOrder() take 300 ms. Output from 02-unstructured-vs-structured.txt:
$ javac src/Unstructured.java && java Unstructured (28-beta+21-ea)
handle() failed: java.lang.IllegalStateException: user service down
handle() has returned. Waiting 400 ms to see what the other thread does:
fetchOrder() finished, although nobody wants the answer any more
The last line is the leak. handle() had already failed and returned; 250 ms later a thread that nobody is waiting for finished its work anyway. The diagram puts the two runs on one time axis.
Top lane: the executor has no idea that the two futures belong together, so the failure at 50 ms changes nothing for fetchOrder(). Bottom lane: the scope treats the failure as the end of the whole group, interrupts the sibling, and does not let handle() leave until the sibling is gone. The rest of the post is how that second lane works.
The root cause is that ExecutorService and Future allow unrestricted concurrency. One thread can submit work, a different thread can await it, and nothing records that findUser and fetchOrder are children of the same handle task. The task-subtask relationship lives only in your head — so the runtime can’t help you with cancellation, error propagation, or observability.
Going deeper: why not just cancel the futures by hand?
You can: wrap the work in try/finally and call cancel(true) on the other futures in the catch block. JEP 543 notes that you would also need the executor in a try-with-resources, because Future gives you no way to wait for a task that has been cancelled, and that “all this keeping track of the inter-task relationships can be tricky to get right, and it often makes the logical intent of the code harder to discern”. The scope does it for you, and the compiler and runtime check that you did.
The JEP also considered making ExecutorService itself enforce structure and rejected it: most uses of ExecutorService in the JDK and the ecosystem are not structured, so passing a structured one to existing code would “be all but certain to throw exceptions”. That is why StructuredTaskScope deliberately does not implement ExecutorService.
The idea: make task structure mirror code structure
Now look at the single-threaded version of the same method (also in Snippets.java):
Response handle() throws IOException, InterruptedException {
String theUser = findUser();
int theOrder = fetchOrder();
return new Response(theUser, theOrder);
}
Here the structure is obvious and free: findUser() and fetchOrder() are subordinate to handle(), they can only return to handle(), and if findUser() throws, fetchOrder() never even starts. The call stack is the task hierarchy.
Structured concurrency brings that same guarantee to multiple threads. Its founding principle is one sentence:
If a task splits into concurrent subtasks, they all return to the same place — the task’s own code block.
Because the subtasks’ lifetimes are nested inside the enclosing block, the runtime can reason about them as a unit: apply a deadline to the whole group, cancel siblings when one fails, and show the hierarchy in a thread dump. This is the same leap structured programming made when it replaced goto with blocks, loops, and functions — now applied to threads.
One prerequisite: virtual threads
Structured concurrency is built on virtual threads (finalized in JDK 21, JEP 444; if they are new to you, Java 21 Features with Examples introduces them). A virtual thread is a lightweight thread managed by the JVM rather than the OS, so you can have millions of them and it’s perfectly fine to dedicate one to every single subtask — even ones that just sit and wait on I/O. By default, every subtask you fork runs in its own fresh virtual thread. You don’t manage a pool, you don’t size anything; the scope owns the threads and guarantees they’re gone when the block exits. Keep this in mind: structured concurrency is “cheap threads” plus “strict lifetimes.”
If you want numbers on what virtual threads do and do not buy you, Virtual Threads vs WebFlux vs Platform Threads on Spring Boot 4.1 measures it.
Your first StructuredTaskScope
The principal class is java.util.concurrent.StructuredTaskScope. Here is the handle() example rewritten — this is the canonical structured-concurrency program (Server.java):
import java.util.concurrent.ExecutionException;
import java.util.concurrent.StructuredTaskScope;
import java.util.concurrent.StructuredTaskScope.Subtask;
record Response(String user, int order) {}
/** The canonical program: open, fork, join, process, close. */
public class Server {
String findUser() throws InterruptedException {
Thread.sleep(120); // pretend: a network call
return "Alice";
}
int fetchOrder() throws InterruptedException {
Thread.sleep(80); // pretend: another network call
return 42;
}
// join() now declares ExecutionException (the scope's third type parameter, R_X) as well as InterruptedException
Response handle() throws ExecutionException, InterruptedException {
try (var scope = StructuredTaskScope.open()) { // 1. open
Subtask<String> user = scope.fork(this::findUser); // 2. fork
Subtask<Integer> order = scope.fork(this::fetchOrder); // fork
scope.join(); // 3. wait for BOTH, propagate any failure
return new Response(user.get(), order.get()); // 4. process results
} // 5. close: cancel + wait (automatic)
}
public static void main(String[] args) throws Exception {
System.out.println(new Server().handle());
System.out.println("Runtime: " + Runtime.version());
}
}
On a JDK 28 build there is nothing to enable. This is the output of javac Server.java and java Server from 01-server.txt:
$ javac src/Server.java && java Server (28-beta+21-ea)
Response[user=Alice, order=42]
Runtime: 28-beta+21-ea
On JDK 27 the same source needs the preview flags, and compiling without them fails. The second block is the same program with --enable-preview; the third is what the compiler says without it (the first of six errors):
$ javac --enable-preview --release 27 src/Server.java && java --enable-preview Server (27.0.1.0.2+1)
Response[user=Alice, order=42]
Runtime: 27.0.1.0.2+1
$ javac src/Server.java (27.0.1.0.2+1)
src/Server.java:2: error: StructuredTaskScope is a preview API and is disabled by default.
import java.util.concurrent.StructuredTaskScope;
^
(use --enable-preview to enable preview APIs)
One change to notice in the source.handle()now declaresthrows ExecutionException, InterruptedException. In the JDK 25 and 26 previewsjoin()threw onlyInterruptedExceptionand a failed scope surfaced as aStructuredTaskScope.FailedException. The final API adds a third type parameter,R_X, for the exceptionjoin()throws, and for the default scope it is the familiarExecutionException. The section on upgrading preview code has the compiler output for each old construct.
Read that try block as a contract. Under all conditions the two threads’ lifetimes are confined to the body of the try-with-resources statement. From that single guarantee you get four valuable properties — and each one is exactly the pain point from the unstructured version, now solved:
- Error handling with short-circuiting — if
findUser()fails,fetchOrder()is cancelled (interrupted) if it hasn’t finished, and vice versa. - Cancellation propagation — if the thread running
handle()is interrupted, both subtasks are cancelled automatically as the scope closes. - Clarity — set up subtasks, wait, then decide success or failure. No manual
try/finally, no jugglingFuture.cancel(...). - Observability — a thread dump shows
findUserandfetchOrderas children of the scope (more on this below).
The first property is the one the timeline above promised. Here is the second lane for real — FailFast.java has the same two subtasks, the same 50 ms failure and the same 300 ms sibling:
$ javac src/FailFast.java && java FailFast (28-beta+21-ea)
fetchOrder() was interrupted
handle() failed: java.lang.IllegalStateException: user service down
handle() has returned. Waiting 400 ms to see what the other thread does:
(nothing more happened)
fetchOrder() was interrupted before handle() reported its failure, and the 400 ms wait afterwards shows nothing left running. Compare the last line of the unstructured transcript.
Going deeper: what the three type parameters mean
StructuredTaskScope<T, R, R_X extends Throwable>: T is the result type of the subtasks you fork, R is what join() returns, and R_X is the exception type join() can throw. The zero-argument open() gives you StructuredTaskScope<T, Void, ExecutionException>: it returns nothing and throws ExecutionException if any subtask fails. This is the signature block from 08-api-28.txt, a javap of the JDK 28 early-access build:
public interface StructuredTaskScope<T, R, R_X extends Throwable> extends AutoCloseable {
public static <T, R, R_X extends Throwable> StructuredTaskScope<T, R, R_X> open(StructuredTaskScope$Joiner<? super T, ? extends R, R_X>, UnaryOperator<StructuredTaskScope$Configuration>);
public static <T, R, R_X extends Throwable> StructuredTaskScope<T, R, R_X> open(StructuredTaskScope$Joiner<? super T, ? extends R, R_X>);
public static <T> StructuredTaskScope<T, Void, ExecutionException> open(UnaryOperator<StructuredTaskScope$Configuration>);
public static <T> StructuredTaskScope<T, Void, ExecutionException> open();
public abstract <U extends T> StructuredTaskScope$Subtask<U> fork(Callable<? extends U>);
public abstract <U extends T> StructuredTaskScope$Subtask<U> fork(Runnable);
public abstract R join() throws R_X, InterruptedException;
public abstract boolean isCancelled();
public abstract void close();
}The point of the third parameter is that a joiner can now decide what kind of exception its policy produces without every caller catching Throwable. The preview versions in JDK 25 and 26 had result() throws Throwable on the joiner, and their join() declared only InterruptedException (compare the two listings in the diff below).
Going further: JEP 543, section “Using StructuredTaskScope”.
The lifecycle: open → fork → join → process → close
Every scope walks through the same five stages, and it helps to hold the whole picture in your head before we vary the details:
- open — call a static
open(...)method, ideally in atry-with-resources. The thread that opens it is the scope’s owner. - fork — submit each subtask with
scope.fork(...). Each one starts immediately in its own virtual thread and returns aSubtaskhandle. - join — the owner calls
scope.join()exactly once, blocking until the scope’s completion policy is satisfied (all done, first success, timeout, …). - process — after
join()returns, read results from theSubtaskhandles, which are now guaranteed complete. - close — usually implicit via
try-with-resources. Closing cancels anything still running and waits for every thread to terminate, so no thread is ever left behind.
The dashed box is the guarantee: whatever starts between open() and close() has ended by the time control leaves the block. Oracle’s documentation draws the same shape with the threads on separate lanes:

Two rules follow from this lifecycle and are worth memorizing early: fork and join may be called only by the owner thread, and you must call join before reading any Subtask.get(). Calling get() too early throws an exception, because the result genuinely isn’t ready yet.
The scope enforces these rules at run time rather than trusting you. Failures.java breaks each rule on purpose; the messages below are the JDK’s own (04-failures.txt):
-- Subtask.get() before join()
java.lang.IllegalStateException: null
-- leaving the try block without join()
java.lang.IllegalStateException: Owner did not join after forking
-- fork() from a thread that does not own the scope
returned java.lang.WrongThreadException: Current thread not owner
-- closing scopes out of order (not try-with-resources)
java.util.concurrent.StructureViolationException: null
-- join() twice
java.lang.IllegalStateException: Already joined or scope is closed
Fingerprints worth recognising.IllegalStateException: Owner did not join after forkingmeans you left the block without callingjoin().WrongThreadException: Current thread not ownermeans a subtask (or another thread) calledforkon a scope it does not own — for example a scope used from inside a forked subtask or from another thread, as the demo does. A bareStructureViolationExceptionmeans scopes were closed in the wrong order, which try-with-resources makes impossible and manualclose()calls make easy.
Going deeper: a subtask that ignores interrupts holds the whole block hostage
Cancellation in this model is interruption, and close() always waits for the subtask threads to actually finish. A subtask that blocks on something non-interruptible, or spins without checking the interrupt flag, therefore delays the end of the try block even though the scope was cancelled immediately. StuckClose.java fails one subtask at 50 ms and leaves a sibling that needs 1.5 seconds, once responsive (Thread.sleep) and once as a busy loop. 14-stuck-close.txt:
subtask that responds to interrupts: caught fails at 50 ms; the try block took >= 1400 ms: false
subtask that ignores interrupts : caught fails at 50 ms; the try block took >= 1400 ms: trueThe responsive subtask is interrupted and the block ends at once; the busy loop keeps its thread for the full 1.5 s and the request waits with it. The elapsed time is reduced to a boolean in the transcript because the exact milliseconds vary between runs.
Going further: JEP 543, section “Cancellation”.
Choosing a policy with Joiners
So far we used the zero-argument open(), whose default policy is “wait for all subtasks; if any fails, cancel the rest and throw; otherwise return null.” That’s the right default, but many real tasks want a different rule — “give me the first one that answers,” or “collect every successful result.” You select a policy by passing a Joiner to open. A joiner decides how completions are handled and what join() ultimately returns.
The built-in joiners (JDK 27 and 28) cover the common cases:
Joiner.… factory |
What scope.join() does |
|---|---|
awaitAllSuccessfulOrThrow() |
Wait for all to succeed; throw on the first failure. Returns null. (Same policy as the default open().) |
allSuccessfulOrThrow() |
Wait for all to succeed; throw on the first failure. Returns a List of the results, in the order the subtasks were forked. |
anySuccessfulOrThrow() |
Return the result of the first subtask to succeed; cancel the rest. Throw only if all fail. |
allUntil(Predicate) |
Wait until all complete or your predicate says “enough,” then return the list of all subtasks. Its exception type is RuntimeException, so join() declares no checked exception here. |
…OrThrow(Function) overloads |
The first three, with a function that turns the failure into an exception of your own type. New in JDK 27. |
awaitAll() |
Existed in the JDK 25 and 26 previews (wait for all, never throw). Removed in JDK 27. |
Run them all and collect results
When every subtask returns the same type and you want all the answers, use allSuccessfulOrThrow() — it hands you a tidy List (Snippets.java):
<T> List<T> runConcurrently(Collection<Callable<T>> tasks) throws ExecutionException, InterruptedException {
try (var scope = StructuredTaskScope.open(Joiner.<T>allSuccessfulOrThrow())) {
tasks.forEach(scope::fork);
return scope.join(); // List<T> of results; throws ExecutionException if any task fails
}
}
Results come back in fork order, not completion order, and one failure cancels the others. Both facts from a real run of Joiners.java (03-joiners.txt); the tasks are named for how long they sleep:
-- allSuccessfulOrThrow(): List in FORK order, not completion order
[slow, fast, medium]
-- allSuccessfulOrThrow(): one failure cancels the others, join() throws ExecutionException
ExecutionException, cause = java.lang.IllegalStateException: broken failed
interrupted: [slow]
Race for the first answer
When you have several redundant sources and want whichever replies first — a classic latency trick — use anySuccessfulOrThrow() (Snippets.java):
<T> T race(Collection<Callable<T>> tasks) throws ExecutionException, InterruptedException {
try (var scope = StructuredTaskScope.open(Joiner.<T>anySuccessfulOrThrow())) {
tasks.forEach(scope::fork);
return scope.join(); // result of the first subtask to succeed
}
}
The moment one subtask succeeds, the scope is cancelled, the slower siblings are interrupted, and join() returns the winner. If every subtask fails, join() throws an ExecutionException (in the JDK 25 and 26 previews it was a FailedException). Real run:
-- anySuccessfulOrThrow(): the race; losers are interrupted
winner: B-50ms
interrupted: [A-300ms, C-300ms]
-- anySuccessfulOrThrow(): when every subtask fails
ExecutionException, cause = java.lang.IllegalStateException: one failed
A practical example you can run: five tasks that each sleep a random amount and throw if they’re “too slow.” RandomTasks.java seeds the random generator, so the durations are repeatable. With allSuccessfulOrThrow(), a fully successful run (seed 5) prints — and a run where one task exceeds the 700 ms threshold (seed 3) short-circuits the rest. Both from 13-random-tasks.txt:
$ java RandomTasks 5 (28-beta+21-ea)
Duration: 487
Duration: 92
Duration: 474
Duration: 424
Duration: 506
Result: 487
Result: 92
Result: 474
Result: 424
Result: 506
$ java RandomTasks 3 (28-beta+21-ea)
Duration: 734
Duration: 660
Duration: 210
Duration: 581
Duration: 128
ExecutionException: TooSlowException: Duration 734 greater than threshold 700
(The first version of this post quoted two outputs from Oracle’s JDK 26 example. These replace them: same idea, run on the final API.)
Going deeper: the other joiners, and your own exception type
Two more joiners and the Function overloads, each from 03-joiners.txt. awaitAllSuccessfulOrThrow() returns null and you read the Subtask handles afterwards. allUntil takes a predicate that sees each completed subtask; here it stops the scope when two have completed, and the two unfinished subtasks come back UNAVAILABLE and are interrupted:
-- awaitAllSuccessfulOrThrow(): returns null (Void), like the zero-argument open()
join() = null, a=a, b=b
-- allUntil(predicate): stop when two subtasks have completed
SUCCESS t1
SUCCESS t2
UNAVAILABLE
UNAVAILABLE
interrupted: [t3, t4]The new Function overloads are the way to make join() throw your own exception type instead of ExecutionException. The function receives the failed subtask’s exception, and because the joiner’s R_X is now your type, a checked exception is declared by join() itself:
-- the Function overload: join() throws OUR exception type
caught ServiceException: service call failed: orders failedGoing further: JEP 543, section “Joiners”.
Make a new joiner for every scope. AJoinercarries the state of one scope’s results. Never share one across scopes or reuse it after the scope closes — the JEP is explicit about it, and the factory-method style (Joiner.<T>allSuccessfulOrThrow()inline in theopen(...)call) makes it the default.
Handling failures
When a scope is considered failed, join() throws the exception type its joiner declares — ExecutionException for the default scope and the three built-in …OrThrow() joiners — whose getCause() is the original exception from the failed subtask. Because you handle it after the scope has closed, all sibling threads are already cancelled and joined — there is nothing left to clean up. Pattern matching makes the handling crisp (Snippets.java):
Response handleWithCatch() throws InterruptedException {
try (var scope = StructuredTaskScope.open()) {
Subtask<String> user = scope.fork(this::findUser);
Subtask<Integer> order = scope.fork(this::fetchOrder);
scope.join();
return new Response(user.get(), order.get());
} catch (ExecutionException e) {
Throwable cause = e.getCause();
switch (cause) {
case IOException ioe -> System.err.println("I/O failed: " + ioe);
default -> System.err.println("Unexpected: " + cause);
}
throw new IllegalStateException(e);
}
}
The same shape, run for real in Failures.java — and what happens when two subtasks fail at nearly the same time (04-failures.txt):
-- the failure surfaces as ExecutionException; pattern-match the cause
I/O failed: disk gone
-- two subtasks fail: which cause do you get?
cause = first (the first to fail wins)
Only one cause comes back: the first to fail. join() reports a single exception, so if the other failures matter to you, log them inside the subtasks. If a particular failure should instead yield a default value, prefer catching it inside the subtask and returning the default — that keeps the scope’s success/failure logic simple and intentional.
Timeouts and configuration
A further open overload accepts a configuration callback, letting you name the scope, set a deadline for the whole group, or supply a custom thread factory. Notice how this builds directly on the policy idea — the joiner decides what counts as done, and the timeout decides how long the group is allowed to take (Timeouts.java):
static <T> List<T> runConcurrently(Collection<Callable<T>> tasks, ThreadFactory factory, Duration timeout)
throws ExecutionException, InterruptedException {
try (var scope = StructuredTaskScope.open(Joiner.<T>allSuccessfulOrThrow(),
cf -> cf.withName("orders").withThreadFactory(factory).withTimeout(timeout))) {
tasks.forEach(scope::fork);
return scope.join();
}
}
If the timeout fires before join() is satisfied, the scope is cancelled (all incomplete subtasks interrupted) and join() throws the joiner’s timeout outcome. For the built-in joiners that is an ExecutionException whose cause is a StructuredTaskScope.CancelledByTimeoutException (the JDK 25 and 26 previews threw StructuredTaskScope.TimeoutException). The deadline applies to the entire sub-tree of work — exactly the kind of policy that’s awkward to express with raw futures. Real run, 200 ms deadline against a 5 s subtask (05-timeouts-and-custom-joiner.txt):
-- deadline 200 ms, one subtask needs 5 s
ExecutionException, cause = java.util.concurrent.StructuredTaskScope$CancelledByTimeoutException
returned within well under a second (not 5 s): the slow subtask was interrupted
The call came back in well under a second, not five, because the slow subtask was interrupted by the cancellation.
Going deeper: a timeout that returns a partial answer, and naming threads
Because the joiner owns the timeout outcome (its timeout() method), a timeout does not have to be an error. A joiner whose R_X is RuntimeException and whose timeout() returns what it has collected turns the same 200 ms deadline into a partial result. The last line shows withThreadFactory deciding thread names:
-- the same deadline with a joiner whose timeout() returns a partial result
join() = [fast]
-- a thread factory decides the subtask thread's name
[order-0, order-1]The BestEffort joiner is in Timeouts.java. withName names the scope for the thread dump below.
Going further: JEP 543, section “Configuration”.
Writing a custom Joiner
The built-in joiners are an “all or nothing” family. Sometimes you want something in between — for example, collect whatever succeeds and silently ignore failures. Implement the Joiner<T, R, R_X> interface, where T is the subtask result type, R is what join() returns and R_X is the exception type join() may throw. This is the interface as javap prints it on JDK 28 early access (08-api-28.txt):
public interface StructuredTaskScope$Joiner<T, R, R_X extends Throwable> {
public default boolean onFork(StructuredTaskScope$Subtask<T>);
public default boolean onComplete(StructuredTaskScope$Subtask<T>);
public abstract R result() throws R_X;
public abstract R timeout() throws R_X;
onFork and onComplete return a boolean that says “should the scope cancel now?”. result() produces what join() returns or throws. timeout() is new in JDK 27: it replaces the JDK 26 onTimeout() and, unlike it, must produce an outcome. Here is the collecting joiner from the JEP, fleshed out so it compiles as-is (CollectingJoiner.java):
public class CollectingJoiner<T> implements Joiner<T, List<T>, CompletionException> {
private final Queue<T> results = new ConcurrentLinkedQueue<>();
@Override
public boolean onComplete(Subtask<T> subtask) {
if (subtask.state() == Subtask.State.SUCCESS) {
results.add(subtask.get());
}
return false; // never cancel early: let everyone finish
}
@Override
public List<T> result() {
return List.copyOf(results);
}
@Override
public List<T> timeout() {
// the scope was opened with a timeout and it expired
throw new CompletionException(new StructuredTaskScope.CancelledByTimeoutException());
}
Two things matter here. First, onComplete can be called concurrently by several subtask threads, so the joiner must be thread-safe — that’s why results go into a ConcurrentLinkedQueue. Second, Subtask.state() reports one of three values: SUCCESS, FAILED, or UNAVAILABLE (forked but not completed, or completed after the scope was cancelled). Use it instead of calling get() blindly. Reuse rule: create a fresh Joiner for every scope — never share one across scopes or reuse it after a scope closes. Using it, and with a timeout (from 05-timeouts-and-custom-joiner.txt):
$ javac src/CollectingJoiner.java && java CollectingJoiner (28-beta+21-ea)
collected (sorted): [1, 3]
-- with a 100 ms timeout and a 5 s subtask
CompletionException, cause = java.util.concurrent.StructuredTaskScope$CancelledByTimeoutException
Observability: see the hierarchy in a thread dump
Remember the original complaint — a thread dump of the unstructured version showed three unrelated threads with no hint they belonged together. Structured concurrency fixes that too. Generate a JSON thread dump while your program runs. This is the real command and its reply, against a program holding a scope named orders open, with one of its subtasks running a child scope named inventory (ThreadDump.java, 15-jcmd-thread-dump.txt):
$ jcmd <pid> Thread.dump_to_file -format=json threads.json (28-beta+21-ea)
<pid>:
Created <tmp>/threads.json
Each scope appears as a container that lists the threads forked inside it and a reference to its parent scope, so tools can reconstruct the exact task tree. These are the lines of that file that carry the structure, from the orders container onwards, with identity hashes and numeric ids masked and each stack reduced to the frame that is application code:
# the scope "orders" and its child scope in threads.json (stack frames, timestamps and ids trimmed or masked)
"container": "orders\/jdk.internal.misc.ThreadFlock$ThreadContainerImpl@<hash>",
"parent": "<root>",
"owner": <n>,
"tid": <n>,
"virtual": true,
"state": "TIMED_WAITING",
"ThreadDump.lambda$main$1(ThreadDump.java:12)",
"tid": <n>,
"virtual": true,
"state": "WAITING",
"ThreadDump.lambda$main$2(ThreadDump.java:16)",
"container": "inventory\/jdk.internal.misc.ThreadFlock$ThreadContainerImpl@<hash>",
"parent": "orders\/jdk.internal.misc.ThreadFlock$ThreadContainerImpl@<hash>",
"owner": <n>,
"tid": <n>,
"virtual": true,
"state": "TIMED_WAITING",
"ThreadDump.lambda$main$4(ThreadDump.java:15)",
This is the observability payoff of the whole model: the runtime hierarchy mirrors your code’s block structure, so “what is this request working on right now?” becomes answerable for concurrent code, not just single-threaded code. The tree is in the parent lines: orders hangs off <root> because the thread that opened it is not inside another scope, and inventory, opened by a subtask of orders, names the orders container as its parent.
Going deeper: taking the dump from inside the program
The same JSON can be produced without jcmd through com.sun.management.HotSpotDiagnosticMXBean.dumpThreads(file, ThreadDumpFormat.JSON), which is how ThreadDump.java does it when run without arguments (06-thread-dump.txt). It lives in the jdk.management module, so the demo is compiled with --add-modules jdk.management. One typo worth knowing about: the JEP text spells the interface HotSpotDiagnosticsMXBean (with an extra “s”); the class in the JDK is HotSpotDiagnosticMXBean, which is what javap prints.
Going further: JEP 543, section “Observability”.
Scope hierarchies and scoped values
Because a subtask can itself open a StructuredTaskScope and fork its subtasks, scopes nest into a tree that mirrors your method calls. The lifetime rule composes cleanly: a child scope’s threads are all terminated before the child scope closes, which happens before the parent scope closes. Cancellation flows down that tree automatically.
The tree is the same shape as the call stack of a single-threaded program, with threads in place of frames. The blue box is a ScopedValue binding, and every green box underneath it reads the value; the dashed box is a plain thread started next to the scope, which does not.
Subtasks also inherit ScopedValue bindings (JEP 506, final in JDK 25). If the owner binds a scoped value — say, the current request ID or principal — every forked subtask reads the same value, with none of the mutability hazards of ThreadLocal. Structured concurrency and scoped values are designed as a matched pair: one carries immutable context down into the subtasks, the other carries structure and lifetime. ScopedValues.java proves both halves (07-scoped-values.txt):
forked subtask sees req-7731
plain thread: bound? false
outside the binding: bound? false
Scoped values themselves are covered in Scoped Values vs ThreadLocal in Java 25: Migration Guide with Virtual Threads.
Structured concurrency vs. CompletableFuture
A fair question for experienced readers: don’t we already have CompletableFuture? They solve different problems, and the distinction is the whole point of the feature:
CompletableFutureis for the asynchronous, non-blocking style: you compose callbacks (thenApply,thenCompose,exceptionally) into a pipeline, and tasks may outlive the method that created them. It’s powerful but the lifetime is unbounded and error/cancellation handling is manual.StructuredTaskScopeis for the blocking, structured style: fork, block once injoin(), then handle results and errors centrally. Lifetimes are confined to a lexical block, and cancellation is automatic.
With cheap virtual threads, “just block” is no longer expensive, so the structured/blocking style gives you most of the throughput with far less cognitive load. The JEP is explicit that structured concurrency is not meant to replace ExecutorService, Future, or CompletableFuture — reach for it when your tasks have a genuine parent-child relationship and should live and die together.
Migrating from ExecutorService
You don’t need a rewrite. The mechanical translation is: replace the ExecutorService (used in a try-with-resources) with a StructuredTaskScope, replace submit(...) with fork(...), add a single join() before you read results, and replace future.get() with subtask.get(). The payoff is that thread leaks and manual cancellation simply disappear, because the scope enforces structure at runtime — forking from a non-owner thread or leaving the block without closing throws a StructureViolationException or a WrongThreadException rather than silently corrupting your task tree (see the transcript in the lifecycle section).
Upgrading code written for the JDK 25 or 26 preview
If you adopted the preview early, the final shape breaks your code in six specific ways. Each row is a source in broken/ that compiles on JDK 26 and fails on the 28 early-access build, with the compiler’s own message from 10-broken-old-api.txt:
| You wrote (25/26 preview) | The 27/28 compiler says | Write instead |
|---|---|---|
String handle() throws InterruptedException around scope.join() |
unreported exception ExecutionException |
declare or catch ExecutionException |
catch (StructuredTaskScope.FailedException e) |
cannot find symbol ... class FailedException |
catch (ExecutionException e) |
catch (StructuredTaskScope.TimeoutException e) |
cannot find symbol ... class TimeoutException |
ExecutionException with a CancelledByTimeoutException cause |
Joiner.awaitAll() |
cannot find symbol ... method <String>awaitAll() |
awaitAllSuccessfulOrThrow(), or allUntil with a predicate that ends the scope when you decide |
implements Joiner<T, List<T>> with onTimeout() |
wrong number of type arguments; required 3 |
Joiner<T, List<T>, X> and timeout() |
new StructuredTaskScope.ShutdownOnFailure() (JDK 21–24 tutorials) |
cannot find symbol ... class ShutdownOnFailure (on 25 as well) |
StructuredTaskScope.open() |
The compiler output for two of them, verbatim
$ javac broken/MissingCatch.java (28-beta+21-ea)
broken/MissingCatch.java:8: error: unreported exception ExecutionException; must be caught or declared to be thrown
scope.join();
^
1 error
exit=1$ javac broken/OldJoiner.java (28-beta+21-ea)
broken/OldJoiner.java:7: error: wrong number of type arguments; required 3
public class OldJoiner<T> implements Joiner<T, List<T>> {
^
1 error
exit=1The other four are in 10-broken-old-api.txt. The JDK 26 compiles print exit=0 because they used --enable-preview --release 26; the 28 compiles use no flags.
The preview flag is a trap in both directions. Code compiled on JDK 27 with--enable-previewcarries class-file minor version 65535 and will not load on JDK 28, even with--enable-preview: the runtime only accepts preview class files for its own feature release. Rebuild on 28 without the flag. Andjavac --release 27on a 28 compiler still treats the API as preview, because--release 27compiles against the JDK 27 view of the API, where it is one. The full transcript, including the--release 27compile errors, is in 11-preview-traps.txt; the run-time half:$ java --enable-preview Server (28-beta+21-ea) Error: LinkageError occurred while loading main class Server java.lang.UnsupportedClassVersionError: Server (class file version 71.65535) was compiled with preview features that are unsupported. This version of the Java Runtime only recognizes preview features for class file version 72.65535 $ java Server (27.0.1.0.2+1) Error: LinkageError occurred while loading main class Server java.lang.UnsupportedClassVersionError: Preview features are not enabled for Server (class file version 71.65535). Try running with '--enable-preview'
The first line of the error is the one to search for if you hit it in CI: class file version 71.65535 is JDK 27 plus the preview marker. The same class file ran fine on 27 with the flag (the first lines of the transcript). And if you build with --enable-preview --release 28 on the 28 early-access JDK for other reasons, javac may print Note: ... uses preview features of Java SE 28 even for this file; that Note comes from the platform’s own class files (Record, Duration, Integer and others are reported as using preview features of Java SE 28), not from structured concurrency (11-preview-traps.txt shows the first lines of -Xlint:preview).
Best practices and gotchas
A short checklist distilled from everything above:
- Flags depend on the JDK. On JDK 28 there are none. On JDK 27 compile with
javac --release 27 --enable-previewand run withjava --enable-preview(orjshell --enable-previewto experiment), and rebuild for 28; the class files do not carry over. - One
join()per scope, by the owner thread. Don’t readSubtask.get()before joining. - A new
Joinerper scope. Never reuse or share joiner instances. - Make subtasks interruptible. A subtask that ignores interrupts (e.g. blocks on a non-interruptible call) can delay
close()indefinitely, becauseclose()always waits for threads to actually terminate (the busy-loop transcript above). - Let exceptions flow, handle them after the block. Use a
catch (ExecutionException e)on the try-with-resources and pattern-matche.getCause(). - Don’t pass a scope to code that expects an
ExecutorService.StructuredTaskScopedeliberately does not implementExecutorService, precisely because that interface is used in unstructured ways.
What changed across the previews: version history and current status
Structured concurrency has had an unusually long, careful incubation — useful context when you read older tutorials whose API no longer matches. The API changed in four of the ten releases (21, 25, 26 and 27); the others re-previewed it, or finalised it, as it was.
Read the colours as “did my code from the previous release still compile?”: light blue yes, dark blue no. The table gives each release’s change in the words of its JEP, and the right-hand column says what I could check against the binaries.
| Release | JEP | What changed | Checked against a binary |
|---|---|---|---|
| JDK 19 | 428 | Incubator, in jdk.incubator.concurrent |
No (JEP text only) |
| JDK 20 | 437 | Second incubator; inherits scoped values (JEP 429) | No (JEP text only) |
| JDK 21 | 453 | First preview, in java.util.concurrent; fork returns a Subtask (not a Future) |
No (JEP text only) |
| JDK 22 / 23 / 24 | 462 / 480 / 499 | Re-previews “without change” | No (JEP text only) |
| JDK 25 | 505 | Big API change: public constructors replaced by static open(...) factories; Joiner introduced; the zero-argument open() covers the common case. ShutdownOnFailure / ShutdownOnSuccess no longer exist |
Yes: 25.0.5+8 has open, Joiner, FailedException, TimeoutException; ShutdownOnFailure does not compile |
| JDK 26 | 525 | allSuccessfulOrThrow() now returns a List; anySuccessfulResultOrThrow renamed to anySuccessfulOrThrow; Joiner.onTimeout() added; open(Joiner, Function) now takes a UnaryOperator |
Yes: javap diff 25 to 26 shows all four, plus allUntil returning a List and Subtask<T> parameters on onFork/onComplete |
| JDK 27 | 533 | Seventh preview: third type parameter R_X for join()’s exception; open(UnaryOperator); …OrThrow(Function) overloads and ExecutionException as the default; awaitAll() removed; onTimeout() replaced by timeout(); CancelledByTimeoutException |
Yes: javap diff 26 to 27, and the nested-class list per JDK |
| JDK 28 | 543 | Final, “without further change”. Status: Integrated; GA proposed 2027-03-09 | Yes, on an early-access build: javap diff 27 to 28 is empty and the @PreviewFeature annotation is gone |
If you’re starting today, learn the open(...) + Joiner API shown here; the pre-JDK-25 ShutdownOnFailure / ShutdownOnSuccess classes you may see in older posts are gone (the compiler output is in the upgrade section). The 27-to-28 diff, as printed by run.sh (09-api-diff.txt), is empty apart from the build names; the 25-to-26 and 26-to-27 diffs are in the accordion below:
$ diff api-27 api-28 (signature lines only; the first line of each file names the build)
(no difference)
The full javap diffs between 25, 26 and 27, and which exception classes exist where
From 09-api-diff.txt, with the signature lines that changed. Lines starting with < are the older release, > the newer. First 25 to 26:
$ diff api-25 api-26 (signature lines only; the first line of each file names the build)
< public static <T, R> StructuredTaskScope<T, R> open(StructuredTaskScope$Joiner<? super T, ? extends R>, Function<StructuredTaskScope$Configuration, StructuredTaskScope$Configuration>);
> public static <T, R> StructuredTaskScope<T, R> open(StructuredTaskScope$Joiner<? super T, ? extends R>, UnaryOperator<StructuredTaskScope$Configuration>);
< public default boolean onFork(StructuredTaskScope$Subtask<? extends T>);
< public default boolean onComplete(StructuredTaskScope$Subtask<? extends T>);
> public default boolean onFork(StructuredTaskScope$Subtask<T>);
> public default boolean onComplete(StructuredTaskScope$Subtask<T>);
> public default void onTimeout();
< public static <T> StructuredTaskScope$Joiner<T, stream.Stream<StructuredTaskScope$Subtask<T>>> allSuccessfulOrThrow();
< public static <T> StructuredTaskScope$Joiner<T, T> anySuccessfulResultOrThrow();
> public static <T> StructuredTaskScope$Joiner<T, List<T>> allSuccessfulOrThrow();
> public static <T> StructuredTaskScope$Joiner<T, T> anySuccessfulOrThrow();
< public static <T> StructuredTaskScope$Joiner<T, stream.Stream<StructuredTaskScope$Subtask<T>>> allUntil(Predicate<StructuredTaskScope$Subtask<? extends T>>);
> public static <T> StructuredTaskScope$Joiner<T, List<StructuredTaskScope$Subtask<T>>> allUntil(Predicate<StructuredTaskScope$Subtask<T>>);Then 26 to 27 (09-api-diff.txt), the release that changed the most:
$ diff api-26 api-27 (signature lines only; the first line of each file names the build)
< public interface StructuredTaskScope<T, R> extends AutoCloseable {
< public static <T, R> StructuredTaskScope<T, R> open(StructuredTaskScope$Joiner<? super T, ? extends R>, UnaryOperator<StructuredTaskScope$Configuration>);
< public static <T, R> StructuredTaskScope<T, R> open(StructuredTaskScope$Joiner<? super T, ? extends R>);
< public static <T> StructuredTaskScope<T, Void> open();
> public interface StructuredTaskScope<T, R, R_X extends Throwable> extends AutoCloseable {
> public static <T, R, R_X extends Throwable> StructuredTaskScope<T, R, R_X> open(StructuredTaskScope$Joiner<? super T, ? extends R, R_X>, UnaryOperator<StructuredTaskScope$Configuration>);
> public static <T, R, R_X extends Throwable> StructuredTaskScope<T, R, R_X> open(StructuredTaskScope$Joiner<? super T, ? extends R, R_X>);
> public static <T> StructuredTaskScope<T, Void, ExecutionException> open(UnaryOperator<StructuredTaskScope$Configuration>);
> public static <T> StructuredTaskScope<T, Void, ExecutionException> open();
< public abstract R join() throws InterruptedException;
> public abstract R join() throws R_X, InterruptedException;
< public interface StructuredTaskScope$Joiner<T, R> {
> public interface StructuredTaskScope$Joiner<T, R, R_X extends Throwable> {
< public default void onTimeout();
< public abstract R result() throws Throwable;
< public static <T> StructuredTaskScope$Joiner<T, List<T>> allSuccessfulOrThrow();
< public static <T> StructuredTaskScope$Joiner<T, T> anySuccessfulOrThrow();
< public static <T> StructuredTaskScope$Joiner<T, Void> awaitAllSuccessfulOrThrow();
< public static <T> StructuredTaskScope$Joiner<T, Void> awaitAll();
< public static <T> StructuredTaskScope$Joiner<T, List<StructuredTaskScope$Subtask<T>>> allUntil(Predicate<StructuredTaskScope$Subtask<T>>);
> public abstract R result() throws R_X;
> public abstract R timeout() throws R_X;
> public static <T, R_X extends Throwable> StructuredTaskScope$Joiner<T, List<T>, R_X> allSuccessfulOrThrow(Function<Throwable, R_X>);
> public static <T> StructuredTaskScope$Joiner<T, List<T>, ExecutionException> allSuccessfulOrThrow();
> public static <T, R_X extends Throwable> StructuredTaskScope$Joiner<T, T, R_X> anySuccessfulOrThrow(Function<Throwable, R_X>);
> public static <T> StructuredTaskScope$Joiner<T, T, ExecutionException> anySuccessfulOrThrow();
> public static <T, R_X extends Throwable> StructuredTaskScope$Joiner<T, Void, R_X> awaitAllSuccessfulOrThrow(Function<Throwable, R_X>);
> public static <T> StructuredTaskScope$Joiner<T, Void, ExecutionException> awaitAllSuccessfulOrThrow();
> public static <T> StructuredTaskScope$Joiner<T, List<StructuredTaskScope$Subtask<T>>, RuntimeException> allUntil(Predicate<? super StructuredTaskScope$Subtask<T>>);And the exception classes that exist in each JDK, and whether the API still carries the preview annotation (09-api-diff.txt again):
$ jimage list modules | grep -E 'FailedException|TimeoutException|CancelledByTimeout' (25.0.5+8-LTS)
java/util/concurrent/StructuredTaskScope$FailedException.class
java/util/concurrent/StructuredTaskScope$TimeoutException.class
$ jimage list modules | grep -E 'FailedException|TimeoutException|CancelledByTimeout' (26.0.2.1+1)
java/util/concurrent/StructuredTaskScope$FailedException.class
java/util/concurrent/StructuredTaskScope$TimeoutException.class
$ jimage list modules | grep -E 'FailedException|TimeoutException|CancelledByTimeout' (27.0.1.0.2+1)
java/util/concurrent/StructuredTaskScope$CancelledByTimeoutException.class
$ jimage list modules | grep -E 'FailedException|TimeoutException|CancelledByTimeout' (28-beta+21-ea)
java/util/concurrent/StructuredTaskScope$CancelledByTimeoutException.class
$ javap -v StructuredTaskScope | grep 'PreviewFeature(' -A1 (26.0.2.1+1)
jdk.internal.javac.PreviewFeature(
feature=Ljdk/internal/javac/PreviewFeature$Feature;.STRUCTURED_CONCURRENCY
$ javap -v StructuredTaskScope | grep 'PreviewFeature(' -A1 (27.0.1.0.2+1)
jdk.internal.javac.PreviewFeature(
feature=Ljdk/internal/javac/PreviewFeature$Feature;.STRUCTURED_CONCURRENCY
$ javap -v StructuredTaskScope | grep 'PreviewFeature(' -A1 (28-beta+21-ea)
(no @PreviewFeature annotation: the API is final)
Why the numbers 428 to 543 are not a straight line
Each JEP above is a separate document with its own status. JEP 543 was created on 2026/08/05 and last updated 2026/10/17; JEP 533, the JDK 27 preview, was created on 2025/12/12. Both lists are on the JDK pages: JDK 27 shows “533: Structured Concurrency (Seventh Preview)” among its features, and JDK 28 lists “543: Structured Concurrency” among the JEPs targeted so far. A claim you may have seen that the finalising JEP is a different number, or that the feature was final in JDK 27, does not match either page.
Quick reference
Every shape from this post in one compiling file (QuickReference.java; it opens each scope kind once and closes them in reverse order, which is the rule try-with-resources applies for you):
// Open a scope: the policy is the Joiner, the type parameters are <T subtask result, R join() result, R_X join() exception>
var plain = StructuredTaskScope.<String>open(); // join() -> Void, throws ExecutionException
var all = StructuredTaskScope.open(Joiner.<String>allSuccessfulOrThrow()); // join() -> List<String>, in fork order
var any = StructuredTaskScope.open(Joiner.<String>anySuccessfulOrThrow()); // join() -> String, first success wins
var await = StructuredTaskScope.open(Joiner.<String>awaitAllSuccessfulOrThrow()); // join() -> Void
var until = StructuredTaskScope.open(Joiner.<String>allUntil(s -> false)); // join() -> List<Subtask<String>>, no checked exception
var mine = StructuredTaskScope.open(Joiner.<String, IllegalStateException>allSuccessfulOrThrow(IllegalStateException::new)); // your exception type
// Configure: name for thread dumps, a deadline for the whole group, a thread factory for the subtasks
var cfg = StructuredTaskScope.open(Joiner.<String>allSuccessfulOrThrow(),
cf -> cf.withName("orders").withTimeout(Duration.ofMillis(500)).withThreadFactory(factory));
// Fork, then read each Subtask only after join()
Subtask<String> subtask = all.fork(() -> "value");
all.join();
String value = subtask.get(); // like Future.resultNow()
Subtask.State state = subtask.state(); // SUCCESS | FAILED | UNAVAILABLE
// Subtask also has exception(); see output/08-api-28.txt for every signature
For exact signatures, the javap listing of the JDK 28 early-access build is in 08-api-28.txt; the Joiner part of it is quoted in the custom-joiner section above.
Should you use it today?
It depends on which JDK you can ship on. There is no GA JDK with the final API yet. On JDK 25 or 27 the API is a preview: you need--enable-previewat compile and run time, and the class files you produce only run on that one feature release, as the71.65535error above shows. That rules it out for libraries and for anything you deploy to a runtime you do not control. For an application you build and run yourself, on 27, it is a reasonable bet — JEP 543 proposes no change on the way to 28 — but plan to recompile when you move.
On JDK 28 after general availability (proposed 2027-03-09) it is an ordinary API and the answer is yes for the case it was designed for: a request that fans out to a handful of blocking calls and needs them all, or the first of several. It is the wrong tool for tasks that must outlive the method that starts them (use an executor), for event pipelines (useCompletableFutureor a reactive library), and for fan-outs so small and fast that a sequential loop is clearer. And it does nothing for a subtask that ignores interrupts.
Closing thought
Structured concurrency is a small API with a big idea: concurrency should be as readable and reliable as a method call. You write what looks like ordinary blocking code; the runtime gives you parallel execution, automatic cancellation, clean error propagation, and a thread dump that finally makes sense. Start with open() and join() for the 80% case, reach for a Joiner when you need a race or a collection, and write a custom joiner only when your policy is genuinely your own. With virtual threads underneath, this is how concurrent Java is meant to be written from here on — and from JDK 28 it no longer needs a flag.
Run it yourself
Everything above lives in the structured-concurrency-final module of the javademos repository: the sources in src/, the six old-API sources in broken/, and every transcript in output/. JDK25=… JDK26=… JDK27=… JDK28=… ./run.sh regenerates all of it. Timings are reduced to booleans so the files are byte-stable between runs. Outputs came from Temurin 28-beta+21-ea, 27.0.1.0.2+1, 26.0.2.1+1 and 25.0.5+8.
References and further reading
- JEP 543: Structured Concurrency — OpenJDK (the final API, JDK 28)
- JEP 533: Structured Concurrency (Seventh Preview) — OpenJDK (JDK 27)
- JEP 525: Structured Concurrency (Sixth Preview) and JEP 505: Structured Concurrency (Fifth Preview) — OpenJDK
- JDK 28 project page and JDK 27 project page — OpenJDK
- Structured Concurrency — JDK 26 Core Libraries Guide — Oracle (describes the 26 preview API)
- JEP 444: Virtual Threads and JEP 506: Scoped Values — OpenJDK
- On ankurm.com: Java 27 Is Out: Every JEP, Scoped Values vs ThreadLocal, Top 40 Java Concurrency Interview Questions
- Companion code: javademos / structured-concurrency-final
No Comments yet!