engnotes.dev
NotebookTopicsAbout

Subscribe

One email when a new post goes up. Nothing else.

one per post · no tracking · also on RSS

Site

  • Notebook
  • Topics
  • About
  • Contact

Topics

Project Loom9Structured Concurrency9Tail Latency & System Behavior4

Elsewhere

  • GitHub
  • X
  • LinkedIn
  • Email
engnotes.dev© 2026 Jagdish Salgotra · written on personal time. not on employer time.
PrivacyTermsCookies
blog/project-loom/part 9
Project Loom · Part 9 of 9

Migrating Project Loom Code from Java 21 to Java 25

The Loom migration from Java 21 to Java 25 is one real change plus two cleanups: Joiner-based scopes replace ShutdownOnFailure, scoped values lose the preview flag, and virtual threads stay exactly where you left them.

J
Jagdish Salgotra
2025-08-31·16 min read·~1,700 words

Series navigation

← Previous · Part 8Future Directions and Migration Planning
Code repositoryproject-loom
#project-loom
share
J

Written by

Jagdish Salgotra

Distributed systems, cloud-native architecture, and the JVM. mostly shipping, occasionally reading.

all posts

Keep reading · rest of the series

  • 2025-07-0615 min read
    Part 1
    Java Virtual Threads: Why They Matter for I/O Scalability
  • 2025-07-1315 min read
    Part 2
    Building Web Services with Virtual Threads
  • 2025-07-2028 min read
    Part 3
    Real-World Microservices
  • 2025-07-2725 min read
    Part 4
    Structured Concurrency in Practice
Was this article helpful? or email →
anonymous · no account needed

On this page

Reading progress

0 min of 16 · ~16 left

Ask the post

Any answer points back at the paragraph it came from.

The migration in this repository is small enough to learn from. That is useful because Project Loom itself is not one feature. Virtual threads, structured concurrency, and scoped values move through the JDK on different timelines, and treating them as one upgrade can hide the part that actually changed.

Virtual threads became final in Java 21 through JEP 444. Scoped values became final in Java 25 through JEP 506. Structured concurrency is still preview in Java 25 through JEP 505, and the preview API shape is the part that changed most in this codebase.

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.

That framing matters. OpenJDK has continued the structured-concurrency preview work beyond Java 25 through JEP 525, so this article is not a general promise that Java 25 makes all Loom code faster. It is a record of what changed in the companion repository, what still behaves the same, and where the code had to become more explicit.

What changed in this repository

The branch diff from feature/java-21 to the current main branch changed 15 Java or build files under pom.xml and src/main/java, with 190 insertions and 169 deletions. That is a migration, not a rewrite.

The pom.xml change is the quiet part: source and target moved from 21 to 25, while --enable-preview stayed because structured concurrency remains preview. Removing preview flags would still break the structured-concurrency examples.

The more important change is in structured scopes. The Java 21 branch used the old subclass model. Across src/main/java, the branch had 56 StructuredTaskScope.ShutdownOnFailure references, 7 StructuredTaskScope.ShutdownOnSuccess references, and 48 throwIfFailed calls. The current branch has 63 StructuredTaskScope.open references and 63 StructuredTaskScope.Joiner references.

That count explains the migration shape. The code did not move away from structured concurrency. It moved from built-in scope subclasses to explicit joiner policies.

The all-success migration

The most mechanical migration is the all-success case. In Java 21, code that wanted all children to complete successfully used ShutdownOnFailure and then remembered to call throwIfFailed after join.

java
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    var user = scope.fork(() -> userService.fetchUser(userId));
    var orders = scope.fork(() -> orderService.fetchOrders(userId));

    scope.join();
    scope.throwIfFailed();

    return new UserDashboard(user.get(), orders.get());
}

The current code uses StructuredTaskScope.open(...) with a joiner policy. In ScopedRequestHandler.java, the small runInScope helper now opens the scope with StructuredTaskScope.Joiner.awaitAllSuccessfulOrThrow():

