Java Scoped Values (Java 21+)

ScopedValue (preview in Java 20, finalized in Java 21 as JEP 446) is a new way to share immutable per-call-tree context (such as the current user, tenant ID, or request ID) without explicitly passing it through every method signature. Unlike ThreadLocal, a ScopedValue is immutable within its scope, automatically cleaned up when the scope exits, and inherited safely by child virtual threads.

Contents

Why Not ThreadLocal?

ThreadLocal has three well-known problems that ScopedValue is designed to fix.

Java
/*
  ThreadLocal problems:
  ─────────────────────────────────────────────────────────
  1. Memory leaks — values live as long as the thread,
     not as long as the logical operation. In thread pools
     a previous request's data can leak into a new one
     unless remove() is called explicitly.

  2. Mutability — any code can call set() at any time,
     making it hard to reason about what the current value is.

  3. Poor virtual-thread fit — ThreadLocal was designed for
     platform threads. With millions of virtual threads, the
     per-thread storage overhead becomes significant.
  ─────────────────────────────────────────────────────────
*/

Basic Usage

Declare a static ScopedValue constant, then bind a value for the duration of a specific Runnable or Callable using ScopedValue.where().run().

Java
import java.lang.ScopedValue;

public class RequestContext {

    // Declare as a static final constant — one per logical context value
    public static final ScopedValue<String> USER_ID    = ScopedValue.newInstance();
    public static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();
}

// Bind values at the entry point (e.g. a servlet filter or web handler)
ScopedValue.where(RequestContext.USER_ID,    "alice")
           .where(RequestContext.REQUEST_ID, "req-abc-123")
           .run(() -> {
               // All code called from here can access the scoped values
               orderService.processOrder(orderId);
           });
The binding is only visible within the run() (or call()) lambda and any methods it calls: directly or indirectly. When the lambda returns, the binding is automatically removed.

Reading the Value

Call .get() anywhere in the call stack to read the bound value. Use .isBound() to check whether a value is set before reading.

Java
public class OrderService {

    public void processOrder(String orderId) {
        // Read from any depth in the call stack
        String userId     = RequestContext.USER_ID.get();
        String requestId  = RequestContext.REQUEST_ID.get();

        System.out.printf("[%s] User %s processing order %s%n",
                          requestId, userId, orderId);

        auditLog.record(userId, orderId);
    }
}

public class AuditLog {

    public void record(String userId, String orderId) {
        // Or read directly from the scoped value — no parameters needed
        if (RequestContext.REQUEST_ID.isBound()) {
            String reqId = RequestContext.REQUEST_ID.get();
            System.out.println("Audit [" + reqId + "]: " + userId + " → " + orderId);
        }
    }
}

Rebinding Within a Scope

Inner ScopedValue.where().run() calls shadow the outer binding for their duration. The outer value is restored automatically when the inner scope exits.

Java
ScopedValue.where(RequestContext.USER_ID, "alice").run(() -> {

    System.out.println(RequestContext.USER_ID.get()); // alice

    // Shadow with a different value for a sub-operation
    ScopedValue.where(RequestContext.USER_ID, "system").run(() -> {
        System.out.println(RequestContext.USER_ID.get()); // system
        systemTask();
    });

    System.out.println(RequestContext.USER_ID.get()); // alice (restored)
});
Rebinding creates a new scope for the inner lambda only: it does not mutate the outer binding. Unlike ThreadLocal.set(), this change is automatically undone when the inner scope exits.

Inheritance with Virtual Threads

Child virtual threads started inside a ScopedValue scope automatically inherit the parent's bindings: a clean model compared to InheritableThreadLocal.

Java
ScopedValue.where(RequestContext.USER_ID, "alice").run(() -> {

    // Spawn a virtual thread — it inherits USER_ID = "alice"
    Thread.ofVirtual().start(() -> {
        String user = RequestContext.USER_ID.get(); // "alice"
        System.out.println("Virtual thread sees user: " + user);
    }).join();
});
Inheritance is read-only: the child thread sees the parent's value but cannot rebind it for the parent. Any rebinding in the child creates an isolated inner scope that does not affect the parent or sibling threads.

Integration with Structured Concurrency

ScopedValue works naturally with StructuredTaskScope: all forked subtasks inherit the enclosing scope's bindings.

Java
import java.util.concurrent.StructuredTaskScope;

ScopedValue.where(RequestContext.USER_ID, "alice").run(() -> {
    try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {

        var orderTask   = scope.fork(() -> fetchOrders(RequestContext.USER_ID.get()));
        var profileTask = scope.fork(() -> fetchProfile(RequestContext.USER_ID.get()));

        scope.join().throwIfFailed();

        System.out.println("Orders: "  + orderTask.get());
        System.out.println("Profile: " + profileTask.get());
    }
});

ScopedValue vs ThreadLocal

Use ScopedValue for new code, especially when virtual threads or structured concurrency are involved.

Java
/*
  ThreadLocal                       ScopedValue (Java 21+)
  ─────────────────────────────────────────────────────────────
  Mutable — any code can set()      Immutable within a scope
  Manual remove() required          Auto-cleaned at scope exit
  Lives as long as the thread       Lives only for the scope
  Memory per thread                 Memory per scope (cheaper)
  InheritableThreadLocal for child  Inherited by child threads
    threads (complex semantics)       automatically (clean)
  Works with platform threads       Designed for virtual threads
  ─────────────────────────────────────────────────────────────

  Migrate from ThreadLocal:
  • Replace static ThreadLocal<T> with static ScopedValue<T>
  • Replace set() + remove() calls with ScopedValue.where().run()
  • Replace get() calls with .get() (same API)
*/