Java 25 features

Scoped Values vs ThreadLocal

Share immutable data down a call chain without parameters and without cleanup bugs.

ScopedValues.java GitHub
static final ScopedValue<String> USER = ScopedValue.newInstance();

void main() {
    ScopedValue.where(USER, "alice").run(() -> handle("first request"));
    ScopedValue.where(USER, "bob").run(() -> handle("second request"));
    IO.println("Outside any scope, bound? " + USER.isBound());
}

void handle(String request) {
    IO.println(USER.get() + " -> " + request);
    audit();
}

void audit() {
    // No parameter passing: the value is visible all the way down the call chain.
    IO.println("audit: request made by " + USER.get());
}

Run with JDK 25: java ScopedValues.java

A ScopedValue holds a value that is visible to a method and everything it calls, for a bounded time. When the scope ends, the binding disappears on its own.

ScopedValue vs ThreadLocal

ThreadLocalScopedValue
MutableYes (set)No, rebind in a nested scope instead
LifetimeUntil you call remove()Only inside the run or call block
Forgotten cleanup leaks dataYesNo

Output

alice -> first request
audit: request made by alice
bob -> second request
audit: request made by bob
Outside any scope, bound? false

Takeaway

If you pass a user, tenant or request id through five method signatures just so the sixth can read it, a ScopedValue is the cleaner tool. Keep using ThreadLocal only where you truly need a mutable per-thread slot.