ThreadLocal has quietly caused three kinds of pain for as long as Java has had it: anything with a reference to it can mutate it at any time from anywhere, nothing enforces when it gets cleared (thread-pool reuse turning into accidental data leakage between requests is a bug almost every Java team has shipped at least once), and every child thread that should see a parent’s value needs its own private copy of it. That third one used to be a rounding error. Once virtual threads make it normal to have hundreds of thousands of threads alive at once, it stops being one.
JEP 506, final in JDK 25, is Java’s answer: ScopedValue, an immutable, thread-confined binding that a method can share with its callees and with structured child tasks, and that’s guaranteed to be gone the moment the block that bound it exits. This post covers the API, the one assumption about it that will break your code silently if you carry it over from ThreadLocal, and a real, measured answer to whether the memory story is actually true — including a first measurement that turned out to be too noisy to trust, and what a second, more precise one showed instead.
Versions used in this post. JDK 25 (Temurin 25.0.4.1+1).ScopedValue(JEP 506) is fully final — no flag needed.StructuredTaskScope(JEP 505), which this post uses to demonstrate inheritance, is still preview on 25 and needs--enable-preview --release 25; it finalizes in a later release. Every code block links to a source file and every output block is quoted verbatim from a committed transcript, both in the javademos repository.
The API: bind, read, and it’s gone
A ScopedValue starts empty. You bind it for the extent of one block with where(...).run(...) (or .call(...) if the block needs to return a value), and anything that runs on that same thread while the block is active — including methods several calls deep, with no parameter threading it through — can read it with get(). Once the block returns, the binding is gone. Not cleared, not still there until someone remembers to remove it: gone, structurally, because the block that created it ended.
static final ScopedValue<String> USER = ScopedValue.newInstance();
ScopedValue.where(USER, "ankur").run(() -> {
USER.isBound(); // true
USER.get(); // "ankur" -- from this frame, or any method called from here
deepMethod(); // also sees "ankur", no parameter needed
});
USER.get(); // NoSuchElementException -- unbound outside the block
USER.orElse("anonymous"); // "anonymous" -- fallback instead of an exception
Source: ScopedValuesBasics.java. Output:
CHECK ok : isBound() is true inside where().run()
CHECK ok : get() returns "ankur" inside the binding
CHECK ok : a method several frames deep, given no parameter, still sees the binding
CHECK ok : isBound() is false again once run() has returned
CHECK ok : get() outside a binding throws NoSuchElementException, not null or a default value
CHECK ok : orElse(fallback) returns the fallback when unbound, no exception
CHECK ok : orElseThrow(supplier) throws exactly the supplied exception when unbound
A nested where() on the same ScopedValue rebinds it — shadows the outer value for the extent of the inner block only, then hands control back to the original binding, unchanged, once the inner block exits. This is the mechanism a request handler uses to say “everything called from here behaves as a different identity” without touching the outer caller’s binding at all — still in ScopedValuesBasics.java:
CHECK ok : outer binding: ankur
CHECK ok : inner binding shadows the outer one: support-bot
CHECK ok : a method called from inside the inner scope sees the REBOUND value, not the original
CHECK ok : outer binding is restored, unchanged, once the inner where() block exits: ankur
One paragraph of reference depth: orElseThrow(Supplier<X>) lets an unbound read fail with your own exception type instead of the generic NoSuchElementException, which matters if “this code path requires an authenticated user” needs to surface as a domain-specific error rather than a platform one.
- Going deeper: chapter 17 of the javademos docs covers multi-value binding chains and the exact rebinding semantics with a longer worked example.
The trap: inheritance only happens through StructuredTaskScope
Here is the mental model swap that will silently break code carried over from InheritableThreadLocal: a ScopedValue binding is not visible to just any child thread. It’s visible on the same thread, and it’s visible to a subtask forked from a StructuredTaskScope that was opened while the binding is active. A plain Thread.ofVirtual().start(...) — even called from directly inside the bound block — sees nothing.
ScopedValue.where(REQUEST_ID, "req-42").run(() -> {
Thread t = Thread.ofVirtual().start(() -> System.out.println(readOrReport()));
t.join(); // prints UNBOUND
try (var scope = StructuredTaskScope.open()) {
scope.fork(() -> { System.out.println(readOrReport()); return null; }); // prints req-42
scope.join();
}
});
Source: InheritanceRequiresStructuredTaskScope.java. Real output confirming both the negative and positive case:
Thread.ofVirtual().start(...) from inside the bound block: UNBOUND
CHECK ok : a plain virtual thread started from inside where().run() does NOT see the binding -- REQUEST_ID.get() threw NoSuchElementException
new Thread(...) from inside the bound block: UNBOUND
CHECK ok : a plain platform Thread doesn't see the binding either -- this isn't a virtual-thread-specific rule
StructuredTaskScope.fork(...) from inside the bound block: req-42
CHECK ok : a subtask forked from a StructuredTaskScope opened while REQUEST_ID is bound DOES see "req-42"
This is the one migration detail that fails silently. Swapping anExecutorService.submit(...)-based background task for one that reads aScopedValuecompiles cleanly and throws at runtime, loudly, the first time it runs — that at least gets caught. The dangerous version is code that only reads the value withorElse(fallback)instead ofget(): it compiles, runs, and quietly falls back to the default on every call from a plain thread, with no exception to notice. If you’re migrating request-scoped logging context, security principals, or tracing IDs offThreadLocal, audit every place a new thread gets created and make sure it goes throughStructuredTaskScope— or accept that it needs the value passed as a plain parameter instead.
- Going deeper: chapter 17 covers why: a
StructuredTaskScopesubtask carries a reference to the same binding snapshot as its parent, established at fork time, rather than inheriting anything through theThreadobject itself. - Related: Java Stream Gatherers covers
Gatherers.mapConcurrent, which also uses virtual threads internally but, notably, does not cancel siblings on failure the wayStructuredTaskScopedoes.
The memory claim, measured — and remeasured
JEP 506’s own motivation names “expensive inheritance” as one of ThreadLocal’s three structural problems: every child thread that inherits values has to allocate its own copy of all of them. A ScopedValue binding isn’t copied into a child at all — the previous section’s StructuredTaskScope subtask gets a reference to the same binding chain its parent already holds. The obvious way to check that is to measure it, so I did — twice, because the first attempt gave an answer I didn’t trust.
First attempt: total heap before and after
Park 500,000 subtasks (via StructuredTaskScope, since that’s the only mechanism that actually inherits a binding), each holding 5 bound values — once with ScopedValue, once with InheritableThreadLocal — and measure Runtime.totalMemory() - Runtime.freeMemory() immediately before and after every subtask confirms it has read its values, with three System.gc() passes in between to settle things down.
ScopedValue: 500000 live subtasks, 5 bound values each, heap delta = 531.8 MB (1115 bytes/thread)
ThreadLocal: 500000 live subtasks, 5 inherited values each, heap delta = 546.1 MB (1145 bytes/thread)
ThreadLocal used 1.0x the heap ScopedValue used, for holding the identical 5 values live on 500000 threads
Source: ScopedValueVsThreadLocalMemory.java. That’s a roughly 1.0x ratio — essentially no difference — and re-running it produces a different small number each time, occasionally favoring the wrong side entirely. That’s not a finding, it’s noise: the fixed cost of a VirtualThread object plus its StackChunk plus its StructuredTaskScopeImpl$SubtaskImpl runs well over 1,000 bytes per thread either way, and it swamps whatever the actual ThreadLocalMap difference is. A before/after subtraction across a multi-hundred-megabyte heap isn’t precise enough to isolate a signal that small.
A measurement that fits the JEP’s claim isn’t automatically the right one to trust. It would have been easy to run this once, see ThreadLocal come out slightly ahead, and print it as confirmation. Rerunning it a few times and watching the ratio wander — and once flip sign — is what caught that this method wasn’t measuring what it claimed to. The fix wasn’t a bigger sample; it was asking the JVM directly instead of inferring from a subtraction.
Second attempt: ask the JVM for an exact object count
jcmd <pid> GC.class_histogram lists the live instance count and byte total for every class currently on the heap. Instead of measuring the whole heap and subtracting, park 100,000 subtasks holding their 5 bindings, then ask the running JVM to count objects directly — no GC-timing sensitivity, no subtraction, no noise floor to fight.
$ jcmd <pid> GC.class_histogram (100000 subtasks parked, mode=sv, JDK 25)
2: 100000 16800000 java.lang.VirtualThread ([email protected])
9: 100000 2400000 java.util.concurrent.StructuredTaskScopeImpl$SubtaskImpl ([email protected])
128: 5 160 java.lang.ScopedValue$Carrier ([email protected])
$ jcmd <pid> GC.class_histogram (100000 subtasks parked, mode=tl, JDK 25)
2: 100000 16800000 java.lang.VirtualThread ([email protected])
3: 500005 16000160 java.lang.ThreadLocal$ThreadLocalMap$Entry ([email protected])
4: 100001 8000080 [Ljava.lang.ThreadLocal$ThreadLocalMap$Entry; ([email protected])
9: 100001 2400024 java.lang.ThreadLocal$ThreadLocalMap ([email protected])
12: 100000 2400000 java.util.concurrent.StructuredTaskScopeImpl$SubtaskImpl ([email protected])
(The jdk.internal.vm.StackChunk row is trimmed from both excerpts above: it’s present in the real transcripts, large, and irrelevant to this comparison since its size is machine-dependent stack-usage bookkeeping, not binding storage — see the full, untrimmed output linked below.)
Source: MemoryFootprintProbe.java, driven by scripts/scoped-values.sh. Read literally: with 100,000 subtasks alive, there are exactly 5 ScopedValue$Carrier objects on the entire heap — the same 5, shared by all 100,000, one per nested where() call, 160 bytes total. The ThreadLocal run has 100,001 ThreadLocalMap instances, 100,001 backing arrays, and 500,005 individual Entry objects — 26.4 MB, every byte of it a real per-thread copy of the same 5 values every other thread already has its own copy of.
| ScopedValue | InheritableThreadLocal | |
|---|---|---|
| Binding-storage objects (100,000 threads) | Carrier × 5 (160 bytes total) | ThreadLocalMap + array + Entry × ~700,000 (26.4 MB) |
| Shared or copied? | Shared — one reference per thread to the same chain | Copied — a private map per thread |
| Marginal cost per extra thread | ~0 bytes | ~264 bytes |
That 264 bytes/thread is small in isolation, but it’s pure waste that a straight ScopedValue migration removes entirely — and it scales with however many InheritableThreadLocals a real application actually has active (logging MDC, security context, tracing span, locale, tenant ID easily adds up to more than the 5 used here), because ThreadLocal inheritance copies all of them into every new thread whether that thread needs them or not. ScopedValue’s cost doesn’t move with that count at all — it’s always a handful of shared objects, regardless of how many threads reference them.
- Going deeper: chapter 17 has the full, unfiltered histogram output and the exact
jcmdinvocation used to capture it.
Should you migrate?
Migrate request-scoped, read-mostly context; keep ThreadLocal for anything that’s genuinely mutable per-thread state.ScopedValueis a strong fit for exactly the use cases that motivated it — security principals, request IDs, tracing context, tenant identifiers — values a thread receives once and reads many times without ever reassigning. It’s a poor fit for a per-thread cache or counter that code mutates in place;ScopedValuehas noset(), on purpose. And the inheritance trap above means a migration isn’t a find-and-replace: audit every thread-creation site first, not just every read site.
Further reading
- Companion repository: javademos, chapter 17 — Scoped values vs ThreadLocal
- Java Stream Gatherers (JEP 485) — the companion post, including
mapConcurrent‘s different (non-cancelling) failure behavior on virtual threads - Official: JEP 506: Scoped Values
- Official: JEP 505: Structured Concurrency (preview)
- Official Javadoc: java.lang.ScopedValue
No Comments yet!