JDK25 ScopedValue instead of ThreadLocal


Let’s start the discussion with ThreadLocal. ThreadLocal is a mechanism that allows you to associate state with a thread.

For example, instead of retrieving the userId in the controller and passing it through all layers until it reaches the final layer(usually the repository layer that holds the database connection), you can store the userId in a ThreadLocal and access it anywhere within the same thread.

with and without ThreadLocal in multi threaded environment
with and without ThreadLocal in multi threaded environment


The left side of the above diagram depicts how ThreadLocal is used to store user data for the entire lifetime of the business logic execution. 

The Spring Framework uses ThreadLocal to store security data related to authentication and authorisation. It is a mechanism to decouple the security aspects from the core business logic of the application.

Let's explore further on the Spring SecurityContext

Spring SecurityContextHolder

I have created a sample application to demonstrate how ThreadLocal is used by Spring’s SecurityContext. This application allows users to log in with a username and password. After logging in, the user can use the JWT token to access the Settings API, which is secured using Spring Security.

Curl : POST localhost:8080/auth/settings  sends a request to the secured endpoint to update settings


Filter : JwtAuthenticationFilter.java validates the token before it enters the controller and store the authentication status in the ThreadLocal. 

The line SecurityContext context = SecurityContextHolder.createEmptyContext(); creates a new context (to avoid race conditions across multiple threads) for the current thread/request, and the authentication details are set using context.setAuthentication(auth);. Finally, the SecurityContext is stored in a ThreadLocal using SecurityContextHolder.setContext(context).

SecurityConfig: This is responsible for checking each request and check if they are authenticated


.anyRequest().authenticated()); looks into the SecurityContextHolder (the ThreadLocal storage) to see if there is a valid Authentication object there.


The issues with ThreadLocal

1. Unconstrained mutability

Any code in the same thread can modify the values in ThreadLocal using set(). Any method could change the values, and you wouldn’t know where the change occurred.
        Spring advises always starting with a new context because Spring uses the Tomcat thread pool, and there could be threads that still have old values in the ThreadLocal. To avoid corrupted data, you should always start with a brand new context.

2. Unbounded lifetime

Data lives as long as the thread lives, so you need to manually clear the data after finishing the execution of the logic, especially in cases where you use thread pools.
        In Spring, the SecurityContextHolderFilter takes care of clearing the security context at the end of each request. Furthermore, to make it more secure, it is advised to create a new context each time since threads are reused from the pool in Tomcat.

private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
        throws ServletException, IOException {
    if (request.getAttribute(FILTER_APPLIED) != null) {
        chain.doFilter(request, response);
        return;
    }
    request.setAttribute(FILTER_APPLIED, Boolean.TRUE);
    Supplier<SecurityContext> deferredContext = this.securityContextRepository.loadDeferredContext(request);
    try {
        this.securityContextHolderStrategy.setDeferredContext(deferredContext);
        chain.doFilter(request, response);
    }
    finally {
        this.securityContextHolderStrategy.clearContext();
        request.removeAttribute(FILTER_APPLIED);
    }
}
Link to the original code in GitHub

The finally block get always executed for each request.

What happens if you start a new child thread in Spring (for example: new Thread(() -> { ... }).start())? In this case, you must clear the context yourself since the child thread is not monitored by the filter. If you don’t clear it and there are objects in the thread, this could cause a memory leak.

This GitHub issue shows how ThreadLocal could cause memory leaks: https://github.com/apache/logging-log4j2/issues/3819
(extracted from the github issue)
protected LogBuilder getLogBuilder(final Level level) {
      final SLF4JLogBuilder builder = logBuilder.get();
      return Constants.ENABLE_THREADLOCALS && !builder.isInUse()
              ? builder.reset(this, level)
              : new SLF4JLogBuilder(this, level);
}

final SLF4JLogBuilder builder = logBuilder.get(); stores the initial value in the ThreadLocal. That value is then ignored and is never cleared. When Constants.ENABLE_THREADLOCALS is false, the system assumes that ThreadLocals are disabled and therefore never clears the value in the ThreadLocal.

