Scoped Values

In large applications, we often need to pass “context” (like a User ID, Transaction ID, or Tenant ID) deeply through the call stack. Passing this as an explicit argument to every method (doSomething(Context ctx, Data data)) is tedious and clutters the API.

For 20 years, Java developers used ThreadLocal to solve this. It acts like a global variable that is unique to the current thread.

1. The Problem with ThreadLocal

While ThreadLocal works fine for Platform Threads, it is disastrous for Virtual Threads.

  1. Unbounded Lifetime: A ThreadLocal variable lives as long as the thread. Since threads in a pool are reused, you must manually call remove(). If you forget, you leak memory (and potentially security credentials) to the next request.
  2. Expensive Inheritance: When a thread spawns a child thread, InheritableThreadLocal must copy the parent’s map to the child. With Platform Threads (thousands), this is manageable. With Virtual Threads (millions), copying these maps kills performance.
  3. Mutability: Any code can call set() at any time, making data flow hard to reason about (“Who changed the User ID?”).

2. Enter Scoped Values

Scoped Values (ScopedValue<T>) are designed to replace ThreadLocal for Virtual Threads. They introduce the concept of Implicit Method Parameters.

Core Properties

  • Immutable: Once a value is bound to a scope, it cannot be changed. This eliminates side effects.
  • Bounded Lifetime: The value is only valid for the execution of a specific code block (the scope). When the block exits, the value is gone. No manual cleanup required.
  • Efficient Inheritance: Child threads (created via StructuredTaskScope) automatically share the parent’s Scoped Values. This is done via pointer sharing, not copying, making it zero-cost.

3. How it Works (Mental Model)

Imagine ScopedValue not as a map, but as a linked list in the stack.

  1. When you call ScopedValue.where(KEY, "A").run(...), Java pushes “A” onto the thread’s scope stack.
  2. When the run block finishes, Java pops “A” off the stack.
  3. Looking up a value involves checking the current scope.

4. Interactive: Scoped Stack Visualizer

See how ScopedValue bindings work like a stack. Notice that inner scopes “shadow” outer scopes, but only temporarily.

  1. Push a binding (e.g., User=”Alice”).
  2. Rebind inside that scope (e.g., User=”Bob”).
  3. Pop (Return) to restore the previous value.

Execution Stack (Scopes)

Base Context (Empty)

Current Visible Value

null
Scans stack top-down for nearest binding

5. Java Example: Basic Binding

import jdk.incubator.concurrent.ScopedValue;

public class Server {
    // 1. Define a static final ScopedValue (acts as the key)
    public static final ScopedValue<User> CURRENT_USER = ScopedValue.newInstance();

    public void serve(Request req) {
        User user = authenticate(req);

        // 2. Bind the value for the scope of 'process'
        //    Legacy: ThreadLocal.set(user); try { ... } finally { ThreadLocal.remove(); }
        ScopedValue.where(CURRENT_USER, user)
                   .run(() -> process(req));
    }

    void process(Request req) {
        // 3. Access the value anywhere deep in the stack
        //    Legacy: ThreadLocal.get();
        if (CURRENT_USER.isBound()) {
            User user = CURRENT_USER.get();
            System.out.println("Processing for " + user.name());
        }
    }
}

Rebinding (Nested Scopes)

You can nest scopes. The inner scope “shadows” the outer value, but only for that block.

ScopedValue.where(CURRENT_USER, "Alice").run(() -> {
    System.out.println(CURRENT_USER.get()); // Alice

    // Rebind for a specific sub-operation
    ScopedValue.where(CURRENT_USER, "Bob").run(() -> {
        System.out.println(CURRENT_USER.get()); // Bob
    });

    System.out.println(CURRENT_USER.get()); // Alice (Restored automatically)
});

6. Comparison: Java vs Go

Go uses context.Context to pass request-scoped values. Java uses ScopedValue.

Java (ScopedValue)

// Implicit: No need to pass 'ctx' argument
ScopedValue.where(KEY, "val").run(() -> {
    doWork(); // internal access via KEY.get()
});

Go (context.Context)

// Explicit: Must pass 'ctx' to every function
ctx := context.WithValue(parentCtx, key, "val")
doWork(ctx) // function must accept (ctx context.Context)

Java’s approach is cleaner for existing codebases (no need to refactor every method signature to add ctx), while Go’s approach is more explicit.

7. Key Takeaways

  1. Use ScopedValue for implicit parameters (User, Tenant, Transaction).
  2. Avoid ThreadLocal in Virtual Threads (memory leaks, expense).
  3. Immutable: Values cannot be changed within a scope, preventing “spaghetti data”.
  4. Structured: The scope lifetime is strictly bound to the syntax block.