JEP 543 Finalizes Structured Concurrency in JDK 28, Ending Seven Preview Rounds
JDK 28 finalizes structured concurrency through JEP 543, with no API changes from JDK 27. Teams on JDK 27 only need to drop enable preview and recompile. Code written for JDK 25 or 26 must adapt to the R X exception type parameter, ExecutionException wrapping, and the abstract timeout() joiner callback. By A N M…

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.
Originally published on
InfoQ
Keep reading





