With Java 21, the Java platform completely reinvented how threads work by introducing virtual threads alongside traditional platform threads.
Virtual threads let us create millions of concurrent tasks without worrying about creation time or memory usage. This makes it possible to handle vastly more requests than before, but it doesn’t solve every problem that once led developers to reactive programming.
In this article, we’ll explore how StructuredTaskScope and ScopedValue, two powerful features that complement virtual threads, help us write safer, faster, and more maintainable concurrent code.
Structured Task Scopes — the Missing Piece
Introduced as a preview in Java 21, StructuredTaskScope provides a structured way to manage concurrent tasks. The API remained stable through Java 24, but with Java 25 the Java team completely redesigned it. Simplifying its usage and improving performance. Let’s see what’s new and how to use it effectively.
Virtual Threads in Action
Virtual threads remove the need for most thread-pools.
Here’s a simple example in a Spring Boot controller:
@GetMapping("/talk/{speaker}")
Talk getTalk(@PathVariable String speaker) {
// ~1s
return restClient.retrieveTalk(speaker);
}
One incoming request triggers one outgoing call. Virtual threads handle this perfectly.
But what if we need to gather multiple pieces of data?
@GetMapping("/speaker/{speaker}")
Speaker getSpeaker(@PathVariable String speaker) {
// ~1s
final var talk = fakeRestClient.retrieveTalk(speaker);
// 3 x ~500ms
final var infosGoogle = fakeRestClient.retrieveGoogle(speaker);
final var infosLinkedin = fakeRestClient.retrieveLinkedin(speaker);
// may throw
final var infosFacebook = fakeRestClient.retrieveFacebook(speaker);
final var bestInfos = Stream.of(infosFacebook, infosGoogle, infosLinkedin)
.max(Comparator.comparingInt(info -> info.infoList().size()))
.orElseThrow();
return new Speaker(talk, bestInfos);
}
Sequential calls like these mean a total request time of around 2.5 seconds. And if one request fails, the entire call fails. We can do better.
Concurrent Calls with StructuredTaskScope
We want all requests to run concurrently, and we want to handle errors gracefully after all tasks complete. That’s where StructuredTaskScope comes in.
private Info getInfo(String speaker) throws InterruptedException {
try (var scope = StructuredTaskScope.open(Joiner.awaitAll())) {
var infosGoogle = scope.fork(() -> restClient.retrieveGoogle(speaker));
var infosLinkedin = scope.fork(() -> restClient.retrieveLinkedin(speaker));
var infosFacebook = scope.fork(() -> restClient.retrieveFacebook(speaker));
scope.join();
return Stream.of(infosGoogle, infosFacebook, infosLinkedin)
.filter(task -> task.state() == Subtask.State.SUCCESS)
.map(Subtask::get)
.max(Comparator.comparingInt(info -> info.infoList().size()))
.orElseThrow();
}
}
How it works
- (1) We open a new scope with
StructuredTaskScope.open(). Because scopes implementAutoCloseable, we use try-with-resources. - (2) Each call to
fork()starts a new virtual thread and returns aSubtask, similar to aFuture. - (3)
scope.join()waits for all subtasks to complete. - (4) We filter successful results, handle failures ourselves, and pick the best response.
With this approach, the total latency drops from ~2.5 seconds to ~1.5 seconds, since all calls run concurrently. By using the Joiner.awaitAll(), errors no longer abort the whole request unless we choose to. Joiners are a great tool to configure, how we want our scope to handle results.
Joiners — Controlling How Tasks Complete
The Joiner interface defines how subtasks are collected and what happens on failure.
Java ships several ready-made joiners:
| Joiner | Description |
|---|---|
Joiner.awaitAll() | Waits for all subtasks to finish. |
Joiner.allSuccessfulOrThrow() | Throws if any subtask fails. |
Joiner.anySuccessfulResultOrThrow() | Returns when one task succeeds. |
Joiner.allUntil(predicate) | Waits until the given condition is met. |
For most cases these built-ins are enough, but sometimes you need custom logic. For example if we want to select the most informative result while ignoring failures.
Writing a Custom Joiner
public class InfoJoiner implements Joiner<Info, Info> {
private final Collection<Info> results = new ConcurrentLinkedQueue<>();
private final Collection<Throwable> exceptions = new ConcurrentLinkedQueue<>();
@Override
public boolean onComplete(Subtask<? extends Info> subtask) {
if (subtask.state() == Subtask.State.SUCCESS){
results.add(subtask.get());
}
else if (subtask.state() == Subtask.State.FAILED) {
exceptions.add(subtask.exception());
}
return false; // never cancel
}
@Override
public Info result() {
if (results.isEmpty()) {
var rte = new RuntimeException("All subtasks failed");
exceptions.forEach(rte::addSuppressed);
throw rte;
}
return results.stream()
.max(Comparator.comparingInt(info -> info.infoList().size()))
.orElseThrow();
}
}
Let’s take a quick look into the two methods we are overriding here:
results()
This gets called when scope.join() gets called and is a great place to gather all the information and return the result to the caller. In our case we just check, if there are any successful subtasks in our results collection and if so, we get the one result with the most information. If there wasn’t any element in the list, we would just add all the Throwable objects from our exceptions list to a newly created RuntimeException and then throw that exception.
onComplete
onComplete will get called each time, a subtask has completed with any kind of result be it SUCCESS or FAILED. This is the perfect place to check the state of our subtask and either add the successful result to our results collection using subtask.get() or to add it to our exceptions-list by using subtask.exception(). One more interesting part of this method is the boolean return value. This will tell the scope if it should cancel the scope or not. In our case we never want to cancel the scope after a specific subtask has ended, so we always return false. Other default implementations like anySuccessfulResultOrThrow are using that functionality as they don’t want to wait for all subtasks to finish, but only need one successful result.
Now after implementing everything needed, we can use it in our controller:
private Info getInfo(String speaker) throws InterruptedException {
try (var scope = StructuredTaskScope.open(new InfoJoiner())) {
scope.fork(() -> restClient.retrieveGoogle(speaker));
scope.fork(() -> restClient.retrieveFacebook(speaker));
scope.fork(() -> restClient.retrieveLinkedin(speaker));
return scope.join();
}
}
The method is now concise and efficient. scope.join() returns the best result directly from our custom joiner.
Combining Concurrent Scopes
Because retrieving the talk and fetching speaker info are independent, we can run both in parallel:
@GetMapping("/speaker/{speaker}")
Speaker getSpeaker(@PathVariable String speaker) throws InterruptedException {
try (var scope = StructuredTaskScope.open()) {
var talk = scope.fork(() -> fakeRestClient.retrieveTalk(speaker));
var infos = scope.fork(() -> getInfo(speaker));
scope.join();
return new Speaker(talk.get(), infos.get());
}
}
Two tasks, two virtual threads, all running concurrently.
Why Structured Task Scopes Matter
Easy to create: It’s never been simpler to write concurrent Java code.
Predictable error handling: Decide whether to fail fast or collect results first.
Extensible: Build your own Joiner for custom aggregation logic.
Testable: Because execution is still synchronous from the caller’s point of view, standard unit tests work perfectly.
Scoped Values – Replacing ThreadLocal
Before structured task scopes, Java applications often relied on ThreadLocal to store request-specific context. But with multiple virtual threads per request, ThreadLocal becomes unreliable. Each virtual thread has its own isolated local state.
Enter ScopedValue.
From ThreadLocal to ScopedValue
Old approach:
public static final ThreadLocal<String> TL = new ThreadLocal<>();
public static void main() throws InterruptedException {
TL.set("my data");
try (var scope = StructuredTaskScope.open()) {
System.out.println("T1: " + TL.get()); // "my data"
scope.fork(() -> System.out.println("T2: " + TL.get())); // null
scope.join();
}
}
The child thread prints null because the ThreadLocal value isn’t inherited in the thread created by scope.fork.
New approach with ScopedValue:
public static final ScopedValue<String> SV = ScopedValue.newInstance();
public static void main() throws InterruptedException {
ScopedValue.where(SV, "my data").run(() -> {
try (var scope = StructuredTaskScope.open()) {
System.out.println(SV.get()); // "my data"
scope.fork(() -> System.out.println(SV.get())); // "my data"
scope.join();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
});
}
Now both threads print the same value.ScopedValue binds data to a scope, not a thread, ensuring all child tasks share the same context. In our case we are using run to start our context, as we don’t need the result of our lambda. We could also use call which will return the result of our lambda expression instead.
Why ScopedValue is Better
Inheritance: Child scopes automatically inherit values.
ScopedValue.where(Key1, "1").run(() ->
ScopedValue.where(Key2, "2").run(() ->
System.out.println(Key1.get() + Key2.get()) // prints "12"
)
);
Immutability: Scoped values can’t be changed within a scope. You can override them only in nested scopes, eliminating accidental mutations.
ScopedValue.where(Key1, "1").run(() -> {
Key1.set("2"); // compile error
ScopedValue.where(Key1, "2").run(...); // valid
});
If you use one-thread-per-request, ThreadLocal still works. But when adopting virtual threads and structured task scopes, you should migrate to ScopedValue for predictable behavior.
Final Thoughts
Java 25 delivers huge steps forward in concurrent programming.
While StructuredTaskScope is still a preview feature, its redesign in this release shows the platform’s commitment to clarity and correctness. The willingness to overhaul the API even after several iterations is a testament to the Java team’s dedication to getting it right.
With StructuredTaskScope and ScopedValue, Java’s approach to concurrency finally feels modern, intuitive, and safe. Once the API stabilizes, likely in Java 26, these tools will be indispensable for anyone building high-throughput applications on the JVM.