Blog

Master Structured Concurrency In Java 25

make threads come home
Neon threads woven into one organized cable over a dark concurrency diagram

Old Java concurrency often looks like someone spilled threads on floor and called it architecture.

Java 25 continues the structured concurrency preview with JEP 505 and finalizes scoped values in JEP 506. Together they make concurrent work easier to reason about: subtasks start together, finish together, and context has clear lifetime.

The ExecutorService Mess

Classic fan-out code:

ExecutorService pool = Executors.newFixedThreadPool(10);
Future<User> user = pool.submit(() -> userClient.fetch(id));
Future<List<Offer>> offers = pool.submit(() -> offerClient.fetch(id));

return new Page(user.get(), offers.get());

Where does pool shut down? What happens if user fails and offers keeps running? How do you see relationship in thread dump? Who owns cancellation?

You can solve these with discipline. Structured concurrency makes discipline default shape.

Java 25 StructuredTaskScope

JEP 505 shows the Java 25 API using StructuredTaskScope.open(). It is preview API, so compile and run with preview enabled.

Response handle(String id) throws InterruptedException {
    try (var scope = StructuredTaskScope.open()) {
        Subtask<User> user = scope.fork(() -> userClient.fetch(id));
        Subtask<List<Offer>> offers = scope.fork(() -> offerClient.fetch(id));

        scope.join();
        return new Response(user.get(), offers.get());
    }
}

If one subtask fails, default policy fails scope and cancels the other. The lifetime is lexical. Threads do not leak into dark corner.

Official source: JEP 505 Structured Concurrency.

Scoped Values Beat ThreadLocal Abuse

ThreadLocal is mutable backpack. It is easy to forget cleanup, especially with pools.

Scoped values share immutable data for bounded execution scope. JEP 506 finalizes them in Java 25.

private static final ScopedValue<RequestContext> CONTEXT = ScopedValue.newInstance();

void serve(Request request) {
    RequestContext ctx = RequestContext.from(request);

    ScopedValue.where(CONTEXT, ctx).run(() -> handle(request));
}

String correlationId() {
    return CONTEXT.get().correlationId();
}

Subtasks created with structured concurrency can inherit scoped value bindings safely. Context lifetime is visible in code structure.

Official source: JEP 506 Scoped Values.

Why This Matters In Backend

Modern services fan out constantly: user, permissions, offers, limits, risk, preferences. Old style makes cancellation and error handling inconsistent.

Structured concurrency says: these calls are one unit of work. If unit fails, children stop. If request is cancelled, children stop. Observability shows hierarchy.

What To Avoid

Do not wrap structured concurrency inside random helper that hides scope lifetime. Do not use it for background jobs that intentionally outlive request. Do not ignore interruption. Subtasks must respond to cancellation.

Also remember preview status for JEP 505. Use intentionally, not accidentally.

Migration Pattern

Find request handlers with two or more independent I/O calls. Replace ad hoc futures with scope. Keep result composition after join(). Add tests around failure and timeout behavior.

Simple first. Fancy later.

Final Thought

Concurrency is hard because lifetime is hard. Java 25 gives better lifetime structure.

Use it. Or keep chasing leaked tasks through logs like it is 2012.