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
61 lines
4.2 KiB
Markdown
61 lines
4.2 KiB
Markdown
# 17. Scoped values vs ThreadLocal (JEP 506, final in JDK 25)
|
|
|
|
Prev: [16. Stream Gatherers](16-gatherers.md) · [Back to the README](../README.md)
|
|
|
|
`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](12-lanes-25-to-29.md)) needs
|
|
`--enable-preview --release 25`. Source: [`scoped-values/src/`](../scoped-values/src), transcripts
|
|
[99-basics](output/99-basics.txt), [99-inheritance](output/99-inheritance.txt),
|
|
[99-memory-heap-delta](output/99-memory-heap-delta.txt), [99-histogram-scopedvalue](output/99-histogram-scopedvalue.txt),
|
|
[99-histogram-threadlocal](output/99-histogram-threadlocal.txt).
|
|
|
|
## The API ([`ScopedValuesBasics.java`](../scoped-values/src/ScopedValuesBasics.java))
|
|
|
|
```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](output/99-basics.txt). It's a scope, not an assignment.
|
|
|
|
## Inheritance requires StructuredTaskScope -- this is the trap ([`InheritanceRequiresStructuredTaskScope.java`](../scoped-values/src/InheritanceRequiresStructuredTaskScope.java))
|
|
|
|
The natural assumption, coming from `InheritableThreadLocal`, is that a binding is visible to any child thread. It
|
|
is not. [99-inheritance.txt](output/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`](../scoped-values/src/ScopedValueVsThreadLocalMemory.java), [`MemoryFootprintProbe.java`](../scoped-values/src/MemoryFootprintProbe.java))
|
|
|
|
First attempt: total heap before/after parking N subtasks holding 5 bound values each
|
|
([99-memory-heap-delta.txt](output/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](output/99-histogram-scopedvalue.txt) vs
|
|
[99-histogram-threadlocal.txt](output/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](16-gatherers.md) · [Back to the README](../README.md)
|