Files
javademos/docs/17-scoped-values-migration.md
T
Claude 6b5a918278 Add two standalone modules: Stream Gatherers (JEP 485) and Scoped Values vs ThreadLocal (JEP 506)
gatherers/: windowFixed, windowSliding, fold, scan, mapConcurrent, a custom
Gatherer (dedupeConsecutive, takeUntil), and a Collector-vs-Gatherer comparison.
Verified: windowSliding emits one short window on a too-short stream rather than
shrinking to empty like windowFixed; mapConcurrent waits for in-flight siblings
to finish (~901ms) rather than cancelling them when one mapper throws.

scoped-values/: ScopedValue API and rebinding, why plain threads (virtual or
platform) do not inherit a binding while a StructuredTaskScope subtask does,
and the memory cost vs InheritableThreadLocal measured two ways -- a noisy
heap-delta first pass, then a precise jcmd GC.class_histogram object census
(5 shared Carrier objects vs ~700,000 copied ThreadLocalMap/Entry objects for
100,000 threads).

Companion code for the ankurm.com posts "Java Stream Gatherers (JEP 485)" and
"Scoped Values vs ThreadLocal in Java 25: Migration Guide with Virtual Threads".

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01FtpJvZfg4nvLvtzgJTDWpB
2026-09-24 04:27:12 +00:00

4.2 KiB
Raw Blame History

17. Scoped values vs ThreadLocal (JEP 506, final in JDK 25)

Prev: 16. Stream Gatherers · Back to the README

ScopedValue itself needs no --enable-preview on 25+ -- JEP 506 finalized in JDK 25. Every file that also touches StructuredTaskScope (JEP 505, still preview on 25 -- see 12. Version lanes) needs --enable-preview --release 25. Source: scoped-values/src/, transcripts 99-basics, 99-inheritance, 99-memory-heap-delta, 99-histogram-scopedvalue, 99-histogram-threadlocal.

The API (ScopedValuesBasics.java)

static final ScopedValue<String> USER = ScopedValue.newInstance();
ScopedValue.where(USER, "ankur").run(() -> {
    USER.get();           // "ankur", from any method called on this thread while bound
    USER.isBound();        // true
});
USER.get();                 // NoSuchElementException -- unbound outside the block
USER.orElse("anonymous");   // fallback instead of an exception
  • Rebinding: a nested where() on the same ScopedValue shadows the outer binding for its own block only, then the outer binding comes back unchanged once the inner block exits -- confirmed in 99-basics.txt. It's a scope, not an assignment.

Inheritance requires StructuredTaskScope -- this is the trap (InheritanceRequiresStructuredTaskScope.java)

The natural assumption, coming from InheritableThreadLocal, is that a binding is visible to any child thread. It is not. 99-inheritance.txt: a plain Thread.ofVirtual().start(...) and a plain new Thread(...), both started from inside an active where().run() block, see the value as unbound (NoSuchElementException). Only a subtask fork()ed from a StructuredTaskScope opened while the binding is active sees it. This is the one migration detail that breaks code silently rather than loudly -- a straight ExecutorService.submit() swap-in for ThreadLocal-based code will compile fine and lose every binding.

Memory vs ThreadLocal, measured two ways (ScopedValueVsThreadLocalMemory.java, MemoryFootprintProbe.java)

First attempt: total heap before/after parking N subtasks holding 5 bound values each (99-memory-heap-delta.txt). At 500,000 subtasks the two scenarios come out within 1-2% of each other, and the sign flips between runs. That's noise, not a finding -- the fixed cost of a VirtualThread + its StackChunk + its StructuredTaskScopeImpl$SubtaskImpl (1000-2000+ bytes/thread either way) swamps the actual difference this test is trying to isolate.

Second attempt: jcmd <pid> GC.class_histogram against 100,000 parked subtasks, an exact object census instead of a before/after subtraction.

ScopedValue InheritableThreadLocal
Binding-storage objects ScopedValue$Carrier × 5 (160 bytes total) ThreadLocalMap × 100,001, Entry[] × 100,001, Entry × 500,005 (26.4 MB total)
Shared or per-thread? Shared -- all 100,000 subtasks reference the same 5 Carrier objects Copied -- every subtask gets its own map + array + 5 entries
Cost per additional thread ~0 bytes ~264 bytes

That's 99-histogram-scopedvalue.txt vs 99-histogram-threadlocal.txt, read literally: 5 Carrier instances for 100,000 threads, full stop, versus 100,001 ThreadLocalMap instances. This is JEP 506's "expensive inheritance" claim made concrete -- not a multiplier on the total heap, but a real, avoidable, per-thread allocation that simply doesn't happen with ScopedValue, because nothing is copied into the child in the first place.

Prev: 16. Stream Gatherers · Back to the README