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 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"