BT

Facilitating the Spread of Knowledge and Innovation in Professional Software Development

Write for InfoQ

Topics

Choose your language

InfoQ Homepage News JEP 543 Finalizes Structured Concurrency in JDK 28, Ending Seven Preview Rounds

JEP 543 Finalizes Structured Concurrency in JDK 28, Ending Seven Preview Rounds

Listen to this article -  0:00

JEP 543, Structured Concurrency, has been integrated into JDK 28, bringing the StructuredTaskScope API in java.util.concurrent to final status with no API changes from JDK 27's seventh preview. For codebases already on JDK 27, the migration comes down to recompiling without --enable-preview.

Finalization concludes the longest preview cycle of any Project Loom feature. Structured concurrency debuted as an incubator API in JEP 428 (JDK 19) and JEP 437 (JDK 20), then progressed through seven previews: JEP 453 (JDK 21), JEP 462 (JDK 22), JEP 480 (JDK 23), JEP 499 (JDK 24), JEP 505 (JDK 25), JEP 525 (JDK 26), and JEP 533 (JDK 27). Across every iteration, the core objective has been to treat groups of related subtasks running in different threads as a single unit of work, ensuring that a failure in one subtask cancels the others and that the parent scope cannot exit before all child threads have terminated. Traditional ExecutorService and Future pipelines do not enforce those boundaries: a parent method can return while subtasks continue running in the background, and a subtask failure surfaces only when a caller eventually invokes Future.get().

The API centers on StructuredTaskScope<T, R, R_X extends Throwable>, a sealed interface opened through static factory methods rather than public constructors. The zero-argument open() factory returns a scope whose join() method waits for all subtasks to succeed, cancels the rest on the first failure, and throws ExecutionException:

Response handle() throws ExecutionException, InterruptedException {
    try (var scope = StructuredTaskScope.open()) {
        Subtask<String>  user  = scope.fork(() -> findUser());
        Subtask<Integer> order = scope.fork(() -> fetchOrder());
        scope.join(); // throws ExecutionException if either subtask fails
        return new Response(user.get(), order.get());
    }
}

Each fork() call starts a virtual thread by default; join() serves as the single point where the owner thread awaits the outcome; and close() at the end of the try-with-resources block cancels any unfinished subtasks and waits for their threads to terminate. Completion policies are defined through the Joiner<T, R, R_X> interface: Joiner.awaitAllSuccessfulOrThrow() matches the default policy above, Joiner.allSuccessfulOrThrow() returns a List<T> directly from join() for homogeneous subtasks, Joiner.anySuccessfulOrThrow() returns the first successful result while canceling the rest, and Joiner.allUntil(Predicate) cancels the scope once a predicate matches.

The finalized API preserves the exact shape established in JDK 27. JEP 533 introduced the third type parameter, R_X, so that join() declares the specific exception type its Joiner can throw rather than raising a preview-specific FailedException. Under the built-in throwing joiners, join() throws ExecutionException, the same wrapper long used by Future.get(). Teams coming from JDK 26 have more to change. They will see FailedException replaced by ExecutionException and R_X added to custom joiner signatures. In addition, StructuredTaskScope.TimeoutException has been removed in favor of reporting timeouts through a CancelledByTimeoutException cause, Joiner.awaitAll() is no longer available, and the onTimeout() callback has been replaced by an abstract timeout() method.

Custom policies are where the third type parameter earns its place. A joiner that waits for a quorum of replica reads, for example, can surface its own checked exception:

class QuorumJoiner<T> implements Joiner<T, List<T>, QuorumException> {

    private final int quorum;
    private final Queue<T> results = new ConcurrentLinkedQueue<>();

    QuorumJoiner(int quorum) {
        this.quorum = quorum;
    }

    @Override
    public boolean onComplete(Subtask<T> subtask) {
        if (subtask.state() == Subtask.State.SUCCESS) {
            results.add(subtask.get());
        }
        return results.size() >= quorum; // true cancels the remaining reads
    }

    @Override
    public List<T> result() throws QuorumException {
        if (results.size() < quorum) {
            throw new QuorumException(results.size(), quorum);
        }
        return List.copyOf(results).subList(0, quorum);
    }

    @Override
    public List<T> timeout() throws QuorumException {
        throw new QuorumException(results.size(), quorum,
                new CancelledByTimeoutException());
    }
}

try (var scope = StructuredTaskScope.open(new QuorumJoiner<String>(2))) {
    replicas.forEach(r -> scope.fork(() -> r.read(key)));
    List<String> values = scope.join(); // throws QuorumException
}

Because the joiner declares QuorumException, an application-defined checked exception, as its R_X, join() throws it directly rather than wrapping it in ExecutionException. timeout() follows the JDK 27 contract by throwing the same exception with a CancelledByTimeoutException cause.

Timeout and configuration semantics also carry over unchanged. Passing a UnaryOperator<Configuration> to open() configures a scope name for observability, a custom ThreadFactory, or a deadline that cancels the scope if it expires before or during join():

try (var scope = StructuredTaskScope.open(
        cf -> cf.withTimeout(Duration.ofSeconds(2)).withName("profile-page"))) {
    Subtask<String> user = scope.fork(() -> findUser());
    Subtask<Integer> order = scope.fork(() -> fetchOrder());
    scope.join();
    return new Response(user.get(), order.get());
} catch (ExecutionException e) {
    return switch (e.getCause()) {
        case CancelledByTimeoutException _ -> handleTimeout();
        case IOException ioe -> handleIo(ioe);
        default -> throw e;
    };
}

The API is also strict about structure. Only the thread that opened the scope can call fork() and join(), results are read through Subtask.get() only after join() returns, and structural misuse, such as closing scopes out of order, fails fast with StructureViolationException instead of leaking threads. For context propagation and debugging, subtasks inherit ScopedValue bindings (JEP 506) and appear under their parent in JSON thread dumps.

One detail is deliberately left open: if several subtasks fail at once, the API does not say which failure join() reports. Viktor Klang raised the question during code review, and Alan Bateman, the JEP's owner at Oracle, reworded the Javadoc so it promises only that the exception comes from a failed subtask.

For teams on JDK 27, moving to the final API is a build change, not a code change. The finalization commit only removes the preview annotations and changes no method signatures, so teams can drop --enable-preview from javac, test runners, and launch scripts, then recompile on a JDK 28 early-access build that includes the change. JDK 28 is scheduled for general availability in March 2027.

The bigger shift is for library and framework maintainers, who can now build on structured concurrency without forcing preview flags on their users. The velo/structured-concurrency project already backports the JEP 543 API to JDK 25 and ships migration recipes for moving to the JDK classes after an upgrade.

About the Author

Rate this Article

Adoption
Style

BT