Skip to main content

Structured Concurrency Is Final in JDK 28 (JEP 543): From Preview to Production

JEP 543 finalizes StructuredTaskScope in JDK 28. Runnable examples, what changed in each preview from JDK 21 to 27, and an upgrade guide for 25/26 code.

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: Temurin 28-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. javap prints 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 — but fetchOrder() 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 and fetchOrder() has already failed, you still block on user.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.

ExecutorService (Unstructured.java)handle() threadfindUser()fetchOrder()catches the exception, returns, and the request is overthrows at 50 msstill running at 300 ms: a leaked threadStructuredTaskScope (FailFast.java)owner threadfindUser()fetchOrder()join() throws; close() waits for every thread; then the catch block runsthrows at 50 msinterrupted at 50 ms, gone before handle() returns0 ms100 ms200 ms300 msThe 50 ms and 300 ms are the sleep() values in the two source files; the right-hand notes are what the transcript printed.

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 declares throws ExecutionException, InterruptedException. In the JDK 25 and 26 previews join() threw only InterruptedException and a failed scope surfaced as a StructuredTaskScope.FailedException. The final API adds a third type parameter, R_X, for the exception join() throws, and for the default scope it is the familiar ExecutionException. 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 juggling Future.cancel(...).
  • Observability — a thread dump shows findUser and fetchOrder as 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:

  1. open — call a static open(...) method, ideally in a try-with-resources. The thread that opens it is the scope’s owner.
  2. fork — submit each subtask with scope.fork(...). Each one starts immediately in its own virtual thread and returns a Subtask handle.
  3. join — the owner calls scope.join() exactly once, blocking until the scope’s completion policy is satisfied (all done, first success, timeout, …).
  4. process — after join() returns, read results from the Subtask handles, which are now guaranteed complete.
  5. 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 try-with-resources block: every thread started inside it ends inside itowner threadopen()fork() x2join()get() resultsclose()subtask threads (virtual, one per fork)findUser()fetchOrder()join() returns here: all subtasks are finishedif the block is left early,close() cancels the rest and waitsRead left to right: the dashed box is the lifetime guarantee. A subtask cannot outlive the line that closes it,and the results are only readable after join(), because before that they might not exist yet.

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:

Diagram: the owner thread opens a StructuredTaskScope, forks subtasks into separate threads, joins them as a unit, then closes the scope.
Forking subtasks and joining them as a single unit of work. Image credit: Oracle — JDK 26 Core Libraries Guide, “Structured Concurrency.”

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 forking means you left the block without calling join(). WrongThreadException: Current thread not owner means a subtask (or another thread) called fork on 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 bare StructureViolationException means scopes were closed in the wrong order, which try-with-resources makes impossible and manual close() 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: true

The 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 failed

Going further: JEP 543, section “Joiners”.

Make a new joiner for every scope. A Joiner carries 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 the open(...) 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.

ScopedValue.where(REQUEST_ID, “req-7731”).run(…)scope “orders” (owner)subtask Asubtask Bchild scope (B owns it)subtask B1new Thread(…)REQUEST_ID.isBound() = falsegreen boxes read req-7731(07-scoped-values.txt)

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:

  • CompletableFuture is 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.
  • StructuredTaskScope is for the blocking, structured style: fork, block once in join(), 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=1

The 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-preview carries 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. And javac --release 27 on a 28 compiler still treats the API as preview, because --release 27 compiles against the JDK 27 view of the API, where it is one. The full transcript, including the --release 27 compile 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-preview and run with java --enable-preview (or jshell --enable-preview to experiment), and rebuild for 28; the class files do not carry over.
  • One join() per scope, by the owner thread. Don’t read Subtask.get() before joining.
  • A new Joiner per 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, because close() 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-match e.getCause().
  • Don’t pass a scope to code that expects an ExecutorService. StructuredTaskScope deliberately does not implement ExecutorService, 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.

JDK 19JEP 428incubatorJDK 20JEP 437incubatorJDK 21JEP 453SubtaskJDK 22JEP 462sameJDK 23JEP 480sameJDK 24JEP 499sameJDK 25JEP 505new APIJDK 26JEP 525minorJDK 27JEP 533R_X addedJDK 28JEP 543finalTen releases, one featureOrange: incubator. Light blue: preview with the same API as the one before. Darker blue: preview where the API changed. Green: final.The API you write against today (JDK 27) is the API JDK 28 finalises: JEP 543 says “without further change”,and javap shows the same signatures on 27 and on 28-ea+21 (output/09-api-diff.txt).

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-preview at compile and run time, and the class files you produce only run on that one feature release, as the 71.65535 error 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 (use CompletableFuture or 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

No Comments yet!

This site uses Akismet to reduce spam. Learn how your comment data is processed.