Modern Java

39. ScopedValue vs ThreadLocal

ScopedValue (Java 21+) is the modern, structured alternative to ThreadLocal โ€” immutable within a scope, works correctly with virtual threads, and automatically cleaned up.

Java 21+ (preview โ†’ finalized in later versions)

Explanation

ThreadLocal stores a value per thread, mutable at any time, and never automatically removed (requiring explicit remove() to avoid memory leaks in thread pools). It also doesn't compose well with virtual threads โ€” a virtual thread may run on many carrier threads, but ThreadLocal still works (the value follows the virtual thread); however, the inheritableThreadLocal semantics can be surprising.

ScopedValue (JEP 446) provides a value that is bound within a structured scope and is immutable โ€” it cannot be accidentally overwritten. It is automatically unbound when the scope exits. This makes it safe for structured concurrency's subtask trees.

Pattern: declare as a static final ScopedValue<T>, then bind with ScopedValue.where(KEY, value).run(() -> ...). Inside the runnable, KEY.get() returns the bound value. Subtasks (forked within the scope) inherit the binding.

Example

import java.util.concurrent.StructuredTaskScope;

public class ScopedValueDemo {
    // Declare as static final (like a ThreadLocal)
    static final ScopedValue<String> CURRENT_USER = ScopedValue.newInstance();
    static final ScopedValue<String> REQUEST_ID   = ScopedValue.newInstance();

    static void processRequest(String user, String reqId) throws Exception {
        // Bind values for the duration of this scope
        ScopedValue.where(CURRENT_USER, user)
                   .where(REQUEST_ID, reqId)
                   .run(() -> {
                       try { handleRequest(); }
                       catch (Exception e) { throw new RuntimeException(e); }
                   });
    }

    static void handleRequest() throws Exception {
        // Accessible anywhere in the call tree within the scope
        String user  = CURRENT_USER.get();
        String reqId = REQUEST_ID.get();
        System.out.printf("[%s] Handling request for user %s%n", reqId, user);

        // Works inside structured concurrency subtasks too
        try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
            scope.fork(() -> {
                System.out.printf("[%s] Subtask running as %s%n",
                    REQUEST_ID.get(), CURRENT_USER.get());
                return null;
            });
            scope.join().throwIfFailed();
        }
    }

    public static void main(String[] args) throws Exception {
        processRequest("alice", "req-001");
        processRequest("bob",   "req-002");

        // Outside the scope: isBound() returns false
        System.out.println("Bound outside scope: " + CURRENT_USER.isBound()); // false
    }
}

Sources