Java 25 Scoped Values Tutorial with Examples

Introduction Applications often need to make some information available to all the code that handles a request - the current user, a transaction, a request ID for logging - without passing it as a parameter through every method in between. Since Java 1.2, the tool for this has been ThreadLocal. But ThreadLocal has problems: any code can change its value at any time, the value lives until someone remembers to call remove() - a common source of leaks and of data leaking between requests in thread pools - and child threads which inherit it get expensive copies. With virtual threadsClick to read: virtual threads, where an application may run millions of threads, these costs matter even more. Scoped values, incubated in Java 20, previewed from Java 21 to 24 and final in Java 25Click to read tutorial: Overview of New Features in Java 25 (JEP 506), are the modern alternative. This tutorial explains scoped values with examples - binding and reading a value, rebinding it, how they compare with ThreadLocal, and how they behave with threads. Binding and reading a scoped value A ScopedValue is usually a static final field. A value is bound to it for the duration of a method call with ScopedValue.where(key, value).run(...), and any code called from there - however deeply - reads it with get(). When run() returns, the binding is gone -
Sharing the current user with code called deep in a request
//ScopedValueDemo.java
public class ScopedValueDemo {
  static final ScopedValue<String> CURRENT_USER = ScopedValue.newInstance();

  // Framework code: binds the user for the duration of the request
  static void handleRequest(String user, Runnable handler) {
    ScopedValue.where(CURRENT_USER, user).run(handler);
  }

  // Application code: no user parameter anywhere
  static void showOrders() { audit("viewed orders"); }
  static void audit(String action) {
    System.out.println("[" + CURRENT_USER.get() + "] " + action);   // read the bound value
  }

  public static void main(String[] args) {
    handleRequest("tom", ScopedValueDemo::showOrders);
    handleRequest("harry", ScopedValueDemo::showOrders);

    System.out.println("Bound outside a request? " + CURRENT_USER.isBound());
    System.out.println("With a default: " + CURRENT_USER.orElse("anonymous"));
    try {
      CURRENT_USER.get();
    } catch (java.util.NoSuchElementException e) {
      System.out.println("get() when unbound -> NoSuchElementException");
    }
  }
}
 OUTPUT of the above code
[tom] viewed orders
[harry] viewed orders
Bound outside a request? false
With a default: anonymous
get() when unbound -> NoSuchElementException
Explanation of the code
  • ScopedValue.where(CURRENT_USER, user) creates a binding, and .run(handler) runs the handler with it. audit(), two calls down, reads the value without it being passed as a parameter. call() works like run() for code which returns a result.
  • The binding has a clear lifetime: exactly the duration of run(). There is no remove() to forget, so values cannot leak from one request into the next.
  • Outside any binding, isBound() is false, orElse() returns a default, and get() throws NoSuchElementException.
  • A scoped value is immutable within its scope: there is no set(), so no code in the call chain can change what code further up bound.
Rebinding in a nested scope Although a binding cannot be changed, code can rebind the same scoped value for the call it makes - for example to run part of a request with elevated rights. The new value is visible only inside the nested call, and the original binding is back when it returns -
A nested binding for part of the work
//RebindDemo.java
public class RebindDemo {
  static final ScopedValue<String> ROLE = ScopedValue.newInstance();

  static void log(String step) { System.out.println(step + " as " + ROLE.get()); }

  public static void main(String[] args) {
    ScopedValue.where(ROLE, "user").run(() -> {
      log("Read profile");
      ScopedValue.where(ROLE, "admin").run(() -> log("Write audit record"));   // nested rebinding
      log("Render page");                                                        // back to "user"
    });
  }
}
 OUTPUT of the above code
Read profile as user
Write audit record as admin
Render page as user
Scoped values and threads Each thread has its own bindings: a scoped value bound in one thread is not visible in another, just like a ThreadLocal. The program below binds a request ID in many virtual threads at once, and each sees only its own -
Independent bindings in concurrent virtual threads
//ThreadsDemo.java
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;

public class ThreadsDemo {
  static final ScopedValue<Integer> REQUEST_ID = ScopedValue.newInstance();

  static void work(int expected, AtomicInteger mismatches) throws InterruptedException {
    Thread.sleep(10);                                         // other threads run meanwhile
    if (REQUEST_ID.get() != expected) mismatches.incrementAndGet();
  }

  public static void main(String[] args) {
    AtomicInteger mismatches = new AtomicInteger();
    try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
      for (int i = 0; i < 10_000; i++) {
        int id = i;
        executor.submit(() -> ScopedValue.where(REQUEST_ID, id).call(() -> { work(id, mismatches); return null; }));
      }
    }
    System.out.println("10,000 concurrent requests, values seen by the wrong request: " + mismatches.get());
  }
}
 OUTPUT of the above code
10,000 concurrent requests, values seen by the wrong request: 0
Scoped values were designed to be inherited by child threads cheaply when work is split up with structured concurrency (StructuredTaskScope, still a preview API in Java 25): subtasks forked in a scope automatically see their parent's bindings, without copying. Since bindings are immutable, sharing them is safe.
Scoped values vs ThreadLocal
ThreadLocalScopedValue
MutabilityAny code can set() a new valueImmutable within a binding; only rebinding for a nested call
LifetimeUntil remove() or the thread ends - easy to leakExactly the duration of run() or call()
Child threadsInheritableThreadLocal copies valuesShared without copying in structured concurrency
Best forPer-thread caches of expensive objectsPassing context (user, transaction, request ID) down a call chain
ThreadLocal is not deprecated and remains the right tool when code genuinely needs a mutable, per-thread value. For one-way transmission of context from a caller to its callees, scoped values are simpler, safer and cheaper. Summary In this tutorial we saw how scoped values, final in Java 25, share immutable data with the methods a thread calls, without passing it as a parameter. We bound a value with ScopedValue.where().run() and read it deep in the call chain with get(), used isBound() and orElse() outside a binding, rebound a value for a nested call, and saw that each of 10,000 concurrent virtual threads sees only its own binding. Lastly, we compared scoped values with ThreadLocal. Next, read about compact object headersClick to read tutorial: Java 25 Compact Object Headers Explained.