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
This commit is contained in:
Claude
2026-09-24 04:27:12 +00:00
parent f59c1de96d
commit 6b5a918278
25 changed files with 1124 additions and 5 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
# 15. Build files: what the 21 to 25 upgrade does to Maven, Gradle and Lombok
Prev: [14. The 21 to 25 lane](14-lanes-21-to-25.md) &middot; [Back to the README](../README.md)
Prev: [14. The 21 to 25 lane](14-lanes-21-to-25.md) &middot; Next: [16. Stream Gatherers](16-gatherers.md)
The build half of the [upgrade guide](https://ankurm.com/java-21-to-25-upgrade-guide-ai-prompts/), run by `scripts/upgrade.sh`.
Everything below is a run on JDK 21, 23, 24, 25, 26 or 27; the transcripts are in `output/92` to `output/97`.
@@ -58,4 +58,4 @@ Even on the working versions the JVM prints `WARNING: A Java agent has been load
[96](output/96-jdeps-internals.txt) is `jdeps --jdk-internals` on the class from [`UnsafeWarning.java`](../lanes/src/UnsafeWarning.java): it names `sun.misc.Unsafe` before the JVM prints its run-time warning ([86](output/86-unsafe-and-jni.txt)).
Prev: [14. The 21 to 25 lane](14-lanes-21-to-25.md) &middot; [Back to the README](../README.md)
Prev: [14. The 21 to 25 lane](14-lanes-21-to-25.md) &middot; Next: [16. Stream Gatherers](16-gatherers.md)
+66
View File
@@ -0,0 +1,66 @@
# 16. Stream Gatherers (JEP 485, final since JDK 24)
Prev: [15. Build files](15-build-files.md) &middot; Next: [17. Scoped values vs ThreadLocal](17-scoped-values-migration.md)
Compiles and runs on JDK 25+ with **no** `--enable-preview` flag -- JEP 485 finalized in JDK 24 and shipped unchanged in 25.
Source: [`gatherers/src/`](../gatherers/src), transcripts [98-basics](output/98-basics.txt), [98-mapconcurrent](output/98-mapconcurrent.txt),
[98-custom-gatherer](output/98-custom-gatherer.txt), [98-collector-vs-gatherer](output/98-collector-vs-gatherer.txt).
## The five built-in gatherers ([`GathererBasics.java`](../gatherers/src/GathererBasics.java), [`MapConcurrentDemo.java`](../gatherers/src/MapConcurrentDemo.java))
```java
Stream.of(1,2,3,4,5,6,7).gather(Gatherers.windowFixed(3)).toList(); // [[1,2,3],[4,5,6],[7]]
Stream.of(1,2,3,4,5).gather(Gatherers.windowSliding(3)).toList(); // [[1,2,3],[2,3,4],[3,4,5]]
Stream.of(1,2,3,4).gather(Gatherers.fold(() -> "", (a,n) -> a+"["+n+"]")).findFirst(); // "[1][2][3][4]"
Stream.of(1,2,3,4,5).gather(Gatherers.scan(() -> 0, Integer::sum)).toList(); // [1,3,6,10,15]
Stream.of(1,2,3,4).gather(Gatherers.mapConcurrent(4, Worker::call)).toList(); // order preserved
```
* `windowFixed(n)` throws `IllegalArgumentException` for `n <= 0` ("'windowSize' must be greater than zero"), not a silent empty result.
* `windowSliding(n)` does **not** drop a too-short remainder the way `windowFixed` shrinks its last window -- on a
stream shorter than `n`, it still emits one window holding whatever is there (`[1,2]` for `windowSliding(3)` over
`[1,2]`), and only an empty *source* stream produces zero windows. Easy to get backwards if you assume it mirrors
`windowFixed`'s "shrink the leftover" rule.
* `fold` and `scan` share a signature (`Supplier<R>`, `BiFunction<R,T,R>`) but not a shape: `fold` emits exactly one
final value (a stream of size 1), `scan` emits every intermediate accumulation. Neither has a combiner, so neither
runs in parallel -- both are sequential-only by design, which is what lets `R` differ from `T` in the first place
(`Stream.reduce` can't do that safely in parallel without a combiner).
* `mapConcurrent(max, fn)` runs each element's mapper on its **own** virtual thread (confirmed via
`Thread.currentThread().isVirtual()`), bounded to `max` in flight at once, output in encounter order regardless of
completion order. **The one behavior worth isolating**: a mapper that throws does **not** cancel its still-running
siblings. [98-mapconcurrent.txt](output/98-mapconcurrent.txt) has element 3 throw immediately while elements
1/2/4/5 each sleep 900ms -- the exception doesn't surface until ~900ms later, once every already-started mapper has
finished. `mapConcurrent` is not `StructuredTaskScope`; there's no fail-fast cancellation here.
## Writing a custom Gatherer ([`CustomGatherer.java`](../gatherers/src/CustomGatherer.java))
`Gatherer<T,A,R>` is four composable pieces -- initializer (private state `A`), integrator (per element: touch state,
push 0+ `R` downstream, return whether to keep pulling), combiner (parallel merge, omit for sequential-only), finisher
(flush on exhaustion). `Gatherer.ofSequential(...)` skips the combiner entirely.
```java
static <T> Gatherer<T, ?, T> dedupeConsecutive() {
return Gatherer.ofSequential(() -> new ArrayList<Object>(List.of()), (state, element, downstream) -> {
boolean changed = state.isEmpty() || !state.get(0).equals(element);
if (changed) { if (state.isEmpty()) state.add(element); else state.set(0, element); return downstream.push(element); }
return true;
});
}
```
`takeUntil` in the same file is the short-circuiting case a `Collector` structurally cannot express: the integrator
returns `false` once the stop condition is met, and upstream is never pulled again --
[98-custom-gatherer.txt](output/98-custom-gatherer.txt) proves it with a `peek()` upstream of the gather that only
ever sees the 3 elements actually consumed, not all 6 in the source.
## Collector vs Gatherer ([`CollectorVsGatherer.java`](../gatherers/src/CollectorVsGatherer.java))
A `Collector` always reduces to exactly one terminal value and can only sit at the end of a pipeline. A `Gatherer` is
an intermediate op -- it returns `Stream<R>`, so a gather can be followed by `filter`, `map`, another `gather`, or a
`collect`. `groupingBy`/`summingDouble` for an order-totals `Map` is still the right call in
[98-collector-vs-gatherer.txt](output/98-collector-vs-gatherer.txt): the answer really is one `Map`, and reaching for
`gather()` there would add ceremony with nothing to show for it. `scan` feeding straight into `filter`/`map` in the
same lazy pipeline is the case a `Collector` can't do without materializing an intermediate `List` and starting a
second stream.
Prev: [15. Build files](15-build-files.md) &middot; Next: [17. Scoped values vs ThreadLocal](17-scoped-values-migration.md)
+60
View File
@@ -0,0 +1,60 @@
# 17. Scoped values vs ThreadLocal (JEP 506, final in JDK 25)
Prev: [16. Stream Gatherers](16-gatherers.md) &middot; [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` &times; **5** (160 bytes total) | `ThreadLocalMap` &times; 100,001, `Entry[]` &times; 100,001, `Entry` &times; 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) &middot; [Back to the README](../README.md)
+18
View File
@@ -0,0 +1,18 @@
$ java GathererBasics (JDK 25)
windowFixed(3) of 1..7 = [[1, 2, 3], [4, 5, 6], [7]]
CHECK ok : windowFixed(3) groups into [1,2,3][4,5,6][7], last window is the short remainder
CHECK ok : windowFixed(0) throws IllegalArgumentException: 'windowSize' must be greater than zero
windowSliding(3) of 1..5 = [[1, 2, 3], [2, 3, 4], [3, 4, 5]]
CHECK ok : windowSliding(3) of 5 elements produces exactly 3 overlapping windows, each length 3
CHECK ok : windowSliding(3) on a 2-element stream still emits one short window: [[1, 2]]
CHECK ok : windowSliding(3) on an empty stream emits nothing
fold building a String: [1][2][3][4]
CHECK ok : fold("", acc+"[n]") over 1..4 builds "[1][2][3][4]" left to right
CHECK ok : fold's output stream has exactly one element, the final accumulation
scan running total of 1..5 = [1, 3, 6, 10, 15]
CHECK ok : scan(0, +) over 1..5 emits every prefix sum: [1,3,6,10,15]
fold(0,+) over 1..3 = [6]
scan(0,+) over 1..3 = [1, 3, 6]
CHECK ok : fold(0,+) over 1..3 emits just the final sum [6]
CHECK ok : scan(0,+) over 1..3 emits every partial sum [1,3,6]
exit=0
+9
View File
@@ -0,0 +1,9 @@
$ java CollectorVsGatherer (JDK 25)
Collectors.groupingBy totals = {amy=19.75, bo=23.99}
CHECK ok : amy's total via groupingBy/summingDouble is 19.75
CHECK ok : bo's total via groupingBy/summingDouble is 23.99
scan -> filter -> map, one pipeline = [total=30, total=50, total=75]
CHECK ok : scan feeding straight into filter/map in the same pipeline gives [total=30, total=50, total=75]
windowFixed(2) over 7 elements, then collect(counting()) = 4
CHECK ok : collect(counting()) after gather() proves the gather result is still a Stream you can collect further: 4 windows
exit=0
+7
View File
@@ -0,0 +1,7 @@
$ java CustomGatherer (JDK 25)
dedupeConsecutive of a,a,b,b,b,a,c,c = [a, b, a, c]
CHECK ok : consecutive runs collapse to [a, b, a, c]; the second 'a' survives because it isn't adjacent to the first
takeUntil(n == 3) result = [1, 2, 3], upstream elements actually pulled = [1, 2, 3]
CHECK ok : takeUntil includes the element that satisfies the stop condition: [1, 2, 3]
CHECK ok : the upstream peek() only saw 1, 2, 3 -- elements 4, 5, 6 were never pulled, this genuinely short-circuits
exit=0
+18
View File
@@ -0,0 +1,18 @@
$ java MapConcurrentDemo (JDK 25)
mapConcurrent(4, ...) results, completion order reversed: [10, 20, 30, 40]
CHECK ok : mapConcurrent output preserves encounter order [10,20,30,40] even though task 1 finishes last
mapConcurrent(2, ...) over 8 tasks, peak concurrent in-flight = 2
CHECK ok : all 8 tasks completed
CHECK ok : peak in-flight never exceeded maxConcurrency=2, measured peak=2
CHECK ok : the cap is actually reached, not just respected -- measured peak=2
VirtualThread[#38]/runnable@ForkJoinPool-1-worker-2 virtual=true
VirtualThread[#39]/runnable@ForkJoinPool-1-worker-2 virtual=true
VirtualThread[#40]/runnable@ForkJoinPool-1-worker-2 virtual=true
VirtualThread[#41]/runnable@ForkJoinPool-1-worker-2 virtual=true
VirtualThread[#42]/runnable@ForkJoinPool-1-worker-2 virtual=true
VirtualThread[#37]/runnable@ForkJoinPool-1-worker-1 virtual=true
CHECK ok : every mapConcurrent worker thread reports isVirtual() == true
CHECK ok : 6 elements produced 6 distinct virtual threads -- one per element, not a shared pool
mapConcurrent propagated: boom at element 3 after 901ms
CHECK ok : the failure only surfaced after 901ms -- mapConcurrent waited for the other 4 in-flight tasks, it did not cancel them
exit=0
+15
View File
@@ -0,0 +1,15 @@
$ java ScopedValuesBasics (JDK 25, no preview flag -- JEP 506 is final)
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 : orElse(fallback) returns the real value when bound
CHECK ok : orElseThrow(supplier) throws exactly the supplied exception when unbound
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
CHECK ok : call(...) returns whatever the lambda returns -- USER.get().length() == 5
exit=0
+14
View File
@@ -0,0 +1,14 @@
$ jcmd <pid> GC.class_histogram (100000 subtasks parked, mode=sv, JDK 25)
1: 100000 115102880 jdk.internal.vm.StackChunk ([email protected])
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])
175: 5 80 java.lang.InheritableThreadLocal ([email protected])
177: 5 80 java.lang.ScopedValue ([email protected])
203: 2 48 java.lang.ScopedValue$Snapshot ([email protected])
257: 1 32 java.util.concurrent.ThreadLocalRandom ([email protected])
281: 1 24 java.util.concurrent.StructuredTaskScopeImpl$SubtaskImpl$AltResult ([email protected])
292: 1 24 jdk.internal.vm.ScopedValueContainer$BindingsSnapshot ([email protected])
324: 1 16 java.lang.ThreadLocal ([email protected])
353: 1 16 java.util.concurrent.ThreadLocalRandom$Access$1 ([email protected])
Total 1133788 172079104
+15
View File
@@ -0,0 +1,15 @@
$ jcmd <pid> GC.class_histogram (100000 subtasks parked, mode=tl, JDK 25)
1: 100000 109386560 jdk.internal.vm.StackChunk ([email protected])
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])
177: 5 80 java.lang.InheritableThreadLocal ([email protected])
179: 5 80 java.lang.ScopedValue ([email protected])
258: 1 32 java.util.concurrent.ThreadLocalRandom ([email protected])
281: 1 24 java.util.concurrent.StructuredTaskScopeImpl$SubtaskImpl$AltResult ([email protected])
292: 1 24 jdk.internal.vm.ScopedValueContainer$BindingsSnapshot ([email protected])
324: 1 16 java.lang.ThreadLocal ([email protected])
353: 1 16 java.util.concurrent.ThreadLocalRandom$Access$1 ([email protected])
Total 1770749 179468208
+8
View File
@@ -0,0 +1,8 @@
$ java --enable-preview InheritanceRequiresStructuredTaskScope (JDK 25)
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"
exit=0
+10
View File
@@ -0,0 +1,10 @@
$ java --enable-preview -Xmx6g ScopedValueVsThreadLocalMemory 500000 (JDK 25)
threads per scenario: 500000, 5 bound values each, forked via StructuredTaskScope
CHECK ok : every one of 1000 subtasks read all 5 scoped values correctly
CHECK ok : every one of 1000 subtasks read all 5 inherited ThreadLocal values correctly
CHECK ok : every one of 500000 subtasks read all 5 scoped values correctly
ScopedValue: 500000 live subtasks, 5 bound values each, heap delta = 531.8 MB (1115 bytes/thread)
CHECK ok : every one of 500000 subtasks read all 5 inherited ThreadLocal values correctly
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
exit=0