Part 7 of 12

Scoped Values (JEP 506): The Final ThreadLocal Replacement

The ThreadLocal Problem

ThreadLocal has been the standard way to pass contextual data through a call chain without polluting method signatures since Java 1.2. Every Java developer has seen it used for things like:

  • Web request context (user ID, correlation ID, tenant ID)
  • Database transaction binding
  • Security principal propagation
  • Logging MDC (Mapped Diagnostic Context)

Here is the classic pattern:

public class RequestContext {
    // ThreadLocal stores one value per thread
    private static final ThreadLocal<String> CURRENT_USER = new ThreadLocal<>();

    public static void set(String userId) {
        CURRENT_USER.set(userId);
    }

    public static String get() {
        return CURRENT_USER.get();
    }

    public static void clear() {
        CURRENT_USER.remove();  // MUST call this or you get memory leaks
    }
}

// In a servlet / filter
public void doFilter(HttpServletRequest req, HttpServletResponse res, FilterChain chain) {
    try {
        RequestContext.set(req.getHeader("X-User-ID"));
        chain.doFilter(req, res);
    } finally {
        RequestContext.clear();  // forget this and leak on thread pool reuse
    }
}

// Deep in a service layer — no need to pass userId as a parameter
public Order createOrder(Cart cart) {
    String userId = RequestContext.get();  // retrieved without being passed in
    return new Order(userId, cart.getItems());
}

Why ThreadLocal Breaks with Virtual Threads

Java 21 introduced Virtual Threads — lightweight threads that can number in the millions. ThreadLocal was designed for platform threads (pools of ~200 threads). With virtual threads:

  1. Memory explosion: Each ThreadLocal value is stored in the thread’s threadLocals map. With 100,000 virtual threads, a single ThreadLocal<byte[]> with a 1KB value uses 100MB just for that one context variable.

  2. Mutability is a bug magnet: ThreadLocal is mutable. Any code along the call chain can write to it. In concurrent code this causes subtle, hard-to-reproduce bugs.

  3. No inheritance model: Child threads don’t inherit ThreadLocal values from their parent (unless you use InheritableThreadLocal, which has its own problems with thread pools).

  4. Leak risk: Forgetting remove() leaks data when threads are pooled and reused.


ScopedValue: The Design

ScopedValue was designed from scratch for the virtual thread era. It has four key properties:

  1. Immutable within a scope — once bound, the value cannot be changed
  2. Automatically cleaned up — when the scope ends, the binding disappears
  3. Inherited by child threads — structured concurrency tasks automatically see the parent’s bindings
  4. Zero mutation — a child scope can shadow a value with a new binding, but never mutate the parent’s binding

Basic Usage

import java.lang.ScopedValue;

public class RequestContext {
    // Declare a ScopedValue — it's a carrier, not a container
    public static final ScopedValue<String> CURRENT_USER = ScopedValue.newInstance();
    public static final ScopedValue<String> CORRELATION_ID = ScopedValue.newInstance();
}

Binding and Running

// In a web filter / entry point
String userId = request.getHeader("X-User-ID");
String correlationId = UUID.randomUUID().toString();

ScopedValue.where(RequestContext.CURRENT_USER, userId)
           .where(RequestContext.CORRELATION_ID, correlationId)
           .run(() -> handleRequest(request, response));
// When run() returns, bindings are gone — no cleanup needed

Reading

// Deep in service layer
public Order createOrder(Cart cart) {
    // .get() throws NoSuchElementException if not bound — fail fast
    String userId = RequestContext.CURRENT_USER.get();
    String correlId = RequestContext.CORRELATION_ID.get();

    log.info("[{}] Creating order for user {}", correlId, userId);
    return new Order(userId, cart.getItems());
}

ThreadLocal vs. ScopedValue: Side-by-Side

// ─── ThreadLocal approach ───────────────────────────────────────────────────

private static final ThreadLocal<String> USER_TL = new ThreadLocal<>();

void handleWithThreadLocal(String userId, Runnable task) {
    USER_TL.set(userId);
    try {
        task.run();
    } finally {
        USER_TL.remove();   // manual cleanup required
    }
}

String getUserThreadLocal() {
    return USER_TL.get();   // returns null if not set — silent failure
}

// ─── ScopedValue approach ───────────────────────────────────────────────────

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

void handleWithScopedValue(String userId, Runnable task) {
    ScopedValue.where(USER_SV, userId).run(task);
    // No finally needed — scope cleanup is automatic
}

String getUserScopedValue() {
    return USER_SV.get();   // throws NoSuchElementException — fail fast, no silent null
}

Real-World Example: Multi-Tenant Web Application

import module java.base;

public class MultiTenantApp {

    public static final ScopedValue<String> TENANT_ID   = ScopedValue.newInstance();
    public static final ScopedValue<String> USER_ID     = ScopedValue.newInstance();
    public static final ScopedValue<String> REQUEST_ID  = ScopedValue.newInstance();

