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
+68
View File
@@ -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<Order> orders = List.of(
new Order("amy", 12.50), new Order("bo", 4.00),
new Order("amy", 7.25), new Order("bo", 19.99));
Map<String, Double> 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<Integer> 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<String> 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<R> you can keep chaining -- gather() returns Stream<R>, 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");
}
}