11-Jul-2025 15:29:48.796 SEVERE [Catalina-utility-5] org.apache.catalina.loader.WebappClassLoaderBase.checkThreadLocalMapForLeaks 
The web application [<app name>] created a ThreadLocal with key of type [java.lang.ThreadLocal.SuppliedThreadLocal] 
(value [java.lang.ThreadLocal$SuppliedThreadLocal@22437c61]) and a value of type 
[org.apache.logging.slf4j.SLF4JLogBuilder] (value [org.apache.logging.slf4j.SLF4JLogBuilder@2be61ed4]) 
but failed to remove it when the web application was stopped. 
Threads are going to be renewed over time to try and avoid a probable memory leak.

These are the logs from the application, showing that when the web application was stopped, the thread was not removed because it was still holding a reference to the object.

3. Expensive inheritance

The overhead of thread-local variables may be worse when using a large number of threads because thread-local variables of a parent thread can be inherited by child threads.

        In Spring, when an application wants threads spawned by a secure thread to assume the same security identity, this is achieved by using SecurityContextHolder.MODE_INHERITABLETHREADLOCAL. The SecurityContextHolder.MODE_GLOBAL strategy is for applications that want all threads to share the same context, such as Swing applications.

What is a better alternative to ThreadLocal?

With the introduction of virtual threads, which are short-lived, lightweight threads that can run on platform threads and also support ThreadLocal, the memory leak issues become less significant. However, when you have millions of virtual threads, ThreadLocal memory leaks can still become an issue.

So, what would be an ideal alternative to ThreadLocal for both virtual threads and traditional threads?
  • A solution that provides a way to maintain inheritable per-thread data for thousands or millions of virtual threads.
  • A solution that supports per-thread immutable variables that can be efficiently shared by child threads.
  • A solution that ensures the lifetime of these per-thread variables is bounded; any data shared via a per-thread variable should become unusable once the method that initially shared the data has finished.

Scoped Values

