Add two standalone modules: Stream Gatherers (JEP 485) and Scoped Values vs ThreadLocal (JEP 506)

gatherers/: windowFixed, windowSliding, fold, scan, mapConcurrent, a custom
Gatherer (dedupeConsecutive, takeUntil), and a Collector-vs-Gatherer comparison.
Verified: windowSliding emits one short window on a too-short stream rather than
shrinking to empty like windowFixed; mapConcurrent waits for in-flight siblings
to finish (~901ms) rather than cancelling them when one mapper throws.

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

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

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01FtpJvZfg4nvLvtzgJTDWpB
This commit is contained in:
Claude
2026-09-24 04:27:12 +00:00
parent f59c1de96d
commit 6b5a918278
25 changed files with 1124 additions and 5 deletions
+15
View File
@@ -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);
}
}
@@ -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<String> 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";
}
}
}
@@ -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 <pid>
* 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<String> SV1 = ScopedValue.newInstance();
static final ScopedValue<String> SV2 = ScopedValue.newInstance();
static final ScopedValue<String> SV3 = ScopedValue.newInstance();
static final ScopedValue<String> SV4 = ScopedValue.newInstance();
static final ScopedValue<String> SV5 = ScopedValue.newInstance();
static final ThreadLocal<String> TL1 = new InheritableThreadLocal<>();
static final ThreadLocal<String> TL2 = new InheritableThreadLocal<>();
static final ThreadLocal<String> TL3 = new InheritableThreadLocal<>();
static final ThreadLocal<String> TL4 = new InheritableThreadLocal<>();
static final ThreadLocal<String> 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();
}
}
}
}
@@ -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<String> SV1 = ScopedValue.newInstance();
static final ScopedValue<String> SV2 = ScopedValue.newInstance();
static final ScopedValue<String> SV3 = ScopedValue.newInstance();
static final ScopedValue<String> SV4 = ScopedValue.newInstance();
static final ScopedValue<String> SV5 = ScopedValue.newInstance();
static final ThreadLocal<String> TL1 = new InheritableThreadLocal<>();
static final ThreadLocal<String> TL2 = new InheritableThreadLocal<>();
static final ThreadLocal<String> TL3 = new InheritableThreadLocal<>();
static final ThreadLocal<String> TL4 = new InheritableThreadLocal<>();
static final ThreadLocal<String> 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));
}
}
+85
View File
@@ -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<String> 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");
}
}