    // ── Entry point (simulate a web filter) ───────────────────────────────
    public void handleRequest(String tenantId, String userId) {
        String requestId = UUID.randomUUID().toString().substring(0, 8);

        ScopedValue.where(TENANT_ID,  tenantId)
                   .where(USER_ID,    userId)
                   .where(REQUEST_ID, requestId)
                   .run(this::processRequest);
    }

    private void processRequest() {
        log("Starting request");
        var items = fetchItems();
        var order = buildOrder(items);
        log("Order created: " + order);
    }

    // ── Service layer — no userId parameter needed ─────────────────────────
    private List<String> fetchItems() {
        log("Fetching items for tenant " + TENANT_ID.get());
        return List.of("Widget A", "Widget B", "Widget C");
    }

    private String buildOrder(List<String> items) {
        log("Building order for user " + USER_ID.get());
        return "ORDER-" + REQUEST_ID.get() + " [" + String.join(", ", items) + "]";
    }

    private void log(String message) {
        System.out.printf("[%s][tenant=%s][user=%s] %s%n",
            REQUEST_ID.get(), TENANT_ID.get(), USER_ID.get(), message);
    }

    public static void main(String[] args) {
        var app = new MultiTenantApp();
        // Simulate two concurrent requests for different tenants
        Thread.ofVirtual().start(() -> app.handleRequest("acme-corp", "alice"));
        Thread.ofVirtual().start(() -> app.handleRequest("globex",    "bob"));
    }
}

Output (order may vary):

[a3f1b2c4][tenant=acme-corp][user=alice] Starting request
[d7e9f0a1][tenant=globex][user=bob] Starting request
[a3f1b2c4][tenant=acme-corp][user=alice] Fetching items for tenant acme-corp
[d7e9f0a1][tenant=globex][user=bob] Fetching items for tenant globex
[a3f1b2c4][tenant=acme-corp][user=alice] Building order for user alice
[d7e9f0a1][tenant=globex][user=bob] Building order for user bob
[a3f1b2c4][tenant=acme-corp][user=alice] Order created: ORDER-a3f1b2c4 [Widget A, Widget B, Widget C]
[d7e9f0a1][tenant=globex][user=bob] Order created: ORDER-d7e9f0a1 [Widget A, Widget B, Widget C]

Each virtual thread has its own isolated binding. No cross-contamination between tenants.


Rebinding (Shadowing) a ScopedValue

You cannot mutate a bound ScopedValue, but you can create a child scope with a new binding. The child scope sees the new value; the parent scope is unaffected:

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

void main() {
    ScopedValue.where(ROLE, "user").run(() -> {
        System.out.println("Outer role: " + ROLE.get());  // user

        // Temporarily elevate role for an admin operation
        ScopedValue.where(ROLE, "admin").run(() -> {
            System.out.println("Inner role: " + ROLE.get());  // admin
        });

        System.out.println("Outer role after: " + ROLE.get());  // user (unchanged)
    });
}

Output:

Outer role: user
Inner role: admin
Outer role after: user

This is safe by design — a callee can never corrupt the caller’s context.


ScopedValue with Structured Concurrency

Scoped Values integrate seamlessly with Structured Concurrency (JEP 505). Child tasks automatically inherit the parent’s bindings:

import java.util.concurrent.StructuredTaskScope;

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

void processParallel() throws Exception {
    ScopedValue.where(REQUEST_ID, "req-42").run(() -> {
        try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
            var task1 = scope.fork(() -> {
                // Child task SEES parent's REQUEST_ID binding automatically
                return "Task1[" + REQUEST_ID.get() + "] done";
            });
            var task2 = scope.fork(() -> {
                return "Task2[" + REQUEST_ID.get() + "] done";
            });

            scope.join().throwIfFailed();
            System.out.println(task1.get());
            System.out.println(task2.get());
        }
    });
}

Output:

Task1[req-42] done
Task2[req-42] done

With ThreadLocal and virtual threads, this context propagation required manual copying. With ScopedValue, it is automatic.


Migration Guide: ThreadLocal → ScopedValue

ThreadLocalScopedValue
ThreadLocal.withInitial(() -> x)ScopedValue.newInstance() (no default — use .orElse())
tl.set(value)ScopedValue.where(sv, value).run(...)
tl.get()sv.get() (throws if unbound) or sv.orElse(defaultValue)
tl.remove()Automatic — scope exit cleans up
Mutable mid-scopeNot possible — re-bind in a child scope
Child thread inheritsOnly with InheritableThreadLocal

Not every ThreadLocal can be replaced immediately — if your code reads and writes the same ThreadLocal from multiple points in a call chain (mutation pattern), you need a different approach (a mutable holder object inside a ScopedValue, or a redesign).


Summary

ScopedValue (JEP 506) is now final in Java 25. For any code that passes context through deep call chains — web request data, correlation IDs, security principals, tenant IDs — it is the correct tool. It is safer than ThreadLocal, scales to millions of virtual threads, and eliminates an entire class of leak and mutation bugs.


Next up: Structured Concurrency →