java
try (var scope = StructuredTaskScope.open(
        StructuredTaskScope.Joiner.awaitAllSuccessfulOrThrow())) {
    var future = scope.fork(task);
    scope.join();
    return future.get();
}

The visible difference is small, but the review surface changes. In the Java 21 shape, join() waited and throwIfFailed() applied the failure policy. In the Java 25 shape used here, the policy is attached when the scope is opened, so the join() call follows that policy.

This is the best migration in the repository because the intended behavior is local. A reviewer can see the scope policy before reading the child tasks.

First success changed more

The first-success examples changed more than the all-success examples. In Java 21, ShutdownOnSuccess<T> encoded the policy and exposed result():

java
try (var scope = new StructuredTaskScope.ShutdownOnSuccess<String>()) {
    scope.fork(() -> cacheService.read(key));
    scope.fork(() -> databaseService.read(key));

    scope.join();
    return scope.result();
}

The current helper in ScopedRequestHandler.java uses Joiner.allUntil(...), waits until a subtask reaches SUCCESS, and then selects the successful result from the joined subtasks:

java
try (var scope = StructuredTaskScope.open(
        StructuredTaskScope.Joiner.<T>allUntil(s -> s.state() == Subtask.State.SUCCESS)
)) {
    for (Callable<T> task : tasks) {
        scope.fork(task);
    }

    Stream<Subtask<T>> results = scope.join();
    return results
        .filter(s -> s.state() == Subtask.State.SUCCESS)
        .findFirst()
        .map(Subtask::get)
        .orElseThrow(() -> new Exception("No successful result"));
}

That is more code than the Java 21 demo shape. It also makes the policy visible: the scope is allowed to stop waiting once any child succeeds, and the caller must decide how to choose the result from the completed subtasks.

This is one place where the migration is not only syntax. The code now has an explicit selection step, so tests should check not only that one request succeeds, but also that the slower sibling is not treated as the result after the first success has already satisfied the policy.

Timeout code stayed explicit

The Java 21 branch had four joinUntil call sites. The current branch has none. The timeout examples now use explicit deadlines or polling loops instead of a direct joinUntil call.

In ConcurrentServiceLayer.java, shortTimeoutExample() sets a 300ms deadline, forks a 500ms slow service and a 100ms fast service, waits for the scope, and then checks whether the deadline has already passed.

That is not a hard cancellation-at-deadline implementation. The smoke check makes the behavior visible:

EndpointResult
GET /timeout/short500 Internal Server Error, timed out after 508ms
GET /timeout/graceful200 OK, cache-service-ok in 56ms

The first row is the useful teaching point. The code enforces the deadline after the joined work returns, so the request fails after roughly the slow task duration, not exactly at 300ms. If the requirement is strict deadline cancellation, this wrapper is not enough.

The graceful timeout endpoint uses a first-success joiner instead. That is why it returns the cache result quickly while slower siblings are no longer needed for the response.

Scoped values were not the migration here

It is tempting to describe scoped values as a ScopedValue.runWhere(...) or ScopedValue.callWhere(...) migration. That is not what happened in this repository.

The Java 21 branch already used fluent ScopedValue.where(...) in ScopedValueExample.java, and the current branch still uses that style. The migration in ScopedValueExample.java is therefore about the structured scope API around scoped values, not about replacing runWhere or callWhere.

That distinction matters because scoped values are now final in Java 25, while structured concurrency is still preview. In the current run, the structured-scope part of the demo preserved context for all five subtasks:

text
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 plain nested virtual-thread part printed unknown for the child and grandchild context. That is not a scoped-values success case in this repository. The scoped-values example that works here is the structured-scope path, where the child tasks are forked inside the lexical scope.

What I ran

I rebuilt the current branch with OpenJDK 25.0.2 and Maven 3.9.12. The Maven compile passed and compiled 35 source files.

bash
mvn clean compile -DskipTests

The detailed reproduction steps for this article are in testing-and-benchmarking.md.

Then I ran the standalone examples that exercise the migrated API shapes.

