Part 8 of 15

Scoped Values (JEP 446): Thread-Safe Context Without ThreadLocal

Preview Feature — Requires --enable-preview at compile and runtime.

The ThreadLocal Problem at Scale

ThreadLocal has been the standard way to pass context (request ID, user session, database transaction) implicitly through a call stack without threading it through every method signature. It works well with a few hundred platform threads. With virtual threads, it breaks down.

Problem 1 — Memory overhead with inheritance

When you create a child thread with InheritableThreadLocal, Java copies the thread-local map from parent to child:

static InheritableThreadLocal<UserContext> CONTEXT = new InheritableThreadLocal<>();

// Parent sets context
CONTEXT.set(new UserContext("alice", "req-123"));

// Child gets a COPY — not a reference
Thread child = new Thread(() -> {
    UserContext ctx = CONTEXT.get();  // independent copy
});
child.start();

With 1,000,000 virtual threads each inheriting context, you create 1,000,000 copies of the context object. For a UserContext object of 1 KB, that’s 1 GB of heap just for copies.

Problem 2 — Unbounded lifetime

ThreadLocal values live as long as the thread lives. A pooled platform thread that processes millions of requests will carry the thread-local value from request one forward unless explicitly cleared:

try {
    CONTEXT.set(new UserContext(request));
    handleRequest();
} finally {
    CONTEXT.remove();  // easy to forget — causes bugs, memory leaks
}

Problem 3 — Mutability

Any code on the call stack can call CONTEXT.set(newValue), silently replacing the context for all subsequent callers in the same thread. This side effect is invisible to callers.


ScopedValue — Immutable, Bounded, Inherited by Reference

// Declare a ScopedValue — similar to declaring a ThreadLocal
static final ScopedValue<UserContext> CURRENT_USER = ScopedValue.newInstance();

// Bind a value for the duration of a method call
void handleRequest(Request request) {
    UserContext ctx = new UserContext(request.userId(), request.traceId());

    ScopedValue.where(CURRENT_USER, ctx)
        .run(() -> {
            processOrder();         // can call CURRENT_USER.get()
            sendNotification();     // can call CURRENT_USER.get()
            updateAnalytics();      // can call CURRENT_USER.get()
        });

    // Binding automatically gone when run() returns — no remove() needed
}

// Deep in the call stack
void sendNotification() {
    UserContext ctx = CURRENT_USER.get();  // same object, no copy
    emailService.send(ctx.email(), "Order confirmed");
}

How Scoped Values Work

flowchart TD
    Bind["ScopedValue.where(CURRENT_USER, ctx).run(...)"]
    scope["Scope — bounded to run() lifetime"]
    M1["processOrder()\nCURRENT_USER.get() → ctx"]
    M2["sendNotification()\nCURRENT_USER.get() → ctx"]
    M3["updateAnalytics()\nCURRENT_USER.get() → ctx"]
    End["run() returns\nBinding gone automatically"]

    Bind --> scope --> M1 & M2 & M3 --> End

The binding is:

  • Immutable — no set() method; the value cannot be changed within the scope
  • Bounded — lives exactly as long as the run() or call() closure
  • Inherited by reference — child threads (forked inside the scope) share the same object, no copy

ScopedValue vs ThreadLocal

PropertyThreadLocalScopedValue
MutabilityMutable (has set())Immutable (no set())
LifetimeThread lifetime (until remove())Scope lifetime (bounded to run())
Child inheritanceCopies the valueShares same reference (no copy)
Memory at 1M virtual threads1M copies of value1 shared object
CleanupManual remove() in finallyAutomatic
Clarity of flowImplicit, mutableExplicit binding, side-effect-free

Inheritance with Structured Concurrency

Scoped values are automatically inherited by subtasks forked within a StructuredTaskScope:

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

