Contents

Contents

Structured Concurrency and Scoped Values in Java

structured concurrency and scoperd valuer in java

The work on JEP-505 resulted in the introduction of structured concurrency in Java 25. In this article, updated in September 2025, we will delve into the concept of structured concurrency, compare it with the current API, and explore the problems it solves.

Join us on a journey through decades of Java evolution

What is structured concurrency

Structured concurrency means that all subtasks are bound to the scope of their parent task and cannot outlive it, just like a method call cannot last longer than the method that invoked it.

It makes it much easier to reason about concurrent code: no stray background threads continue running after the parent has finished, error handling is more predictable since failures can be managed in one place, and the overall system is simpler to observe and debug.

Check:

In practice, it prevents the typical pitfalls of uncontrolled concurrency, like thread leaks or tasks lingering indefinitely, often leading to fragile and hard-to-maintain systems.

In Java, structured concurrency can be used with virtual threads from Project Loom. Because virtual threads are extremely lightweight, creating new tasks is almost free, making this approach both practical and efficient even in highly concurrent applications.

Example: invoice generation with structured concurrency

To illustrate the issues with the current API, let’s consider a simple example: generating an invoice. We must fetch the issuer, the customer, and the list of items. Doing this sequentially on a single thread works, but it’s slow. A better approach would be to fetch the data in parallel.

Let’s first see how this looks with ExecutorService.

(Un)structured concurrency

Invoice generateInvoice() throws ExecutionException, InterruptedException {
    try (final var executor = Executors.newFixedThreadPool(3)) {
        Future<Issuer> issuer = executor.submit(this::findIssuer);
        Future<Customer> customer = executor.submit(this::findCustomer);
        Future<List<Item>> items = executor.submit(this::findItems);

        return new Invoice(issuer.get(), customer.get(), items.get());
    }
}

This approach brings with it a couple of problems:

  • When findCustomer takes longer to execute than findItems, and findItems throws an exception,
    code will be blocked at customer.get(), until the thread executing findCustomer ends. As a result, we will waste resources waiting for the subtask to finish.
  • If the thread calling generateInvoice() is interrupted, the subtasks in the executor are not interrupted and get leaked.
    They can even operate indefinitely without control.

Structured concurrency

Invoice generateInvoice() throws InterruptedException {
    try (var scope = StructuredTaskScope.open(
            StructuredTaskScope.Joiner.allSuccessfulOrThrow() // (1)
    )) {
        Subtask<Issuer> issuer = scope.fork(this::findIssuer);
        Subtask<Customer> customer = scope.fork(this::findCustomer);
        Subtask<List<Item>> items = scope.fork(this::findItems);

        scope.join(); // (2)

        return new Invoice(issuer.get(), customer.get(), items.get());
    }
}
  • (1): We create a new StructuredTaskScope using the factory method open() with the policy allSuccessfulOrThrow().
    If an exception is thrown by findIssuer, findCustomer, or findItems, the other subtasks will be canceled and the scope fails.
  • (2): Waits for all subtasks in scope to be finished and throws an exception on any failure.

The code above is free of the problems mentioned earlier:

  • When findCustomer takes longer to execute than findItems, but findItems throws an exception, the longer task will be canceled.
  • If the thread calling generateInvoice() is interrupted, cancellation is propagated to subtasks.
  • All subtasks will be completed before leaving the scope.

At first glance, not much has changed: instead of ExecutorService we use StructuredTaskScope, both implement AutoCloseable, so we can use try-with-resources. But the difference is important: ExecutorService usually uses platform threads, while StructuredTaskScope uses lightweight virtual threads from the Loom project. Also, ExecutorService returns Future<T>, while here we work with Subtask<T>.

Structured concurrency policies

A policy is a set of rules for how a StructuredTaskScope should behave when its subtasks succeed or fail.
In the new API, these are implemented as Joiners.

allSuccessfulOrThrow

If any subtask fails, the rest is canceled and the scope fails. It works well in our invoice example - all data must be present.

anySuccessfulResultOrThrow

Stops the remaining subtasks once one of them succeeds or throws an exception if all fail. It is useful when calling multiple sources for the same data, and only the fastest one matters. Used for a typical race condition case.

allUntil

Runs all subtasks until a condition is met, then cancels the rest.

Other policies

If needed, you can implement your own policy by writing a custom StructuredTaskScope.Joiner<T, R>.

Scoped values

Scoped values are a mechanism to share immutable data between methods and child threads in a simple and safe way. They are easier to reason about than ThreadLocal, and have lower cost, mainly when used together with virtual threads and structured concurrency.

The scoped values API is finalized in JDK 25 (JEP 506). One small change compared to previews: the orElse method no longer accepts null.

Example: request ID in logging

import java.util.concurrent.StructuredTaskScope;
import java.util.concurrent.StructuredTaskScope.Joiner;

import static java.lang.ScopedValue.where;

public class LoggerExample {

    private static final ScopedValue<String> REQUEST_ID =
            ScopedValue.newInstance();

    static void main() throws InterruptedException {
        try (var scope = StructuredTaskScope
                .open(Joiner.allSuccessfulOrThrow())) {
            scope.fork(() -> where(REQUEST_ID, "abc-123")
                    .run(Main::handleRequest));
            scope.fork(() -> where(REQUEST_ID, "xyz-789")
                    .run(Main::handleRequest));

            scope.join();
        }
    }

    static void handleRequest() {
        log("handleRequest: start");

        try (var scope = StructuredTaskScope
                .open(Joiner.allSuccessfulOrThrow())) {
            scope.fork(Main::queryDatabase);
            scope.fork(Main::callExternalService);
            scope.join();
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }

        log("handleRequest: done");
    }

    static void queryDatabase() {
        log("Querying database...");
    }

    static void callExternalService() {
        log("Calling external service...");
    }

    static void log(String message) {
        String rid = REQUEST_ID.isBound() ?
                REQUEST_ID.get() : "<unbound>";
        System.out.println("[" + rid + "] " + message);
    }
}
  1. We declare a scoped value REQUEST_ID to hold the request identifier.
  2. In main(), we fork two tasks, each binding REQUEST_ID to a different value (abc-123, xyz-789).
  3. In handleRequest, we create a structured scope and run subtasks in parallel; each subtask automatically sees the correct bound request ID.
  4. The log method reads REQUEST_ID dynamically, so every message is tagged with the proper request ID without passing it as an argument.

Example output:

[xyz-789] handleRequest: start
[abc-123] handleRequest: start
[abc-123] Querying database...
[xyz-789] Querying database...
[xyz-789] Calling external service...
[xyz-789] handleRequest: done
[abc-123] Calling external service...
[abc-123] handleRequest: done

This shows how scoped values let us pass context cleanly and efficiently through the call chain and into subtasks.

Summary

Structured concurrency and scoped values are a great addition to the Java standard API that simplify writing concurrent code.

  • Structured concurrency (JEP 505) lets us manage groups of subtasks as a single unit of work, with predictable cancellation and error handling.
  • Scoped values (JEP 506) provide an efficient and safe way to share immutable context data between methods and threads, without the problems of ThreadLocal.

Reviewed by: Sebastian Rabiej

Blog Comments powered by Disqus.