CheckResult
StructuredExampleWithSuccessall three all-success services completed; first-success returned Service-C result
AdvancedStructuredPatternsconditional cancellation returned results [success, error], cancelled task indices [2, 3], and finished in 410ms
AdvancedStructuredPatternsprogressive results completed 4/4 tasks in 262ms
AdvancedStructuredPatternsadaptive batching completed 20 tasks; batch size stayed at 5 with batches between 288ms and 304ms
AdvancedStructuredPatternsbulkhead run completed 2 critical results and 3 normal results
ProgressiveHierarchicalDemo9 total executions, 9 successful, average duration 444.8ms
ScopedValueExamplestructured subtasks saw test-user; plain nested virtual-thread context printed unknown

The adaptive result is intentionally modest. The controller did not cross its thresholds in this run, so it did not resize the batch. To see the increase branch, reduce task delays below the lower threshold; to see the decrease branch, push them above the upper threshold.

I also started the two Java 25 services used by the later examples and ran focused endpoint checks.

EndpointResult
GET /structured/aggregate on port 8082200, four service calls completed in 207ms
GET /timeout/graceful on port 8082200, first successful result was cache-service-ok in 56ms
GET /async/race on port 8082200, hedge won in 195ms
GET /timeout/short on port 8082500, request timed out after 508ms
GET /services/aggregate on port 8085200, four services aggregated in 208ms, handler duration 214ms
GET /timed/operation?op=slow-task on port 8085200, slow operation completed in 1506ms
GET /cache/data?key=userdata on port 8085200, L1 cache returned in 15ms

Finally, I ran the same small aggregate load shape against both services:

EndpointLoadAverage latencyThroughputTotal requests
http://localhost:8082/structured/aggregate2 threads, 20 connections, 10s207.12ms95.16 req/sec960
http://localhost:8085/services/aggregate2 threads, 20 connections, 10s207.15ms95.16 req/sec960

Those numbers are not evidence that Java 25 is faster than Java 21. They are a sanity check that the migrated Java 25 code still behaves like the examples claim. With 20 concurrent clients and a roughly 200ms slow sibling in the aggregate, a ceiling near 100 requests per second is what you would expect. The measured 95.16 requests per second is close enough to confirm the endpoint shape.

What to test during this kind of migration

Migration tests should follow the policy, not just the syntax. All-success scopes need a failing sibling test because forgetting failure propagation was the old sharp edge. First-success scopes need sequence tests because the first success, the slower success, and the all-failed path are different policies. Timeout examples need elapsed-time checks because a post-join deadline check and a hard cancellation deadline are not the same behavior. Scoped-value tests should prove where context is actually visible, especially across structured subtasks versus plain child virtual threads.

Benchmarks have a narrower job. They should confirm that the migrated code still matches the intended request shape. If you want to claim Java 25 improves performance over Java 21, build and run the Java 21 branch under a Java 21 toolchain with the same machine, same command, same warm-up, and same load settings. This article does not make that comparison.

What this migration teaches

The important Java 25 change for this repository is not virtual threads. That API was already final in Java 21 and most virtual-thread code stayed quiet.

The important change is ownership policy. Java 21 examples often read like "open this special scope, fork children, join, then remember the extra policy call." The Java 25 examples read more like "open a scope with this policy, fork children, join according to the policy." That is a cleaner review model, even when the first-success case asks for a little more code.

The other lesson is that preview APIs are allowed to move. That is not a reason to avoid learning them, but it is a reason to keep learning code small, tested, and honest about which JDK it targets.

What comes next

This closes the Project Loom series in the companion repository. The useful takeaway is smaller than "upgrade and everything is better": virtual threads made blocking code cheaper to wait on, scoped values gave request context a lexical shape, and structured concurrency kept changing because the JDK is still working through the right ownership model.

For learning code, that is enough. Keep the branch history, keep the benchmark commands reproducible, and keep every claim tied to code you can run.

Sources

  • JEP 444: Virtual Threads
  • JEP 505: Structured Concurrency (Fifth Preview)
  • JEP 506: Scoped Values
  • JEP 525: Structured Concurrency (Sixth Preview)