diff --git a/README.md b/README.md
index 4d881dc..638cb46 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,8 @@
# javademos
-Runnable companion code for the ankurm.com post **"Java 27 Is Out: Every JEP, Plus the Java 26 Changes You Skipped"**, and for the Java version guides
-( ). Every number and transcript in the post comes from a file in `docs/output/`, produced by a script in `scripts/`.
+Runnable companion code for the ankurm.com post **"Java 27 Is Out: Every JEP, Plus the Java 26 Changes You Skipped"**, for the Java version guides
+( ), and for two standalone deep dives: **Stream Gatherers (JEP 485)** and **Scoped Values vs
+ThreadLocal (JEP 506)**. Every number and transcript in the post comes from a file in `docs/output/`, produced by a script in `scripts/`.
## What was tested against
@@ -15,7 +16,8 @@ Runnable companion code for the ankurm.com post **"Java 27 Is Out: Every JEP, Pl
| JOL | 0.17 | downloaded and sha1-checked by `scripts/object-headers.sh` |
Next LTS: Java 29, September 2027 (Oracle Java SE Support Roadmap). Layout: `g1-container/`, `object-headers/`, `jep-tour/` (27 features and `broken/` 26-era sources),
-`recap26/` (the Java 26 lane), `lanes/` (the 21 to 25 hub demos, the AI prompts in `lanes/prompts/` and the build-file demo in `lanes/upgrade/`), `other-changes/`, `api-diff/`.
+`recap26/` (the Java 26 lane), `lanes/` (the 21 to 25 hub demos, the AI prompts in `lanes/prompts/` and the build-file demo in `lanes/upgrade/`), `other-changes/`, `api-diff/`,
+`gatherers/` (JEP 485, standalone post), `scoped-values/` (JEP 506 vs `ThreadLocal`, standalone post).
## Quickstart
@@ -25,6 +27,8 @@ export JDK21=/path/to/jdk-21 JDK25=/path/to/jdk-25 JDK26=/path/to/jdk-26 JDK27=/
./scripts/recap26.sh # the 26 recap lane
./scripts/lanes.sh # the 21 to 25 hub claims, checked on 21, 23, 24, 25, 26, 27
./scripts/upgrade.sh # the build-file half: Maven, Gradle, Lombok, --release, jdeps (needs Maven Central)
+./scripts/gatherers.sh # Stream Gatherers (JEP 485): every built-in gatherer, a custom one, mapConcurrent
+./scripts/scoped-values.sh # Scoped Values vs ThreadLocal (JEP 506): API, inheritance, memory (needs jcmd on PATH)
./scripts/run-all.sh # regenerate every docs/output/*.txt (about 15-20 minutes; needs Docker)
```
@@ -49,6 +53,8 @@ Timings, RSS, GC counts and the Vector species are machine dependent; treat them
| 13 | [Upgrade checklist](docs/13-upgrade-checklist.md) |
| 14 | [The 21 to 25 lane: every claim in the hub posts, checked](docs/14-lanes-21-to-25.md) |
| 15 | [Build files: Maven, Gradle and Lombok on the way to 25](docs/15-build-files.md) |
+| 16 | [Stream Gatherers (JEP 485)](docs/16-gatherers.md) |
+| 17 | [Scoped values vs ThreadLocal (JEP 506)](docs/17-scoped-values-migration.md) |
## Captured output (`docs/output/`)
@@ -66,6 +72,8 @@ Timings, RSS, GC counts and the Vector species are machine dependent; treat them
| `70`-`75` | `recap26.sh` | final-field mutation, AOT cache matrix and startup, removals, 26 API, HTTP/3 |
| `92`-`97` | `upgrade.sh` | Maven release 25 on JDK 21, 25, 27, Gradle 8 vs 9 on JDK 25, `--release` vs `-source/-target`, Lombok and Mockito by version, `jdeps --jdk-internals` |
| `80`-`91` | `lanes.sh` | the 21 to 25 hub claims on JDK 21, 23, 24, 25, 26, 27: scoped values, structured concurrency, finalization, pinning, ZGC flags, warnings |
+| `98` | `gatherers.sh` | the four built-in gatherers, `mapConcurrent` + virtual threads, a custom `Gatherer`, Collector vs Gatherer |
+| `99` | `scoped-values.sh` | `ScopedValue` API and rebinding, why plain threads don't inherit a binding, memory vs `ThreadLocal` two ways (heap delta, then `jcmd` object census) |
## Not reproduced
diff --git a/docs/15-build-files.md b/docs/15-build-files.md
index d478d85..7042702 100644
--- a/docs/15-build-files.md
+++ b/docs/15-build-files.md
@@ -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) · [Back to the README](../README.md)
+Prev: [14. The 21 to 25 lane](14-lanes-21-to-25.md) · 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) · [Back to the README](../README.md)
+Prev: [14. The 21 to 25 lane](14-lanes-21-to-25.md) · Next: [16. Stream Gatherers](16-gatherers.md)
diff --git a/docs/16-gatherers.md b/docs/16-gatherers.md
new file mode 100644
index 0000000..1527efa
--- /dev/null
+++ b/docs/16-gatherers.md
@@ -0,0 +1,66 @@
+# 16. Stream Gatherers (JEP 485, final since JDK 24)
+
+Prev: [15. Build files](15-build-files.md) · 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`, `BiFunction`) 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` 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 Gatherer dedupeConsecutive() {
+ return Gatherer.ofSequential(() -> new ArrayList(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`, 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) · Next: [17. Scoped values vs ThreadLocal](17-scoped-values-migration.md)
diff --git a/docs/17-scoped-values-migration.md b/docs/17-scoped-values-migration.md
new file mode 100644
index 0000000..c3f4725
--- /dev/null
+++ b/docs/17-scoped-values-migration.md
@@ -0,0 +1,60 @@
+# 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 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 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)
diff --git a/docs/output/98-basics.txt b/docs/output/98-basics.txt
new file mode 100644
index 0000000..0bb408b
--- /dev/null
+++ b/docs/output/98-basics.txt
@@ -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
diff --git a/docs/output/98-collector-vs-gatherer.txt b/docs/output/98-collector-vs-gatherer.txt
new file mode 100644
index 0000000..f5650b9
--- /dev/null
+++ b/docs/output/98-collector-vs-gatherer.txt
@@ -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
diff --git a/docs/output/98-custom-gatherer.txt b/docs/output/98-custom-gatherer.txt
new file mode 100644
index 0000000..aa2771d
--- /dev/null
+++ b/docs/output/98-custom-gatherer.txt
@@ -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
diff --git a/docs/output/98-mapconcurrent.txt b/docs/output/98-mapconcurrent.txt
new file mode 100644
index 0000000..a48b320
--- /dev/null
+++ b/docs/output/98-mapconcurrent.txt
@@ -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
diff --git a/docs/output/99-basics.txt b/docs/output/99-basics.txt
new file mode 100644
index 0000000..5a53813
--- /dev/null
+++ b/docs/output/99-basics.txt
@@ -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
diff --git a/docs/output/99-histogram-scopedvalue.txt b/docs/output/99-histogram-scopedvalue.txt
new file mode 100644
index 0000000..03f9a1a
--- /dev/null
+++ b/docs/output/99-histogram-scopedvalue.txt
@@ -0,0 +1,14 @@
+$ jcmd GC.class_histogram (100000 subtasks parked, mode=sv, JDK 25)
+ 1: 100000 115102880 jdk.internal.vm.StackChunk (java.base@25.0.4.1)
+ 2: 100000 16800000 java.lang.VirtualThread (java.base@25.0.4.1)
+ 9: 100000 2400000 java.util.concurrent.StructuredTaskScopeImpl$SubtaskImpl (java.base@25.0.4.1)
+ 128: 5 160 java.lang.ScopedValue$Carrier (java.base@25.0.4.1)
+ 175: 5 80 java.lang.InheritableThreadLocal (java.base@25.0.4.1)
+ 177: 5 80 java.lang.ScopedValue (java.base@25.0.4.1)
+ 203: 2 48 java.lang.ScopedValue$Snapshot (java.base@25.0.4.1)
+ 257: 1 32 java.util.concurrent.ThreadLocalRandom (java.base@25.0.4.1)
+ 281: 1 24 java.util.concurrent.StructuredTaskScopeImpl$SubtaskImpl$AltResult (java.base@25.0.4.1)
+ 292: 1 24 jdk.internal.vm.ScopedValueContainer$BindingsSnapshot (java.base@25.0.4.1)
+ 324: 1 16 java.lang.ThreadLocal (java.base@25.0.4.1)
+ 353: 1 16 java.util.concurrent.ThreadLocalRandom$Access$1 (java.base@25.0.4.1)
+Total 1133788 172079104
diff --git a/docs/output/99-histogram-threadlocal.txt b/docs/output/99-histogram-threadlocal.txt
new file mode 100644
index 0000000..16e4155
--- /dev/null
+++ b/docs/output/99-histogram-threadlocal.txt
@@ -0,0 +1,15 @@
+$ jcmd GC.class_histogram (100000 subtasks parked, mode=tl, JDK 25)
+ 1: 100000 109386560 jdk.internal.vm.StackChunk (java.base@25.0.4.1)
+ 2: 100000 16800000 java.lang.VirtualThread (java.base@25.0.4.1)
+ 3: 500005 16000160 java.lang.ThreadLocal$ThreadLocalMap$Entry (java.base@25.0.4.1)
+ 4: 100001 8000080 [Ljava.lang.ThreadLocal$ThreadLocalMap$Entry; (java.base@25.0.4.1)
+ 9: 100001 2400024 java.lang.ThreadLocal$ThreadLocalMap (java.base@25.0.4.1)
+ 12: 100000 2400000 java.util.concurrent.StructuredTaskScopeImpl$SubtaskImpl (java.base@25.0.4.1)
+ 177: 5 80 java.lang.InheritableThreadLocal (java.base@25.0.4.1)
+ 179: 5 80 java.lang.ScopedValue (java.base@25.0.4.1)
+ 258: 1 32 java.util.concurrent.ThreadLocalRandom (java.base@25.0.4.1)
+ 281: 1 24 java.util.concurrent.StructuredTaskScopeImpl$SubtaskImpl$AltResult (java.base@25.0.4.1)
+ 292: 1 24 jdk.internal.vm.ScopedValueContainer$BindingsSnapshot (java.base@25.0.4.1)
+ 324: 1 16 java.lang.ThreadLocal (java.base@25.0.4.1)
+ 353: 1 16 java.util.concurrent.ThreadLocalRandom$Access$1 (java.base@25.0.4.1)
+Total 1770749 179468208
diff --git a/docs/output/99-inheritance.txt b/docs/output/99-inheritance.txt
new file mode 100644
index 0000000..6977c9b
--- /dev/null
+++ b/docs/output/99-inheritance.txt
@@ -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
diff --git a/docs/output/99-memory-heap-delta.txt b/docs/output/99-memory-heap-delta.txt
new file mode 100644
index 0000000..ee64727
--- /dev/null
+++ b/docs/output/99-memory-heap-delta.txt
@@ -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
diff --git a/gatherers/src/Check.java b/gatherers/src/Check.java
new file mode 100644
index 0000000..fc1debf
--- /dev/null
+++ b/gatherers/src/Check.java
@@ -0,0 +1,15 @@
+/**
+ * Tiny assertion helper so every demo is self-checking: a claim that stops being true turns the
+ * run red instead of quietly printing something different. Passing checks are echoed, so the
+ * transcript shows exactly what was verified.
+ */
+final class Check {
+ private Check() {}
+
+ static void that(boolean condition, String claim) {
+ if (!condition) {
+ throw new AssertionError("CHECK FAILED: " + claim);
+ }
+ System.out.println("CHECK ok : " + claim);
+ }
+}
diff --git a/gatherers/src/CollectorVsGatherer.java b/gatherers/src/CollectorVsGatherer.java
new file mode 100644
index 0000000..427a00e
--- /dev/null
+++ b/gatherers/src/CollectorVsGatherer.java
@@ -0,0 +1,68 @@
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+import java.util.stream.Gatherers;
+import java.util.stream.Stream;
+
+/**
+ * When each is the right tool. A Collector always reduces a stream to exactly one final value,
+ * at the very end of the pipeline -- it cannot appear in the middle, and it cannot stop the
+ * source early. A Gatherer is an intermediate operation: it produces a new Stream, can emit zero,
+ * one, or many elements per input, and (as CustomGatherer.takeUntil showed) can stop pulling from
+ * upstream before the source is exhausted. Reach for a Collector when the answer is genuinely one
+ * value; reach for a Gatherer when you need to keep streaming afterward, or the transform itself
+ * needs private state, ordering, or the ability to bail out early.
+ * Chapter: docs/16-gatherers.md
+ */
+public class CollectorVsGatherer {
+
+ public static void main(String[] args) {
+ collectorIsRightForATerminalAggregate();
+ gathererIsRightForAnIntermediateStatefulTransform();
+ aGathererResultCanKeepBeingAStream();
+ }
+
+ static void collectorIsRightForATerminalAggregate() {
+ // groupingBy needs to see the whole stream and produce ONE Map. There's no intermediate
+ // stage here to preserve -- a Gatherer would just add ceremony around the same result.
+ record Order(String customer, double amount) {}
+ List orders = List.of(
+ new Order("amy", 12.50), new Order("bo", 4.00),
+ new Order("amy", 7.25), new Order("bo", 19.99));
+
+ Map totals = orders.stream()
+ .collect(Collectors.groupingBy(Order::customer, Collectors.summingDouble(Order::amount)));
+
+ System.out.println("Collectors.groupingBy totals = " + totals);
+ Check.that(totals.get("amy") == 19.75, "amy's total via groupingBy/summingDouble is 19.75");
+ Check.that(totals.get("bo") == 23.99, "bo's total via groupingBy/summingDouble is 23.99");
+ }
+
+ static void gathererIsRightForAnIntermediateStatefulTransform() {
+ // A running total that still needs filtering and mapping done to it afterward. Doing this
+ // with a Collector would mean collecting to a List of running totals first, then
+ // starting a SECOND stream over that list -- two passes, and the "it's still one
+ // pipeline" property is gone. gather() keeps it as one lazy pipeline throughout.
+ List overThreshold = Stream.of(5, 10, 15, 20, 25)
+ .gather(Gatherers.scan(() -> 0, Integer::sum)) // running totals: 5,15,30,50,75
+ .filter(total -> total > 20)
+ .map(total -> "total=" + total)
+ .toList();
+
+ System.out.println("scan -> filter -> map, one pipeline = " + overThreshold);
+ Check.that(overThreshold.equals(List.of("total=30", "total=50", "total=75")),
+ "scan feeding straight into filter/map in the same pipeline gives [total=30, total=50, total=75]");
+ }
+
+ static void aGathererResultCanKeepBeingAStream() {
+ // The defining shape difference, made concrete: a Collector's result type is whatever the
+ // Collector was parameterized with (here, a single Long). A Gatherer's result is always a
+ // Stream you can keep chaining -- gather() returns Stream, not R.
+ long distinctWindowCount = Stream.of(1, 2, 3, 4, 5, 6, 7)
+ .gather(Gatherers.windowFixed(2))
+ .collect(Collectors.counting());
+ System.out.println("windowFixed(2) over 7 elements, then collect(counting()) = " + distinctWindowCount);
+ Check.that(distinctWindowCount == 4,
+ "collect(counting()) after gather() proves the gather result is still a Stream you can collect further: 4 windows");
+ }
+}
diff --git a/gatherers/src/CustomGatherer.java b/gatherers/src/CustomGatherer.java
new file mode 100644
index 0000000..70e15a7
--- /dev/null
+++ b/gatherers/src/CustomGatherer.java
@@ -0,0 +1,82 @@
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Predicate;
+import java.util.stream.Gatherer;
+import java.util.stream.Stream;
+
+/**
+ * Writing a Gatherer from scratch. Gatherer<T, A, R> is four composable pieces:
+ * initializer (private mutable state A), integrator (per-element: read/write state A, push 0+ R
+ * downstream, return whether to keep going), combiner (merge two states for parallel streams --
+ * omit it and the gatherer just runs sequentially), finisher (flush anything left when the
+ * upstream is exhausted). Two custom gatherers here: dedupeConsecutive (stateful, one-in/0-or-1
+ * out) and takeUntil (short-circuiting -- something no Collector can do, because a Collector
+ * never gets a say in whether the source stops producing).
+ * Chapter: docs/16-gatherers.md
+ */
+public class CustomGatherer {
+
+ public static void main(String[] args) {
+ dedupeConsecutiveDemo();
+ takeUntilStopsTheUpstream();
+ }
+
+ /** Collapses consecutive equal elements, like Unix `uniq` -- keeps non-adjacent duplicates. */
+ static Gatherer dedupeConsecutive() {
+ return Gatherer.ofSequential(
+ // initializer: a single-slot box holding "last element pushed downstream", or
+ // null meaning "nothing pushed yet". A List of size 1 doubles as a
+ // nullable box without a wrapper class.
+ () -> new ArrayList(List.of()),
+ // integrator: push only when this element differs from the last one we pushed.
+ (state, element, downstream) -> {
+ boolean isFirst = state.isEmpty();
+ boolean changed = isFirst || !state.get(0).equals(element);
+ if (changed) {
+ if (isFirst) {
+ state.add(element);
+ } else {
+ state.set(0, element);
+ }
+ return downstream.push(element);
+ }
+ return true; // swallowed a duplicate; keep pulling from upstream
+ });
+ }
+
+ static void dedupeConsecutiveDemo() {
+ List deduped = Stream.of("a", "a", "b", "b", "b", "a", "c", "c")
+ .gather(CustomGatherer.dedupeConsecutive())
+ .toList();
+ System.out.println("dedupeConsecutive of a,a,b,b,b,a,c,c = " + deduped);
+ Check.that(deduped.equals(List.of("a", "b", "a", "c")),
+ "consecutive runs collapse to [a, b, a, c]; the second 'a' survives because it isn't adjacent to the first");
+ }
+
+ /**
+ * takeWhile(p) stops BEFORE the first failing element. takeUntil(p) stops AFTER it --
+ * includes the element that fails the predicate, then no more. Not expressible with
+ * takeWhile(p.negate()) plus one extra element without re-running the predicate; building it
+ * as a Gatherer means integrator itself decides when to stop pulling from upstream.
+ */
+ static Gatherer takeUntil(Predicate super T> stopAfter) {
+ return Gatherer.ofSequential((state, element, downstream) -> {
+ downstream.push(element);
+ return !stopAfter.test(element); // false = stop; upstream is not pulled again
+ });
+ }
+
+ static void takeUntilStopsTheUpstream() {
+ List upstreamSeen = new ArrayList<>();
+ List result = Stream.of(1, 2, 3, 4, 5, 6)
+ .peek(upstreamSeen::add)
+ .gather(takeUntil((Integer n) -> n == 3))
+ .toList();
+
+ System.out.println("takeUntil(n == 3) result = " + result + ", upstream elements actually pulled = " + upstreamSeen);
+ Check.that(result.equals(List.of(1, 2, 3)), "takeUntil includes the element that satisfies the stop condition: [1, 2, 3]");
+ Check.that(upstreamSeen.equals(List.of(1, 2, 3)),
+ "the upstream peek() only saw 1, 2, 3 -- elements 4, 5, 6 were never pulled, this genuinely short-circuits");
+ }
+
+}
diff --git a/gatherers/src/GathererBasics.java b/gatherers/src/GathererBasics.java
new file mode 100644
index 0000000..4ea7d57
--- /dev/null
+++ b/gatherers/src/GathererBasics.java
@@ -0,0 +1,104 @@
+import java.util.List;
+import java.util.stream.Gatherers;
+import java.util.stream.Stream;
+
+/**
+ * The five built-in gatherers (JEP 485, final in Java 24, no preview flag on 25+):
+ * windowFixed, windowSliding, fold, scan, mapConcurrent. This file covers the first four --
+ * mapConcurrent gets its own file because it needs real (slow) work to say anything interesting.
+ * Chapter: docs/16-gatherers.md
+ */
+public class GathererBasics {
+
+ public static void main(String[] args) {
+ windowFixed();
+ windowSliding();
+ fold();
+ scan();
+ foldIsNotScan();
+ }
+
+ static void windowFixed() {
+ // Chunks the stream into lists of exactly `n`, except possibly the last one, which is
+ // shorter if the stream doesn't divide evenly. No overlap between windows.
+ List> windows = Stream.of(1, 2, 3, 4, 5, 6, 7)
+ .gather(Gatherers.windowFixed(3))
+ .toList();
+ System.out.println("windowFixed(3) of 1..7 = " + windows);
+ Check.that(windows.equals(List.of(List.of(1, 2, 3), List.of(4, 5, 6), List.of(7))),
+ "windowFixed(3) groups into [1,2,3][4,5,6][7], last window is the short remainder");
+
+ try {
+ Stream.of(1).gather(Gatherers.windowFixed(0));
+ Check.that(false, "windowFixed(0) should reject a non-positive window size");
+ } catch (IllegalArgumentException expected) {
+ Check.that(true, "windowFixed(0) throws IllegalArgumentException: " + expected.getMessage());
+ }
+ }
+
+ static void windowSliding() {
+ // A window that advances by 1 each time, so consecutive windows overlap. Stops producing
+ // windows once fewer than `n` elements remain -- it never emits a short window.
+ List> windows = Stream.of(1, 2, 3, 4, 5)
+ .gather(Gatherers.windowSliding(3))
+ .toList();
+ System.out.println("windowSliding(3) of 1..5 = " + windows);
+ Check.that(windows.equals(List.of(List.of(1, 2, 3), List.of(2, 3, 4), List.of(3, 4, 5))),
+ "windowSliding(3) of 5 elements produces exactly 3 overlapping windows, each length 3");
+
+ // Easy to assume this behaves like windowFixed's leftover-shrinks-to-fit rule, or like
+ // Collectors.windowed variants elsewhere that drop a too-short remainder -- neither is
+ // right. windowSliding still emits ONE window holding whatever is there, as long as the
+ // stream isn't empty.
+ List> tooFew = Stream.of(1, 2).gather(Gatherers.windowSliding(3)).toList();
+ Check.that(tooFew.equals(List.of(List.of(1, 2))), "windowSliding(3) on a 2-element stream still emits one short window: [[1, 2]]");
+
+ List> empty = Stream.of().gather(Gatherers.windowSliding(3)).toList();
+ Check.that(empty.isEmpty(), "windowSliding(3) on an empty stream emits nothing");
+ }
+
+ static void fold() {
+ // fold(supplier, BiFunction) is a sequential-only, non-associative reduction: the result
+ // type R doesn't have to match the element type T, and there's no combiner because fold
+ // never runs in parallel. That's exactly what lets it do things Stream.reduce can't --
+ // like building a String left-to-right from a Stream.
+ String joined = Stream.of(1, 2, 3, 4)
+ .gather(Gatherers.fold(() -> "", (acc, n) -> acc + "[" + n + "]"))
+ .findFirst()
+ .orElseThrow();
+ System.out.println("fold building a String: " + joined);
+ Check.that(joined.equals("[1][2][3][4]"), "fold(\"\", acc+\"[n]\") over 1..4 builds \"[1][2][3][4]\" left to right");
+
+ // fold, like Collectors.reducing, emits exactly one final value -- it's an intermediate
+ // op that happens to produce a stream of size 1, not a running total.
+ long count = Stream.of(1, 2, 3).gather(Gatherers.fold(() -> 0, Integer::sum)).count();
+ Check.that(count == 1, "fold's output stream has exactly one element, the final accumulation");
+ }
+
+ static void scan() {
+ // scan(supplier, BiFunction) uses the identical signature to fold but emits every
+ // intermediate accumulation, not just the last one -- a running total, a prefix-sum. This
+ // is something no Collector can express: a Collector always reduces to one final value,
+ // never a sequence of intermediate ones.
+ List runningTotals = Stream.of(1, 2, 3, 4, 5)
+ .gather(Gatherers.scan(() -> 0, Integer::sum))
+ .toList();
+ System.out.println("scan running total of 1..5 = " + runningTotals);
+ Check.that(runningTotals.equals(List.of(1, 3, 6, 10, 15)), "scan(0, +) over 1..5 emits every prefix sum: [1,3,6,10,15]");
+ }
+
+ static void foldIsNotScan() {
+ // Same supplier and BiFunction, different gatherer -- proof the two aren't the same
+ // operation wearing different names.
+ var supplier = (java.util.function.Supplier) () -> 0;
+ var adder = (java.util.function.BiFunction) Integer::sum;
+
+ List foldResult = Stream.of(1, 2, 3).gather(Gatherers.fold(supplier, adder)).toList();
+ List scanResult = Stream.of(1, 2, 3).gather(Gatherers.scan(supplier, adder)).toList();
+
+ System.out.println("fold(0,+) over 1..3 = " + foldResult);
+ System.out.println("scan(0,+) over 1..3 = " + scanResult);
+ Check.that(foldResult.equals(List.of(6)), "fold(0,+) over 1..3 emits just the final sum [6]");
+ Check.that(scanResult.equals(List.of(1, 3, 6)), "scan(0,+) over 1..3 emits every partial sum [1,3,6]");
+ }
+}
diff --git a/gatherers/src/MapConcurrentDemo.java b/gatherers/src/MapConcurrentDemo.java
new file mode 100644
index 0000000..ac1c55e
--- /dev/null
+++ b/gatherers/src/MapConcurrentDemo.java
@@ -0,0 +1,120 @@
+import java.time.Duration;
+import java.time.Instant;
+import java.util.List;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.stream.Gatherers;
+import java.util.stream.Stream;
+
+/**
+ * Gatherers.mapConcurrent(maxConcurrency, mapper): the one built-in gatherer whose whole point is
+ * concurrency, not stream shape. It runs the mapper for each element on its own virtual thread (a
+ * new one per element, not a fixed pool of virtual threads), bounded to at most `maxConcurrency`
+ * in flight, and preserves encounter order in the output regardless of which finishes first.
+ * Chapter: docs/16-gatherers.md
+ */
+public class MapConcurrentDemo {
+
+ public static void main(String[] args) throws Exception {
+ orderIsPreservedEvenWhenCompletionOrderIsNot();
+ concurrencyIsBounded();
+ runsOnVirtualThreadsNotThePlatformPool();
+ aFailureWaitsForTheOthersRatherThanCancellingThem();
+ }
+
+ static void orderIsPreservedEvenWhenCompletionOrderIsNot() {
+ // Task N sleeps for (5-N)*40ms, so task 1 finishes LAST and task 4 finishes FIRST -- yet
+ // the output list comes back in input order, 1..4, because mapConcurrent buffers results
+ // and only pushes downstream once every earlier slot is filled.
+ List results = Stream.of(1, 2, 3, 4)
+ .gather(Gatherers.mapConcurrent(4, n -> {
+ sleep(Duration.ofMillis((5 - n) * 40L));
+ return n * 10;
+ }))
+ .toList();
+
+ System.out.println("mapConcurrent(4, ...) results, completion order reversed: " + results);
+ Check.that(results.equals(List.of(10, 20, 30, 40)),
+ "mapConcurrent output preserves encounter order [10,20,30,40] even though task 1 finishes last");
+ }
+
+ static void concurrencyIsBounded() {
+ // 8 tasks, each holding a permit for 60ms, capped at maxConcurrency=2. If the cap were
+ // real only in name, all 8 would overlap and the peak-in-flight counter would read 8.
+ AtomicInteger inFlight = new AtomicInteger(0);
+ AtomicInteger peak = new AtomicInteger(0);
+
+ List results = Stream.of(1, 2, 3, 4, 5, 6, 7, 8)
+ .gather(Gatherers.mapConcurrent(2, n -> {
+ int now = inFlight.incrementAndGet();
+ peak.updateAndGet(p -> Math.max(p, now));
+ sleep(Duration.ofMillis(60));
+ inFlight.decrementAndGet();
+ return n;
+ }))
+ .toList();
+
+ System.out.println("mapConcurrent(2, ...) over 8 tasks, peak concurrent in-flight = " + peak.get());
+ Check.that(results.size() == 8, "all 8 tasks completed");
+ Check.that(peak.get() <= 2, "peak in-flight never exceeded maxConcurrency=2, measured peak=" + peak.get());
+ Check.that(peak.get() == 2, "the cap is actually reached, not just respected -- measured peak=" + peak.get());
+ }
+
+ static void runsOnVirtualThreadsNotThePlatformPool() {
+ // Each element's mapper runs on its own Thread.ofVirtual() thread, not a shared
+ // ForkJoinPool.commonPool() worker the way Stream.parallel() would use.
+ ConcurrentLinkedQueue threadNames = new ConcurrentLinkedQueue<>();
+ AtomicInteger distinctThreads = new AtomicInteger(0);
+
+ Stream.of(1, 2, 3, 4, 5, 6)
+ .gather(Gatherers.mapConcurrent(6, n -> {
+ Thread t = Thread.currentThread();
+ threadNames.add(t + " virtual=" + t.isVirtual());
+ return n;
+ }))
+ .toList();
+
+ threadNames.forEach(System.out::println);
+ boolean allVirtual = threadNames.stream().allMatch(s -> s.endsWith("virtual=true"));
+ long distinct = threadNames.stream().distinct().count();
+ Check.that(allVirtual, "every mapConcurrent worker thread reports isVirtual() == true");
+ Check.that(distinct == 6, "6 elements produced 6 distinct virtual threads -- one per element, not a shared pool");
+ }
+
+ static void aFailureWaitsForTheOthersRatherThanCancellingThem() {
+ // Easy to assume a fail-fast gatherer interrupts its siblings the moment one mapper
+ // throws -- structured concurrency's StructuredTaskScope does exactly that. mapConcurrent
+ // does NOT: it lets every already-started mapper run to completion (or timeout) before
+ // the exception surfaces. Element 3 throws immediately here; elements 1, 2, 4, 5 each
+ // sleep 900ms. If the failure were fail-fast we'd see it in well under 900ms -- instead
+ // the whole call blocks for as long as the slowest sibling.
+ Instant start = Instant.now();
+ try {
+ Stream.of(1, 2, 3, 4, 5)
+ .gather(Gatherers.mapConcurrent(5, n -> {
+ if (n == 3) {
+ throw new IllegalStateException("boom at element 3");
+ }
+ sleep(Duration.ofMillis(900));
+ return n;
+ }))
+ .toList();
+ Check.that(false, "expected the failure to propagate");
+ } catch (IllegalStateException e) {
+ Duration elapsed = Duration.between(start, Instant.now());
+ System.out.println("mapConcurrent propagated: " + e.getMessage() + " after " + elapsed.toMillis() + "ms");
+ Check.that(elapsed.toMillis() >= 850,
+ "the failure only surfaced after " + elapsed.toMillis()
+ + "ms -- mapConcurrent waited for the other 4 in-flight tasks, it did not cancel them");
+ }
+ }
+
+ static void sleep(Duration d) {
+ try {
+ Thread.sleep(d);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new RuntimeException(e);
+ }
+ }
+}
diff --git a/scoped-values/src/Check.java b/scoped-values/src/Check.java
new file mode 100644
index 0000000..fc1debf
--- /dev/null
+++ b/scoped-values/src/Check.java
@@ -0,0 +1,15 @@
+/**
+ * Tiny assertion helper so every demo is self-checking: a claim that stops being true turns the
+ * run red instead of quietly printing something different. Passing checks are echoed, so the
+ * transcript shows exactly what was verified.
+ */
+final class Check {
+ private Check() {}
+
+ static void that(boolean condition, String claim) {
+ if (!condition) {
+ throw new AssertionError("CHECK FAILED: " + claim);
+ }
+ System.out.println("CHECK ok : " + claim);
+ }
+}
diff --git a/scoped-values/src/InheritanceRequiresStructuredTaskScope.java b/scoped-values/src/InheritanceRequiresStructuredTaskScope.java
new file mode 100644
index 0000000..28a0fd6
--- /dev/null
+++ b/scoped-values/src/InheritanceRequiresStructuredTaskScope.java
@@ -0,0 +1,74 @@
+import java.util.concurrent.StructuredTaskScope;
+
+/**
+ * The single easiest wrong assumption about Scoped Values: that binding one makes it visible to
+ * ANY child thread, the way InheritableThreadLocal does. It does not. A binding is only visible
+ * to code running on the SAME thread, or to subtasks forked from a {@code StructuredTaskScope}
+ * opened while the binding is active. A plain {@code Thread.ofVirtual().start(...)} or
+ * {@code new Thread(...)} -- even started from inside the bound block -- sees an UNBOUND value.
+ *
+ * Compile and run with {@code --enable-preview --release 25}: StructuredTaskScope (JEP 505) is
+ * still a preview API on JDK 25 (its sixth preview; finalized later, per docs/12-lanes-25-to-29.md
+ * and docs/output/82-structured-not-final.txt in this repo) even though ScopedValue (JEP 506)
+ * sitting right next to it is fully final. Mixing a final API with a preview one in the same
+ * example is exactly the kind of thing that's easy to get wrong by skimming release notes.
+ *
+ * Chapter: docs/17-scoped-values-migration.md
+ */
+public class InheritanceRequiresStructuredTaskScope {
+
+ static final ScopedValue REQUEST_ID = ScopedValue.newInstance();
+
+ public static void main(String[] args) throws Exception {
+ ScopedValue.where(REQUEST_ID, "req-42").run(() -> {
+ try {
+ plainVirtualThreadDoesNotInherit();
+ plainPlatformThreadDoesNotInherit();
+ structuredTaskScopeForkDoesInherit();
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ });
+ }
+
+ static void plainVirtualThreadDoesNotInherit() throws InterruptedException {
+ String[] outcome = new String[1];
+ Thread t = Thread.ofVirtual().start(() -> outcome[0] = readOrReport());
+ t.join();
+ System.out.println("Thread.ofVirtual().start(...) from inside the bound block: " + outcome[0]);
+ Check.that(outcome[0].equals("UNBOUND"),
+ "a plain virtual thread started from inside where().run() does NOT see the binding -- REQUEST_ID.get() threw NoSuchElementException");
+ }
+
+ static void plainPlatformThreadDoesNotInherit() throws InterruptedException {
+ String[] outcome = new String[1];
+ Thread t = new Thread(() -> outcome[0] = readOrReport());
+ t.start();
+ t.join();
+ System.out.println("new Thread(...) from inside the bound block: " + outcome[0]);
+ Check.that(outcome[0].equals("UNBOUND"), "a plain platform Thread doesn't see the binding either -- this isn't a virtual-thread-specific rule");
+ }
+
+ @SuppressWarnings("preview")
+ static void structuredTaskScopeForkDoesInherit() throws Exception {
+ String[] outcome = new String[1];
+ try (var scope = StructuredTaskScope.open()) {
+ scope.fork(() -> {
+ outcome[0] = readOrReport();
+ return null;
+ });
+ scope.join();
+ }
+ System.out.println("StructuredTaskScope.fork(...) from inside the bound block: " + outcome[0]);
+ Check.that(outcome[0].equals("req-42"),
+ "a subtask forked from a StructuredTaskScope opened while REQUEST_ID is bound DOES see \"req-42\"");
+ }
+
+ static String readOrReport() {
+ try {
+ return REQUEST_ID.get();
+ } catch (java.util.NoSuchElementException e) {
+ return "UNBOUND";
+ }
+ }
+}
diff --git a/scoped-values/src/MemoryFootprintProbe.java b/scoped-values/src/MemoryFootprintProbe.java
new file mode 100644
index 0000000..67e0b46
--- /dev/null
+++ b/scoped-values/src/MemoryFootprintProbe.java
@@ -0,0 +1,81 @@
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.StructuredTaskScope;
+
+/**
+ * Parks N subtasks holding 5 bound values (mode "sv" = ScopedValue, mode "tl" =
+ * InheritableThreadLocal) and prints READY, then sleeps for 60s so an external `jcmd
+ * GC.class_histogram` can be run against it while every subtask is alive and holding its
+ * bindings. scripts/scoped-values.sh drives this and diffs the two histograms -- see
+ * docs/output/99-histogram-scopedvalue.txt and 99-histogram-threadlocal.txt.
+ *
+ * ScopedValueVsThreadLocalMemory.java measures the SAME comparison a coarser way (total heap
+ * delta before/after) and is honest about that measurement being noisy relative to the fixed cost
+ * of a VirtualThread + its StackChunk. This file exists because the coarse measurement alone
+ * would undersell a real, structural difference that a precise object count makes obvious.
+ * Chapter: docs/17-scoped-values-migration.md
+ */
+public class MemoryFootprintProbe {
+
+ static final ScopedValue SV1 = ScopedValue.newInstance();
+ static final ScopedValue SV2 = ScopedValue.newInstance();
+ static final ScopedValue SV3 = ScopedValue.newInstance();
+ static final ScopedValue SV4 = ScopedValue.newInstance();
+ static final ScopedValue SV5 = ScopedValue.newInstance();
+
+ static final ThreadLocal TL1 = new InheritableThreadLocal<>();
+ static final ThreadLocal TL2 = new InheritableThreadLocal<>();
+ static final ThreadLocal TL3 = new InheritableThreadLocal<>();
+ static final ThreadLocal TL4 = new InheritableThreadLocal<>();
+ static final ThreadLocal TL5 = new InheritableThreadLocal<>();
+
+ public static void main(String[] args) throws Exception {
+ String mode = args[0];
+ int n = Integer.parseInt(args[1]);
+ CountDownLatch ready = new CountDownLatch(n);
+ CountDownLatch release = new CountDownLatch(1);
+ System.out.println("PID=" + ProcessHandle.current().pid() + " mode=" + mode + " n=" + n);
+
+ if (mode.equals("sv")) {
+ ScopedValue.where(SV1, "a").where(SV2, "b").where(SV3, "c").where(SV4, "d").where(SV5, "e").run(() -> {
+ try (var scope = StructuredTaskScope.open()) {
+ for (int i = 0; i < n; i++) {
+ scope.fork(() -> {
+ SV1.get();
+ ready.countDown();
+ release.await();
+ return null;
+ });
+ }
+ ready.await();
+ System.out.println("READY sv");
+ Thread.sleep(60_000);
+ release.countDown();
+ scope.join();
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ });
+ } else {
+ TL1.set("a");
+ TL2.set("b");
+ TL3.set("c");
+ TL4.set("d");
+ TL5.set("e");
+ try (var scope = StructuredTaskScope.open()) {
+ for (int i = 0; i < n; i++) {
+ scope.fork(() -> {
+ TL1.get();
+ ready.countDown();
+ release.await();
+ return null;
+ });
+ }
+ ready.await();
+ System.out.println("READY tl");
+ Thread.sleep(60_000);
+ release.countDown();
+ scope.join();
+ }
+ }
+ }
+}
diff --git a/scoped-values/src/ScopedValueVsThreadLocalMemory.java b/scoped-values/src/ScopedValueVsThreadLocalMemory.java
new file mode 100644
index 0000000..726fe6f
--- /dev/null
+++ b/scoped-values/src/ScopedValueVsThreadLocalMemory.java
@@ -0,0 +1,152 @@
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.StructuredTaskScope;
+import java.util.concurrent.atomic.AtomicLong;
+
+/**
+ * JEP 506's own motivation for Scoped Values over ThreadLocal names "expensive inheritance" as
+ * one of ThreadLocal's three flaws: a child thread must allocate storage for every inheritable
+ * thread-local value copied from its parent. A Scoped Value binding isn't copied into the child
+ * at all -- as InheritanceRequiresStructuredTaskScope.java shows, a child only sees a binding if
+ * it's forked from a StructuredTaskScope, and what it gets is a reference to the SAME immutable
+ * binding chain its parent already holds, not a copy.
+ *
+ * This measures that difference directly instead of repeating the claim: N subtasks are forked
+ * (via StructuredTaskScope, since that's the only mechanism that inherits a Scoped Value binding
+ * at all -- see the other file), each holding 5 bound values (ScopedValue in one run,
+ * InheritableThreadLocal in the other), and parked simultaneously so all N are alive and holding
+ * their bindings at once. Heap used is measured immediately before start and immediately after
+ * every subtask confirms it has read its values -- the delta is what N threads' worth of "5 bound
+ * values, held live" actually costs.
+ *
+ * Compile and run with --enable-preview --release 25 (StructuredTaskScope is still preview on 25).
+ * Chapter: docs/17-scoped-values-migration.md
+ */
+public class ScopedValueVsThreadLocalMemory {
+
+ static final ScopedValue SV1 = ScopedValue.newInstance();
+ static final ScopedValue SV2 = ScopedValue.newInstance();
+ static final ScopedValue SV3 = ScopedValue.newInstance();
+ static final ScopedValue SV4 = ScopedValue.newInstance();
+ static final ScopedValue SV5 = ScopedValue.newInstance();
+
+ static final ThreadLocal TL1 = new InheritableThreadLocal<>();
+ static final ThreadLocal TL2 = new InheritableThreadLocal<>();
+ static final ThreadLocal TL3 = new InheritableThreadLocal<>();
+ static final ThreadLocal TL4 = new InheritableThreadLocal<>();
+ static final ThreadLocal TL5 = new InheritableThreadLocal<>();
+
+ static final long EXPECTED_CHECKSUM_PER_THREAD = 5 + 5 + 7 + 5 + 4; // "alpha".."echo" lengths
+
+ public static void main(String[] args) throws Exception {
+ int n = args.length > 0 ? Integer.parseInt(args[0]) : 200_000;
+ System.out.println("threads per scenario: " + n + ", 5 bound values each, forked via StructuredTaskScope");
+
+ // Warm up classes/JIT with a small run so the real measurement doesn't pay class-loading cost.
+ runScopedValueScenario(1_000);
+ runThreadLocalScenario(1_000);
+
+ long scopedBytes = runScopedValueScenario(n);
+ System.out.println("ScopedValue: " + n + " live subtasks, 5 bound values each, heap delta = "
+ + fmt(scopedBytes) + " (" + (scopedBytes / n) + " bytes/thread)");
+
+ long threadLocalBytes = runThreadLocalScenario(n);
+ System.out.println("ThreadLocal: " + n + " live subtasks, 5 inherited values each, heap delta = "
+ + fmt(threadLocalBytes) + " (" + (threadLocalBytes / n) + " bytes/thread)");
+
+ double ratio = (double) threadLocalBytes / scopedBytes;
+ System.out.printf("ThreadLocal used %.1fx the heap ScopedValue used, for holding the identical 5 values live on %d threads%n", ratio, n);
+ // Deliberately NOT asserted as threadLocalBytes > scopedBytes: total-heap-before/after is
+ // too coarse to trust at this scale. The VirtualThread + StackChunk + SubtaskImpl objects
+ // that exist either way run 1000-2000+ bytes/thread; the actual difference this file is
+ // trying to isolate -- InheritableThreadLocal's per-thread ThreadLocalMap -- only comes to
+ // roughly 260 bytes/thread (measured precisely in MemoryFootprintProbe.java's output, via
+ // jcmd's exact object census, not GC-timing-dependent Runtime.freeMemory()). A ~15% signal
+ // sitting inside a multi-hundred-MB heap, remeasured across three System.gc() calls, is
+ // exactly the kind of thing that can and did flip sign between runs here. Leaving this
+ // file's honest, noisy number in place rather than deleting it: it's the reason this repo
+ // reaches for GC.class_histogram in the next file instead of trusting this one alone.
+ }
+
+ @SuppressWarnings("preview")
+ static long runScopedValueScenario(int n) throws Exception {
+ CountDownLatch ready = new CountDownLatch(n);
+ CountDownLatch release = new CountDownLatch(1);
+ AtomicLong checksum = new AtomicLong();
+
+ long before = heapUsedAfterGc();
+ return ScopedValue.where(SV1, "alpha").where(SV2, "bravo").where(SV3, "charlie").where(SV4, "delta").where(SV5, "echo")
+ .call(() -> {
+ try (var scope = StructuredTaskScope.open()) {
+ for (int i = 0; i < n; i++) {
+ scope.fork(() -> {
+ checksum.addAndGet(SV1.get().length() + SV2.get().length() + SV3.get().length() + SV4.get().length() + SV5.get().length());
+ ready.countDown();
+ await(release);
+ return null;
+ });
+ }
+ ready.await();
+ long after = heapUsedAfterGc();
+ release.countDown();
+ scope.join();
+ Check.that(checksum.get() == (long) n * EXPECTED_CHECKSUM_PER_THREAD, "every one of " + n + " subtasks read all 5 scoped values correctly");
+ return after - before;
+ }
+ });
+ }
+
+ @SuppressWarnings("preview")
+ static long runThreadLocalScenario(int n) throws Exception {
+ CountDownLatch ready = new CountDownLatch(n);
+ CountDownLatch release = new CountDownLatch(1);
+ AtomicLong checksum = new AtomicLong();
+
+ TL1.set("alpha");
+ TL2.set("bravo");
+ TL3.set("charlie");
+ TL4.set("delta");
+ TL5.set("echo");
+
+ long before = heapUsedAfterGc();
+ long delta;
+ try (var scope = StructuredTaskScope.open()) {
+ for (int i = 0; i < n; i++) {
+ scope.fork(() -> {
+ checksum.addAndGet(TL1.get().length() + TL2.get().length() + TL3.get().length() + TL4.get().length() + TL5.get().length());
+ ready.countDown();
+ await(release);
+ return null;
+ });
+ }
+ ready.await();
+ long after = heapUsedAfterGc();
+ delta = after - before;
+ release.countDown();
+ scope.join();
+ }
+ TL1.remove(); TL2.remove(); TL3.remove(); TL4.remove(); TL5.remove();
+ Check.that(checksum.get() == (long) n * EXPECTED_CHECKSUM_PER_THREAD, "every one of " + n + " subtasks read all 5 inherited ThreadLocal values correctly");
+ return delta;
+ }
+
+ static void await(CountDownLatch latch) {
+ try {
+ latch.await();
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }
+
+ static long heapUsedAfterGc() throws InterruptedException {
+ for (int i = 0; i < 3; i++) {
+ System.gc();
+ Thread.sleep(200);
+ }
+ Runtime rt = Runtime.getRuntime();
+ return rt.totalMemory() - rt.freeMemory();
+ }
+
+ static String fmt(long bytes) {
+ return String.format("%.1f MB", bytes / (1024.0 * 1024.0));
+ }
+}
diff --git a/scoped-values/src/ScopedValuesBasics.java b/scoped-values/src/ScopedValuesBasics.java
new file mode 100644
index 0000000..57bc5ec
--- /dev/null
+++ b/scoped-values/src/ScopedValuesBasics.java
@@ -0,0 +1,85 @@
+import java.util.NoSuchElementException;
+
+/**
+ * The core API (JEP 506, final in Java 25, no preview flag): newInstance(), where(...).run(...) /
+ * .call(...), get(), isBound(), orElse(), orElseThrow(), and rebinding -- a nested where() that
+ * shadows an outer binding for the extent of its own block, then hands control back to the outer
+ * value once that block exits.
+ * Chapter: docs/17-scoped-values-migration.md
+ */
+public class ScopedValuesBasics {
+
+ static final ScopedValue USER = ScopedValue.newInstance();
+
+ public static void main(String[] args) {
+ basicBindAndRead();
+ unboundThrowsByDefault();
+ orElseAndOrElseThrow();
+ rebindingShadowsThenRestores();
+ callReturnsAValue();
+ }
+
+ static void basicBindAndRead() {
+ ScopedValue.where(USER, "ankur").run(() -> {
+ Check.that(USER.isBound(), "isBound() is true inside where().run()");
+ Check.that(USER.get().equals("ankur"), "get() returns \"ankur\" inside the binding");
+ deepMethodSeesTheSameBinding();
+ });
+ Check.that(!USER.isBound(), "isBound() is false again once run() has returned");
+ }
+
+ static void deepMethodSeesTheSameBinding() {
+ // No parameter was passed here -- USER is visible because this call happens ON THE SAME
+ // THREAD while the binding is active, not because of anything passed explicitly.
+ Check.that(USER.get().equals("ankur"), "a method several frames deep, given no parameter, still sees the binding");
+ }
+
+ static void unboundThrowsByDefault() {
+ try {
+ USER.get();
+ Check.that(false, "expected get() to throw outside any binding");
+ } catch (NoSuchElementException e) {
+ Check.that(true, "get() outside a binding throws NoSuchElementException, not null or a default value");
+ }
+ }
+
+ static void orElseAndOrElseThrow() {
+ Check.that(USER.orElse("anonymous").equals("anonymous"), "orElse(fallback) returns the fallback when unbound, no exception");
+ ScopedValue.where(USER, "ankur").run(() -> Check.that(USER.orElse("anonymous").equals("ankur"), "orElse(fallback) returns the real value when bound"));
+
+ try {
+ USER.orElseThrow(() -> new IllegalStateException("no user in scope"));
+ Check.that(false, "expected orElseThrow to throw when unbound");
+ } catch (IllegalStateException e) {
+ Check.that(e.getMessage().equals("no user in scope"), "orElseThrow(supplier) throws exactly the supplied exception when unbound");
+ }
+ }
+
+ static void rebindingShadowsThenRestores() {
+ ScopedValue.where(USER, "ankur").run(() -> {
+ Check.that(USER.get().equals("ankur"), "outer binding: ankur");
+ ScopedValue.where(USER, "support-bot").run(() -> {
+ // A nested where() on the SAME ScopedValue shadows the outer binding for the
+ // extent of this inner block -- this is rebinding, and it's the mechanism a
+ // request handler uses to say "everything I call from here on behaves as
+ // support-bot" without touching the outer caller's binding at all.
+ Check.that(USER.get().equals("support-bot"), "inner binding shadows the outer one: support-bot");
+ methodInsideInnerScope();
+ });
+ // Back outside the inner where(): the outer binding is exactly as it was, not
+ // mutated, not cleared -- rebinding is scoped, not an assignment.
+ Check.that(USER.get().equals("ankur"), "outer binding is restored, unchanged, once the inner where() block exits: ankur");
+ });
+ }
+
+ static void methodInsideInnerScope() {
+ Check.that(USER.get().equals("support-bot"), "a method called from inside the inner scope sees the REBOUND value, not the original");
+ }
+
+ static void callReturnsAValue() {
+ // .call(...) is where().run()'s twin for when you need a result back, propagating a
+ // checked exception type X through Carrier.call's signature.
+ int length = ScopedValue.where(USER, "ankur").call(() -> USER.get().length());
+ Check.that(length == 5, "call(...) returns whatever the lambda returns -- USER.get().length() == 5");
+ }
+}
diff --git a/scripts/gatherers.sh b/scripts/gatherers.sh
new file mode 100755
index 0000000..b22f157
--- /dev/null
+++ b/scripts/gatherers.sh
@@ -0,0 +1,23 @@
+#!/usr/bin/env bash
+# Stream Gatherers (JEP 485, final since JDK 24, no preview flag needed on 25+). Every demo
+# asserts its own claims (see Check.java), so a green transcript means the post's claims still
+# hold on whatever JDK25 points at.
+# ./scripts/gatherers.sh -> docs/output/98-basics.txt 98-mapconcurrent.txt
+# 98-custom-gatherer.txt 98-collector-vs-gatherer.txt
+set -uo pipefail
+source "$(dirname "$0")/env.sh"
+S="$ROOT/gatherers/src"; B="$ROOT/build/gatherers"; rm -rf "$B"; mkdir -p "$B"
+
+"$JDK25/bin/javac" -d "$B" "$S"/*.java 2>&1 | grep -v -E '^(Note:|$)' || true
+
+run() {
+ local outfile="$1" cls="$2"
+ { echo "\$ java $cls (JDK 25)"; "$JDK25/bin/java" -cp "$B" "$cls" 2>&1; echo "exit=${PIPESTATUS[0]}"; } > "$OUT/$outfile" 2>&1
+}
+
+run 98-basics.txt GathererBasics
+run 98-mapconcurrent.txt MapConcurrentDemo
+run 98-custom-gatherer.txt CustomGatherer
+run 98-collector-vs-gatherer.txt CollectorVsGatherer
+
+echo "wrote $OUT/98-*.txt"
diff --git a/scripts/scoped-values.sh b/scripts/scoped-values.sh
new file mode 100755
index 0000000..5032c14
--- /dev/null
+++ b/scripts/scoped-values.sh
@@ -0,0 +1,52 @@
+#!/usr/bin/env bash
+# Scoped Values (JEP 506, final since JDK 25) vs ThreadLocal. StructuredTaskScope (JEP 505) is
+# still PREVIEW on JDK 25 -- every file here that touches it needs --enable-preview --release 25.
+# ./scripts/scoped-values.sh -> docs/output/99-basics.txt 99-inheritance.txt
+# 99-memory-heap-delta.txt 99-histogram-scopedvalue.txt
+# 99-histogram-threadlocal.txt
+set -uo pipefail
+source "$(dirname "$0")/env.sh"
+S="$ROOT/scoped-values/src"; B="$ROOT/build/scoped-values"; rm -rf "$B"; mkdir -p "$B"
+
+# ScopedValuesBasics.java needs no preview flag (JEP 506 is final); the other three touch
+# StructuredTaskScope and need --enable-preview.
+"$JDK25/bin/javac" -d "$B" "$S/Check.java" "$S/ScopedValuesBasics.java" 2>&1 | grep -v -E '^(Note:|$)' || true
+"$JDK25/bin/javac" --enable-preview --release 25 -Xlint:-preview -d "$B" "$S"/*.java 2>&1 | grep -v -E '^(Note:|$)' || true
+
+{ echo "\$ java ScopedValuesBasics (JDK 25, no preview flag -- JEP 506 is final)"
+ "$JDK25/bin/java" -cp "$B" ScopedValuesBasics 2>&1; echo "exit=${PIPESTATUS[0]}"
+} > "$OUT/99-basics.txt" 2>&1
+
+{ echo "\$ java --enable-preview InheritanceRequiresStructuredTaskScope (JDK 25)"
+ "$JDK25/bin/java" --enable-preview -cp "$B" InheritanceRequiresStructuredTaskScope 2>&1; echo "exit=${PIPESTATUS[0]}"
+} > "$OUT/99-inheritance.txt" 2>&1
+
+{ echo "\$ java --enable-preview -Xmx6g ScopedValueVsThreadLocalMemory 500000 (JDK 25)"
+ "$JDK25/bin/java" --enable-preview -Xmx6g -cp "$B" ScopedValueVsThreadLocalMemory 500000 2>&1; echo "exit=${PIPESTATUS[0]}"
+} > "$OUT/99-memory-heap-delta.txt" 2>&1
+
+# Precise version: park N=100000 subtasks holding 5 bound values each, then ask the JVM itself
+# (jcmd GC.class_histogram) how many objects of each class actually exist while they're all alive.
+histogram() {
+ local mode="$1" outfile="$2"
+ rm -f "$B/probe.log"
+ "$JDK25/bin/java" --enable-preview -Xmx3g -cp "$B" MemoryFootprintProbe "$mode" 100000 > "$B/probe.log" 2>&1 &
+ local pid=$!
+ for _ in $(seq 1 60); do grep -q "READY $mode" "$B/probe.log" 2>/dev/null && break; sleep 1; done
+ {
+ echo "\$ jcmd GC.class_histogram (100000 subtasks parked, mode=$mode, JDK 25)"
+ "$JDK25/bin/jcmd" "$pid" GC.class_histogram 2>&1 \
+ | grep -E 'ThreadLocal|VirtualThread |SubtaskImpl|Carrier|ScopedValue|StackChunk|^Total' \
+ | grep -v CarrierThread
+ } > "$OUT/$outfile" 2>&1
+ kill -9 "$pid" 2>/dev/null
+ wait "$pid" 2>/dev/null
+}
+
+histogram sv 99-histogram-scopedvalue.txt
+histogram tl 99-histogram-threadlocal.txt
+
+scrub() { sed -i -E "s#$ROOT##g" "$@"; }
+scrub "$OUT"/99-*.txt
+
+echo "wrote $OUT/99-*.txt"