void handleRequest(Request req) throws Exception {
    var context = new RequestContext(req.traceId(), req.userId());

    ScopedValue.where(CTX, context).run(() -> {
        try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {

            var orderTask = scope.fork(() -> {
                // Automatically inherits CTX binding — no copy
                log.info("Order fetch trace={}", CTX.get().traceId());
                return fetchOrder(CTX.get().userId());
            });

            var profileTask = scope.fork(() -> {
                log.info("Profile fetch trace={}", CTX.get().traceId());
                return fetchProfile(CTX.get().userId());
            });

            scope.join();
            scope.throwIfFailed();

            return new Response(orderTask.get(), profileTask.get());
        }
    });
}

All forked tasks see the same CTX object — no copying, no memory overhead.


Nested Scopes — Rebinding

A child scope can rebind a scoped value to a different value. The parent’s binding is restored when the child scope exits:

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

void processAsAdmin() {
    ScopedValue.where(ROLE, "USER").run(() -> {
        System.out.println(ROLE.get());  // "USER"

        ScopedValue.where(ROLE, "ADMIN").run(() -> {
            System.out.println(ROLE.get());  // "ADMIN" — rebind within scope
        });

        System.out.println(ROLE.get());  // "USER" — parent value restored
    });
}

Rebinding is useful for privilege escalation within a bounded operation — the escalated privilege cannot outlive the nested scope.


call() — Getting a Return Value

run() is for Runnable; call() is for Callable<T>:

String result = ScopedValue.where(CURRENT_USER, ctx)
    .call(() -> {
        return processAndReturnSummary();  // can throw checked exceptions
    });

Checking if a ScopedValue is Bound

if (CURRENT_USER.isBound()) {
    UserContext ctx = CURRENT_USER.get();
    // safe to use
} else {
    // ScopedValue not bound in this scope — handle gracefully
    log.warn("No user context bound");
}

// orElse / orElseThrow
UserContext ctx = CURRENT_USER.orElse(UserContext.ANONYMOUS);
UserContext ctx = CURRENT_USER.orElseThrow(() -> new IllegalStateException("No user context"));

Practical: Web Request Context

@Component
public class RequestContextFilter implements Filter {

    static final ScopedValue<WebRequestContext> REQUEST_CTX = ScopedValue.newInstance();

    @Override
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
            throws IOException, ServletException {

        HttpServletRequest httpReq = (HttpServletRequest) req;
        var ctx = new WebRequestContext(
            httpReq.getHeader("X-Trace-Id"),
            httpReq.getHeader("X-User-Id"),
            httpReq.getMethod(),
            httpReq.getRequestURI()
        );

        ScopedValue.where(REQUEST_CTX, ctx).run(() -> {
            try {
                chain.doFilter(req, res);
            } catch (Exception e) {
                throw new RuntimeException(e);
            }
        });
    }
}

// Access anywhere in the request handling chain
@Service
public class OrderService {
    public Order createOrder(OrderRequest req) {
        String traceId = RequestContextFilter.REQUEST_CTX.get().traceId();
        log.info("[{}] Creating order", traceId);
        // ...
    }
}

Enabling Preview Features

<compilerArgs>
    <arg>--enable-preview</arg>
</compilerArgs>
java --enable-preview MyApp

Key Takeaways

  • ScopedValue is the modern replacement for ThreadLocal — designed for virtual threads and structured concurrency
  • Bindings are immutable (no set()), bounded (tied to run() lifetime), and inherited by reference (no copying)
  • With 1,000,000 virtual threads, one ScopedValue binding = one object shared by all — vs. 1,000,000 ThreadLocal copies
  • Nested ScopedValue.where() rebinds the value for the inner scope; the outer value is restored automatically on exit
  • Forked tasks inside StructuredTaskScope automatically inherit all scoped value bindings from the parent
  • isBound(), orElse(), and orElseThrow() provide safe access without NullPointerException
  • Use call() when the scoped operation must return a value

Next: Generational ZGC (JEP 439) — how generational garbage collection delivers sub-millisecond GC pauses at any heap size.