Dev & EngARTICLE

Java 25 still has Structured Concurrency in preview: is it ready for production?

StructuredTaskScope from JEP 505 remains in its fifth preview in Java 25, replacing ExecutorService and Future with a block that cancels and propagates errors on its own, but it still depends on the --enable-preview flag to compile and run.

Editor's note: this article uses the word 'now' to discuss the adoption of StructuredTaskScope in production, but Java 25 and the API's fifth preview (JEP 505) have been in circulation for longer than the term suggests, and the JEP itself already points to a sixth preview (JEP 525) on the way. The technical content about the API, its history and behavior is confirmed by the source; the reader should understand 'now' as 'in the preview's current state,' not as a recent release.

StructuredTaskScope from JEP 505 remains in its fifth preview in Java 25, replacing ExecutorService and Future with a block that cancels and propagates errors on its own, but it still depends on the --enable-preview flag to compile and run.

StructuredTaskScope from JEP 505 remains in its fifth preview in Java 25, replacing ExecutorService and Future with a block that cancels and propagates errors on its own, but it still depends on the --enable-preview flag to compile and run.

From Incubation to the Fifth Preview: A Long Journey

Structured Concurrency has accompanied the JDK since incubation in JDK 19 (JEP 428) and JDK 20 (JEP 437), became a formal preview in JDK 21 (JEP 453), and has been repreviewed every version since then: JDK 22 (JEP 462), JDK 23 (JEP 480), JDK 24 (JEP 499), and, in Java 25, adds a fifth round with JEP 505. The JEP already lists a JEP 525 as a sixth preview for the next cycle, so anyone following the API knows it still isn't stable even after six rounds.

This matters for anyone maintaining a Java service with ExecutorService and Future scattered through the code: the API that promises to solve cancellation and thread leaks is still under the --enable-preview flag, and it has changed in meaningful ways from one round to the next. In this version, for example, StructuredTaskScope stopped being opened through a public constructor and switched to static factory methods like open(). Anyone who already had prototype code running on Java 24 needs to rewrite that part to compile on Java 25.

The Problem the API Targets: Concurrency Without Structure

JEP 505 starts from a classic example: a handle() method that launches two concurrent subtasks, findUser() and fetchOrder(), via ExecutorService.

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);
}

The problem isn't the code itself, it's what happens when something fails. If findUser() throws an exception, fetchOrder() keeps running in its own thread: that's a thread leak, in the JEP's literal definition. If the thread calling handle() is interrupted, the interruption doesn't propagate to the subtasks. And if findUser() takes a long time while fetchOrder() has already failed, handle() waits for user.get() to return before even knowing the other side broke, wasting time. None of these three situations is a logic bug: it's the absence of a parent-child relationship between the tasks, one that exists only in the head of whoever wrote the code, not at runtime.

What the Code Looks Like with StructuredTaskScope

The same logic, rewritten with the API proposed by JEP 505, looks like this:

java
Response handle() throws InterruptedException {
 try (var scope = StructuredTaskScope.open()) {
 Subtask<String> user = scope.fork(() -> findUser());
 Subtask<Integer> order = scope.fork(() -> fetchOrder());
 scope.join(); // joins the subtasks, propagating exceptions
 return new Response(user.get(), order.get());
 }
}

The workflow is always the same: open the scope with open(), launch subtasks with fork() (each one runs by default on a virtual thread), join everything with join(), and process the result before closing the scope, usually via try-with-resources. If one subtask fails, the other is automatically canceled (interrupted), without the manual try-finally that would be needed with Future::cancel. If the thread that owns the scope is interrupted before or during join(), all subtasks are canceled when the block is closed. It's cancellation by construction, not by team discipline.

What Changed in This Java 25 Round

The most visible change in the fifth preview is structural: StructuredTaskScope no longer has a public constructor, only static factory methods. The parameterless open() covers the common case (fail if any subtask fails, wait for all the others to finish successfully). For different policies, there's the open(Joiner) variant, which takes a Joiner object configuring the join policy; the JEP itself leaves open which other policies and results can be implemented this way, without detailing each one.

In short: swapping constructor for static factory isn't cosmetic. It isolates scope creation behind an interface (StructuredTaskScope is now a sealed interface), leaving room for the JDK to add behavior variations without breaking the public signature with each preview, even though the body of the code you write keeps changing from one version to the next.

Automatic Cancellation, but the Responsibility Is the Subtask's

The cancellation performed by StructuredTaskScope relies on thread interruption, the same mechanism Thread.interrupt() has always used. The JEP is explicit: subtasks that don't respond to interruption, because they block on a non-interruptible call, can delay the scope's closing indefinitely. The close() method always waits for the subtasks' threads to finish, even if the scope has already been canceled; execution doesn't continue past it. This means that swapping ExecutorService for StructuredTaskScope doesn't free the team from writing code that handles InterruptedException correctly, it just makes negligence more visible when the application hangs at scope closing.

Observability also changes category. A thread dump of an application using StructuredTaskScope shows findUser() and fetchOrder() as children of the scope that created them, instead of appearing loose with no apparent relationship to each other, as happens today with ExecutorService. For anyone debugging production via a thread dump at three in the morning, that's the difference between rebuilding the call tree by hand and getting it ready-made.

Can You Turn On --enable-preview in Production Now?

JEP 505 is explicit about being a preview API, disabled by default. To use it you need to compile with javac --release 25 --enable-preview Main.java, run with java --enable-preview Main, or start jshell --enable-preview to experiment. None of these commands is a secret, but the cost of turning on the flag in production is higher than it looks.

  • Signature instability: this round's own change (public constructor turning into a static factory) already broke code written against the JDK 24 preview. There's no guarantee the sixth preview, referenced in the JEP itself as JEP 525, won't repeat the pattern.
  • Build coupling: classes compiled with --enable-preview generally only run on the same JDK major version with the flag enabled, which complicates CI/CD pipelines and artifact distribution across teams with different JDKs.
  • Limited scope: the JEP itself makes clear that it isn't meant to replace ExecutorService or Future, nor become the definitive structured concurrency API. Simple fan-out/fan-in code like the example benefits; long-running worker pools, work queues, or scenarios without a clear parent-child relationship still require ExecutorService.

Pragmatic verdict: it's worth studying and prototyping StructuredTaskScope now, including in lab branches or low-risk internal services, because the mental model (task and subtasks confined to a lexical block) is the direction the OpenJDK team itself is converging on. But turning on --enable-preview in a service that serves real traffic, with an SLA and a release pipeline shared across multiple teams, is still betting that the sixth preview won't demand another rewrite. For that service, the safer path remains ExecutorService inside a try-with-resources, with explicit manual cancellation in catch blocks, until the API is finalized without the flag.

Source: OpenJDK, JEP 505: Structured Concurrency (Fifth Preview) (https://openjdk.org/jeps/505).

Translated from the Brazilian Portuguese original · Read the original