Series navigation
Written by
Jagdish Salgotra
Distributed systems, cloud-native architecture, and the JVM. mostly shipping, occasionally reading.
Loom roadmap planning is mostly a timing problem: adopting preview features too early and waiting until they settle both ship nothing in the end.
Written by
Distributed systems, cloud-native architecture, and the JVM. mostly shipping, occasionally reading.
Note This part is forward-looking. It discusses evolving and preview APIs (for example, scoped values and structured concurrency evolution). Verify current status before production rollout.
The future of Java concurrency is not a slide full of features. It is a set of boundaries you can choose deliberately.
Virtual threads are already a stable foundation. Scoped values are now part of the Java 25 platform. Structured concurrency is still preview, and it has changed enough between Java 21 and Java 25 that the next article in this series exists only because migration details matter.
That is the planning lesson: use the stable part when it solves a current problem, isolate preview APIs where they earn their place, and test the behavior instead of trusting the feature name.
This article is learning material. The main branch now builds with OpenJDK 25.0.2 and uses the Java 25 preview structured-concurrency API, with the Java 21 version separately managed in the feature/java-21 branch. The measurements below were generated from the current checked-in Java 25 code. Virtual threads are final in Java 21; the preview flag is still used in this repository because other examples in the same build use preview structured-concurrency APIs.
The OpenJDK pages give a useful baseline. JEP 444 lists virtual threads as delivered in Java 21. JEP 506 lists scoped values as delivered in Java 25. JEP 505 lists structured concurrency as the Java 25 fifth preview, and JEP 525 lists the Java 26 sixth preview.
Those statuses should change how you plan code.
Virtual threads can be used as ordinary threads for blocking request code. Scoped values can be used for bounded context propagation. Structured concurrency is still worth learning, but it should be kept behind small helpers or narrow call sites because preview APIs can still move.
That is not a reason to wait for everything. Waiting for every concurrency API to become final means missing the stable piece that is already useful. It is also not a reason to spread preview APIs everywhere. The code should make the stability boundary visible.
For this pass, the local toolchain was OpenJDK 25.0.2 and Maven 3.9.12:
mvn clean compile -DskipTests
mvn dependency:build-classpath -Dmdep.outputFile=cp.txt
The build succeeded and compiled 35 source files.
Then I ran:
java --enable-preview -cp "$(cat cp.txt):target/classes" app.js.scoped.ScopedValueExample
java --enable-preview -cp "$(cat cp.txt):target/classes" app.js.reactive.VirtualThreadReactiveIntegration
java --enable-preview -cp "$(cat cp.txt):target/classes" app.js.structured.AdvancedStructuredPatterns
These are not capacity benchmarks. They are local checks that show how the examples behave after the Java 25 migration.
Thread-local context became common because passing context through every method is noisy. Virtual threads make that habit more expensive to think about, because one request may create many cheap threads and a thread-local value can look like ambient global state.
Scoped values give context a visible lifetime.
In ScopedValueExample.java, a request binds four values, then runs business logic inside that binding:
ScopedValue.where(USER_ID, userId)
.where(REQUEST_ID, requestId)
.where(CORRELATION_ID, "corr-" + requestId)
.where(TENANT_ID, "tenant-" + userId.hashCode() % 3)
.run(() -> {
try {
handleBusinessLogic();
} catch (Exception e) {
throw new RuntimeException(e);
}
});
The business logic opens a structured scope. The subtasks do not receive userId as an argument, but they can read the scoped value while they run inside the parent binding:
try (var scope = StructuredTaskScope.open(StructuredTaskScope.Joiner.awaitAllSuccessfulOrThrow())) {
var authTask = scope.fork(() -> {
logContext("Authenticating user");
Thread.sleep(100 + ThreadLocalRandom.current().nextInt(50));
return "Auth successful for " + getCurrentUserId();
});
var dataTask = scope.fork(() -> {
logContext("Fetching user data");
Thread.sleep(150 + ThreadLocalRandom.current().nextInt(50));
return "Data fetched for " + getCurrentUserId();
});
var auditTask = scope.fork(() -> {
logContext("Creating audit log");
Thread.sleep(80 + ThreadLocalRandom.current().nextInt(30));
return "Audit logged for " + getCurrentUserId();
});
scope.join();
}
The first part of the run showed three request contexts flowing through their structured child tasks:
[user-1] [req-1] [corr-req-1] [tenant-0] Starting business logic
[user-2] [req-2] [corr-req-2] [tenant--2] Starting business logic
[user-3] [req-3] [corr-req-3] [tenant--1] Starting business logic
[user-1] [req-1] [corr-req-1] [tenant-0] Business logic completed: Auth successful for user-1 | Data fetched for user-1 | Audit logged for user-1
[user-2] [req-2] [corr-req-2] [tenant--2] Business logic completed: Auth successful for user-2 | Data fetched for user-2 | Audit logged for user-2
[user-3] [req-3] [corr-req-3] [tenant--1] Business logic completed: Auth successful for user-3 | Data fetched for user-3 | Audit logged for user-3
The dedicated structured-concurrency test showed the same thing more directly:
[test-user] [test-req] [test-corr] [unknown] Starting parallel operations
[test-user] [test-req] [test-corr] [unknown] Executing task 0
[test-user] [test-req] [test-corr] [unknown] Executing task 1
Task 0 completed for test-user
Task 1 completed for test-user
Task 2 completed for test-user
Task 3 completed for test-user
Task 4 completed for test-user
The important word is "bounded." The value is available where the binding says it is available. It is not a global request bag.
The current demo proves that too, although the method name is optimistic. testScopedValueInheritance() starts plain virtual threads with Thread.startVirtualThread(...) inside a scoped-value binding. Those unstructured child threads printed unknown for the scoped values:
[parent-user] [parent-req] [unknown] [unknown] Parent context
[unknown] [unknown] [unknown] [unknown] Child context - inherited values
[unknown] [unknown] [unknown] [unknown] Grandchild context - inherited values
That output is the best teaching detail in this article. Scoped values are not an excuse to stop thinking about ownership. The structured scope carried the context in this example. The unstructured virtual-thread nesting did not.
The same demo ends with a small ThreadLocal versus ScopedValue loop:
ThreadLocal time: 18.14 ms
ScopedValue time: 18.59 ms
ScopedValue is 0.02x slower
Do not turn that into a performance claim. The code loops 100,000 times and compares ThreadLocal.set/get/remove with repeated ScopedValue.where(...).run(...) binding. It is not a realistic request-context benchmark, and the printed ratio is not a conventional speedup ratio.
The useful result is simpler: the run was effectively a tie on this machine, and the context-flow output above is more important than the micro-timing. Scoped values are interesting here because they make context lifetime explicit, not because this demo proves they are faster.
The repository also has VirtualThreadReactiveIntegration.java. The file name is bigger than what the demo proves, so the article needs a narrow claim.
The bridge example takes a stream, forks one virtual-thread task per item inside a structured scope, joins the scope, and reads the results:
try (var scope = StructuredTaskScope.open(StructuredTaskScope.Joiner.awaitAllSuccessfulOrThrow())) {
List<StructuredTaskScope.Subtask<R>> subtasks = new ArrayList<>();
stream.forEach(item -> {
subtasks.add(scope.fork(() -> processor.apply(item)));
});
scope.join();
for (var subtask : subtasks) {
results.add(subtask.get());
}
}
The run showed that the bridge processed five items correctly:
Processed 5 items: [DATA-1-PROCESSED, DATA-2-PROCESSED, DATA-3-PROCESSED, DATA-4-PROCESSED, DATA-5-PROCESSED]
The publisher section also showed the usual concurrent ordering issue. Ten published items arrived, but not in numeric order:
Received: Item-2
Received: Item-6
Received: Item-7
Received: Item-4
Received: Item-1
Received: Item-8
Received: Item-9
Received: Item-0
Received: Item-5
Received: Item-3
That is not wrong. It is a property of the demo. The item tasks sleep for random durations, and the subscriber sees completion order.
The backpressure section is more useful than the word "reactive." It uses an ArrayBlockingQueue with capacity five. The consumer sleeps for 100ms per item, while the producer tries to offer 20 items:
Consumed: 0 (Buffer size: 0)
Consumed: 1 (Buffer size: 4)
Consumed: 2 (Buffer size: 4)
Consumed: 3 (Buffer size: 4)
...
Consumed: 19 (Buffer size: 0)
That makes the boundary visible. The queue is the capacity policy. Virtual threads do not remove the need for that policy.
The performance section in VirtualThreadReactiveIntegration compares one serial loop with two concurrent versions. Each item sleeps for 10ms, and there are 1,000 items.
The run reported:
Traditional blocking: 11845ms
Virtual threads: 21ms (564.05x faster)
Structured concurrency: 15ms (789.67x faster)
Results: Traditional=1000, Virtual=1000, Structured=1000
This is real output, but the interpretation has to stay honest. The serial loop sleeps 1,000 times in sequence. The virtual-thread and structured versions start the sleeps concurrently. The result proves that this specific waiting loop parallelizes well. It does not prove that virtual threads are hundreds of times faster than a reactive framework.
The right lesson is design-shaped: if your current code is callback-heavy only because platform threads were expensive, virtual threads may let you bring the code back toward direct blocking style. If your current code needs stream semantics, backpressure, fan-in, or event-pipeline operators, those policies still need to exist somewhere.
The advanced-pattern run reinforces why preview APIs should stay contained. AdvancedStructuredPatterns.java exercises partial results, conditional cancellation, progressive completion, resource grouping, adaptive batching, and bulkheads.
The run produced:
Completed: 2/4 tasks
Results: [Quick result, Medium result]
Timed out: [2, 3]
Conditional Cancellation Results:
- Completed results: [success, error]
- Cancelled task indices: [2, 3]
- Was cancelled: true
- Reason: Cancellation condition met
- Execution time: 406 ms
Progressive Results Summary:
- Completion rate: 100.0% (4/4 tasks)
- Total execution time: 277 ms
- Results: [Result 1, Result 2, Result 3, Result 4]
- Errors: 0
Batch completed in 261ms, next batch size: 5
Batch completed in 260ms, next batch size: 5
Batch completed in 236ms, next batch size: 5
Batch completed in 282ms, next batch size: 5
Adaptive results: 20 tasks completed
Bulkhead results: Critical: [Critical-1, Critical-2], Normal: [Normal-1, Normal-2, Normal-3]
Those are policy demonstrations. The partial-results helper currently uses CompletableFuture internally, while other examples use StructuredTaskScope.open(...). The conditional-cancellation result records the policy decision, but its 406ms runtime is close to the 400ms slow branch, so it should not be described as early interruption. The adaptive batch size stayed at five because the task durations stayed in the middle band.
That is the future-proofing point. Do not hide preview APIs and policy decisions behind a large abstraction too early. Keep the helper small enough that a migration from Java 25 preview to Java 26 preview is reviewable. Keep the tests specific enough that a changed joiner, timeout behavior, or result shape is visible.
Part 7 ran the pinning demos with -Djdk.tracePinnedThreads=full and did not get pinned-thread stack traces. That result looks surprising if your mental model is Java 21 only.
JEP 491 explains why the pinning story changed. It was delivered in Java 24 and changed the JVM so virtual threads that block in synchronized methods and statements can release their underlying platform threads in nearly all cases. The same JEP says the old jdk.tracePinnedThreads property is no longer needed once that change is in place.
That does not mean locks no longer matter. It means the advice has to move with the JDK. In Java 21, long blocking inside synchronized was a much sharper virtual-thread concern. In Java 25, the better question is what the measured JVM and JFR output actually show.
This is why Part 8 should not be a roadmap prediction piece. The platform moved. The article should teach readers how to check what changed.
Plan around stability and ownership.
Use virtual threads where request code is mostly waiting and where direct blocking code is clearer than callback choreography. Keep capacity controls explicit with semaphores, bounded queues, client pools, or bulkheads. Virtual threads make waiting cheaper; they do not make dependencies unlimited.
Use scoped values for bounded, immutable request context. The repository example shows the good path through structured subtasks and the bad assumption through unstructured child virtual threads. That is exactly the distinction to preserve in application code.
Use structured concurrency where task lifetime and failure policy matter enough to justify a preview API. Keep it near the request orchestration layer or inside small helpers. Avoid spreading preview syntax across unrelated business logic.
Use reactive or stream-style code where the dataflow policy is the point. The bridge demo is useful as a migration sketch, but it is not a replacement for a real backpressure protocol.
And most importantly, measure the policy you think you wrote. If the cancellation demo says "cancelled" but the duration tracks the slowest branch, write that down. If a scoped-value inheritance demo prints unknown, write that down. If a benchmark is only serial sleep versus concurrent sleep, do not call it a framework comparison.
Future-facing code needs tests that make ownership and API boundaries visible. A scoped-value test should show where context is bound, where it is readable, and where it disappears. A structured-concurrency test should assert task outcomes, cancellation behavior, timeout behavior, and result shape, not only status 200. A bridge test should include ordering, error collection, and capacity behavior, because those are the places where migration code quietly changes semantics.
The JDK status also belongs in the review. Before upgrading structured-concurrency code, check the relevant JEP and count the call sites that use preview APIs. If a feature is final, use it directly where it simplifies the code. If a feature is preview, keep the blast radius small enough that the next migration is boring.
Part 9 is the migration bridge: what changed between the Java 21 preview API used in the original examples and the Java 25 preview API used by the current repository.