Dev & EngARTICLE

JDK 25 LTS brings final Scoped Values and Structured Concurrency in fifth preview

The September LTS establishes JEP 506 as the official successor to ThreadLocal and advances JEP 505 to its fifth preview round. See how to swap one for the other in a real service and where the migration still gets stuck.

JDK 25 LTS brings final Scoped Values and Structured Concurrency in fifth preview
Image: Bisneto Braga

JDK 25 reached general availability on September 16, 2025, and is the line's new LTS, according to OpenJDK's official release page. Among the 18 JEPs listed, two are of direct interest to anyone maintaining a Java service running in production today: JEP 506 (Scoped Values), which leaves preview and becomes a final API, and JEP 505 (Structured Concurrency), which reaches its fifth preview round. These aren't showcase features like Compact Object Headers (JEP 519) or Generational Shenandoah (JEP 521): they're changes that directly affect how concurrency is written in Java, and that's why they deserve a closer look, with before-and-after code.

Why ThreadLocal Became a Problem

ThreadLocal has existed since Java 1.2 and solved a real problem: holding context (authenticated user, transaction id, locale) without passing a parameter through every call layer. The problem showed up with virtual threads (Project Loom, stabilized in JDK 21): ThreadLocal was designed for a world with few platform threads, expensive and long-lived. In a service that now creates millions of virtual threads per day, each carrying its own ThreadLocal map, the memory cost and the leak risk (forgetting the remove() in the finally block) become real.

Scoped Values tackles exactly that. The core idea: the value is immutable throughout the entire execution scope, is shared with child threads without copying, and there's no set() API to mutate it afterward: only where().run() or where().call() exist, which set the value for the duration of a block.

Before Code: ThreadLocal in a Typical Service

A common pattern in services that carry request context through filters, controllers, and service layers:

java
private static final ThreadLocal<UserContext> CURRENT_USER = new ThreadLocal<>();

public void handleRequest(Request req) {
    CURRENT_USER.set(loadUser(req));
    try {
        processRequest(req);
    } finally {
        CURRENT_USER.remove();
    }
}

public void processRequest(Request req) {
    UserContext user = CURRENT_USER.get();
    // business logic using user
}

It works, but it has two well-known weaknesses: if someone forgets the remove() in the finally block, the value leaks into the next request handled by the same thread (serious in a pool of reused threads); and nothing stops a deeper layer from calling CURRENT_USER.set() again, silently mutating the context in the middle of processing.

After Code: Scoped Values

The same logic, rewritten with the API that JEP 506 made final:

java
private static final ScopedValue<UserContext> CURRENT_USER = ScopedValue.newInstance();

public void handleRequest(Request req) {
    ScopedValue.where(CURRENT_USER, loadUser(req))
               .run(() -> processRequest(req));
}

public void processRequest(Request req) {
    UserContext user = CURRENT_USER.get();
    // business logic using user
}

The gain isn't just cosmetic. The runtime knows, at compile time and at runtime, that CURRENT_USER only exists inside run(). There's no remove() to forget because there's no state to clean up: when run() ends, the binding disappears with it. And if a child thread is created within that scope (for example, via StructuredTaskScope, which happens to be the other JEP in this LTS), it inherits the same value without any copy cost.

The Trade-off That Immutability Imposes

The trade-off is harsh for legacy code: any piece that relied on changing the ThreadLocal midway through (a common pattern in authentication pipelines that enrich the context in stages) needs to be restructured so that the entire final value is already available before entering the where() scope. This is real migration friction, especially in codebases with years of middleware layers doing successive set() calls. A reasonable path to migrate gradually in a service already in production: keep the read API (get()) compatible behind its own facade, and swap the underlying implementation from ThreadLocal to ScopedValue section by section, starting with the modules where the context is set only once at the start of the request, the most common and lowest-risk case.

Structured Concurrency: Still Preview, and That Matters

JEP 505 arrives in JDK 25 at its fifth preview round, which is itself a signal: the StructuredTaskScope API is still being adjusted release after release since it first appeared as an incubator. That changes the practical recommendation: to experiment in a production service, using it requires --enable-preview on the JVM, which automatically restricts where and how it can be used. No team should put --enable-preview in a deploy serving real traffic without a clear rollback plan, because a preview API can change its signature in the next LTS with no compatibility notice.

The idea behind Structured Concurrency is to treat a group of concurrent tasks as a single unit, with a tied-together lifecycle: if one fails, the others are cancelled; if the scope closes, no child task survives beyond it. A sketch of the pattern, combined with Scoped Values to propagate context to subtasks:

java
try (var scope = StructuredTaskScope.open()) {
    var user = scope.fork(() -> fetchUser(id));
    var orders = scope.fork(() -> fetchOrders(id));
    scope.join();
    return new Response(user.get(), orders.get());
}

The practical value here is eliminating a classic bug class from manual ExecutorService usage: an orphan task that keeps running after the method that created it has already returned, consuming CPU and connections without anyone knowing. In a typical project, the recommended path would be to isolate this code in an experimental module, behind a feature flag, load-tested in staging before any consideration of production, and only migrate for real once the API leaves preview, which the numbering (fifth round) suggests will still take at least one or two more LTS releases.

What's Left Out of This Guide

This LTS also finalizes JEP 511 (Module Import Declarations) and JEP 512 (Compact Source Files), which change the experience of writing simple Java code (scripts, prototypes), but don't affect anyone who already has a service running with modules and a consolidated build. Worth following, but they don't compete in urgency with swapping ThreadLocal for Scoped Values, which is this release's most concrete behavioral change for existing production code.

The practical recommendation for anyone maintaining a Java service today: upgrading to JDK 25 is already worthwhile for LTS support alone (compatibility, long-term security patches); the migration from ThreadLocal to Scoped Values can and should start now, module by module, beginning with contexts that don't undergo incremental mutation; and Structured Concurrency stays reserved for isolated experimentation, with no --enable-preview on critical paths, until the API stabilizes.

Source 1: OpenJDK: JDK 25 Release Notes and JEP Index (https://openjdk.org/projects/jdk/25/)

Translated from the Brazilian Portuguese original · Read the original