A scoped value is a container object that allows a data value to be safely and efficiently shared by a method with its direct and indirect callees within the same thread, and with child threads, without resorting to method parameters. [https://openjdk.org/jeps/506]

How does it work? Code calls ScopedValue.where, presenting a scoped value and the object to which it is to be bound. A chained call of the run method binds the scoped value, providing a copy that is specific to the current thread, and then runs the lambda expression passed as an argument. During the lifetime of the run call, the lambda expression, or any method called directly or indirectly from that expression, can read the scoped value via the value’s get method. After the run method finishes, the binding is destroyed.

This code shows how it works.
record Request(String apiKey) { }

record Response() {
    void write(String msg) {
        System.out.println("Response: " + msg);
    }
}

record FrameworkContext(String user) { }

record PersistedObject(String data) { }

class Database {
    PersistedObject readKey(String key, String user) {
        return new PersistedObject("Data for [" + key + "] accessed by user [" + user + "]");
    }
}

class Application {
    static void handle(Request request, Response response) {
        System.out.println("-> Application: Handling request...");
        Framework framework = new Framework();
        PersistedObject result = framework.readKey("secret_dashboard_data"); // (4)
        response.write(result.data());
    }
}

class Framework {
    private static final ScopedValue<FrameworkContext> CONTEXT = ScopedValue.newInstance(); // (1)

    void serve(Request request, Response response) {
        System.out.println("-> Framework: Preparing context...");
        var context = new FrameworkContext("Alice_Admin");

        ScopedValue.where(CONTEXT, context) // (2)
            .run(() -> Application.handle(request, response));
            
        // CONTEXT.get(); throws NoSuchElementException here (2)(a)
        System.out.println("-> Framework: Request finished.");
    }

    public PersistedObject readKey(String key) { // (4)(a)
        FrameworkContext context = CONTEXT.get(); // (3)
        Database db = new Database();
        return db.readKey(key, context.user());
    }
}

public class ScopedValueDemo {
    public static void main(String[] args) {
        Framework myFramework = new Framework();
        Request req = new Request("xyz-123-token");
        Response res = new Response();
        myFramework.serve(req, res);
    }
}

  • Check (1) in the code: Create a private static final ScopedValue so that it cannot be directly accessed by code in other classes.
  • Check (2) in the code: The scoped value (context) passed to run is bound to the corresponding object for the lifetime of the run call. The run method provides one-way sharing of data from the serve method to the readKey method. The scoped value passed to run is bound to the corresponding object for the lifetime of the run call, so CONTEXT.get() in any method called from run will read that value.
  • Check (2)(a) in the code: After the run block is done, CONTEXT is no longer available in the thread, and if you try to access it, it will throw a NoSuchElementException. That means manually clearing the context when using ThreadLocal is not required—it is cleared automatically.
  • Check (4) and (4)(a) in the code: User code can call Framework.readKey to read the value written by Framework.serve earlier in the thread. Framework.readKey can only be called within the run block since CONTEXT is bound to it. Because the CONTEXT field has private access, user code can only access it via a method in Framework. Since scoped values have no set methods, they cannot be overridden by user code. The value that was set is locked for the duration of the run block.

Even though there is no set method, callees might still need to use the same scoped value to communicate a different value to its own callees.

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

void foo() {
    where(X, "hello").run(() -> bar());
}

void bar() {
    System.out.println(X.get()); // prints hello
    where(X, "goodbye").run(() -> baz());
    System.out.println(X.get()); // prints hello
}

void baz() {
    System.out.println(X.get()); // prints goodbye
}

This code gets executed in the same thread, and scoped values are used to bind different values to X.
  • Level 1 (foo): The thread enters foo(). It pushes a frame onto the stack that says: "For anyone below me, X = 'hello'."
  • Level 2 (bar): The thread enters bar(). It looks up the stack, sees the foo frame, and prints "hello."
  • Level 3 (The Shadow): Inside bar(), you call where(X, "goodbye").run(). This pushes a new special frame onto the stack. It says: "For anyone below me, X = 'goodbye'."
  • Level 4 (baz): The thread enters baz(). It looks up, sees the "goodbye" frame first, and prints "goodbye."
  • Level 2 (bar): The thread enters bar() again. It looks up the stack, sees the foo frame, and prints "hello" again.
This nesting guarantees a bounded lifetime for sharing the new value.

Inheriting scoped values
How do scoped values behave when you create child threads within your main thread? Structured Concurrency

When you have an object in the scope that contains critical information for the execution of your program, you probably want that information to be propagated across your child threads as well.

@Override
public Response handle(Request request, Response response) {
    try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
        Supplier<UserInfo>   user   = scope.fork(() -> readUserInfo());  // (1)
        Supplier<List<Offer>> offers = scope.fork(() -> fetchOffers());   // (2)

        scope.join()            // Wait for both forks
             .throwIfFailed();  // Propagate first error if any

        return new Response(user.get(), offers.get());
    } catch (Exception ex) {
        reportError(response, ex);
        return null;
    }
}

This code executes readUserInfo() and fetchOffers() methods concurrently in two parallel virtual threads. readUserInfo and fetchOffers both can call readKey in the Framework which allows it to access the CONTEXT.

Why is ScopedValue better than ThreadLocal?
  • No unconstrained mutability: Scoped values have no set methods and cannot be overridden by user code. The value that is set is locked for the duration of the run method.
  • No unbounded lifetime: A scoped value is bound to the code block inside the run method, and when the code finishes executing, the context is not available outside the run method. Furthermore, StructuredTaskScope ensures that child threads are terminated, so the binding is destroyed before returning to the parent thread.
  • No expensive inheritance: StructuredTaskScope enables efficient sharing of the context with child threads without making copies.

As of now, based on my research, I couldn’t find evidence that the Spring Framework will fully switch to scoped values. However, I am sure they will make optimisations gradually.


The end!



Comments