Java Streams Were Missing One Thing. Java 22 Finally Fixed It.
Collectors always forced your stream to stop. Gatherers let it keep going — with state, with windows, with full control.
Java Streams Were Missing One Thing. Java 22 Finally Fixed It.
Collectors always forced your stream to stop. Gatherers let it keep going — with state, with windows, with full control.
You’ve written it before. The stream is clean, functional, elegant — and then you need to compute a rolling sum, or group elements into batches of three, or filter only increasing values while remembering what came before.
And suddenly you’re stepping outside the stream entirely. External loops. Mutable variables. Temporary lists. Everything you were trying to avoid in the first place.
It wasn’t your fault. The Stream API genuinely had a gap. Collectors are terminal — the moment you call collect(), the stream dies. You can't continue chaining operations. You can't carry state through the middle of a pipeline.
Java 22 filled that gap with Stream Gatherers.The feature was introduced as a preview in Java 22 but finalized in Java 24
This isn’t just another utility class. It’s a fundamentally different kind of operation — one that gives you stateful, intermediate transformations without breaking out of the functional model. By the end of this article, you’ll know exactly when to reach for them, how to use the built-in ones, and how to write your own.
Photo by Milad Fakurian on Unsplash
The Core Idea: Gatherers vs Collectors
Before we write any code, get this distinction locked in. It’s the key to everything.
CollectorsGatherersStream stageTerminalIntermediateCarries stateYesYesCan emit multiple resultsNoYesCan chain after?NoYesParallel-friendlyYesYes
Collectors build a final result and end the stream. Gatherers transform elements in the middle of a pipeline — you can keep chaining map, filter, or even another gather after them.
That difference is what opens the door to things that previously required leaving the stream entirely.
Built-in Gatherers: Start Here
Java 22 ships a Gatherers utility class with ready-to-use implementations. These cover the most common use cases.
1. windowFixed — Process in Batches
You have a stream of elements and need to process them in chunks of N. Classic example: making API calls in batches of 10.
import java.util.stream.Gatherers;
import java.util.stream.Stream;
Stream.of(10, 20, 30, 40, 50)
.gather(Gatherers.windowFixed(2))
.forEach(System.out::println);
Output:
[10, 20]
[30, 40]
[50]
No external loop. No accumulating list. The gatherer handles windowing — including that last incomplete batch of just [50] — and the stream continues flowing.
2. fold — Aggregate the Entire Stream Into One Result
fold is like reduce, but as an intermediate operation. It accumulates all elements into a single aggregate and emits one value when the stream ends. Perfect for building summaries, concatenations, or any single computed result mid-pipeline.
Stream.of(1, 2, 3, 4, 5)
.gather(Gatherers.fold(() -> 0, (sum, e) -> sum + e))
.forEach(System.out::println);
Output:
15
One result. That’s it. fold consumes everything and emits once at the end. If you want a value emitted at every step — like a running total — that's scan, covered next.
3. scan — Rolling Aggregations at Every Step
scan applies a function to the current state and each element, emitting the updated state downstream after every element. Perfect for running totals, moving averages, or any metric that updates with each new value.
Stream.of(1, 2, 3, 4, 5)
.gather(Gatherers.scan(() -> 0, (sum, e) -> sum + e))
.forEach(System.out::println);
Output:
1
3
6
10
15
Same function as fold, but emits at every step instead of only at the end. fold gives you the final answer. scan gives you the answer at every point along the way.
4. mapConcurrent — Parallel Map Without the Boilerplate
Need to apply a transformation in parallel — say, making HTTP calls or running CPU-heavy computations — while preserving stream semantics?
IntStream.range(1, 6)
.boxed()
.gather(Gatherers.mapConcurrent(4, x -> x * 10)) // 4 = max concurrent tasks
.forEach(System.out::println);
mapConcurrent handles the threading. You get concurrency without manually managing thread pools or collecting into a list first.
Writing Your Own Gatherer
The built-ins cover common cases. For everything else, you use Gatherer.of().
It takes two things:
- A state initializer — creates a fresh state object for the gatherer
- An integrator — receives each element, the current state, and a
downstreamto push results into
Let’s build a streaming distinct() — but one where you control the deduplication logic.
import java.util.HashSet;
import java.util.Set;
import java.util.stream.Gatherer;
import java.util.stream.Stream;
Gatherer<Integer, Set<Integer>, Integer> uniqueGatherer = Gatherer.of(
HashSet::new, // state: a set to track seen values
(seen, e, downstream) -> {
if (seen.add(e)) downstream.push(e); // emit only if not seen before
}
);
Stream.of(1, 2, 2, 3, 1, 4)
.gather(uniqueGatherer)
.forEach(System.out::println);
Output:
1
2
3
4
The HashSet is the gatherer's private state — it persists across elements, remembers what's been seen, and controls what gets pushed downstream. This is what was impossible with standard stream operations.
Stateful Filtering: Only Increasing Numbers
Here’s a slightly more advanced example. Filter a stream to keep only elements larger than the previous one emitted.
Gatherer<Integer, int[], Integer> increasing = Gatherer.of(
() -> new int[]{ Integer.MIN_VALUE }, // state: last seen value
(last, e, downstream) -> {
if (e > last[0]) {
last[0] = e;
downstream.push(e);
}
}
);
Stream.of(1, 3, 2, 5, 4, 6)
.gather(increasing)
.forEach(System.out::println);
Output:
1
3
5
6
2 is skipped because 3 came before it. 4 is skipped because 5 came before it. The gatherer remembers — and filters accordingly. There's no clean way to do this with standard streams.
Handling the Last Batch: The Finisher
There’s one edge case worth addressing explicitly. What if the stream ends with a partial window?
By default, a gatherer doesn’t know the stream has ended. You need a finisher — a third argument to Gatherer.of() that runs when the stream closes.
Gatherer.of(
ArrayList::new,
(buffer, e, downstream) -> {
buffer.add(e);
if (buffer.size() == 3) {
downstream.push(new ArrayList<>(buffer));
buffer.clear();
}
},
(buffer, downstream) -> {
if (!buffer.isEmpty()) downstream.push(new ArrayList<>(buffer)); // emit the leftovers
}
);
Without the finisher, a stream of [1, 2, 3, 4, 5] would emit [1, 2, 3] and silently drop 4 and 5. With the finisher, you get [1, 2, 3] followed by [4, 5]. Always add a finisher when your gatherer buffers elements.
Composing Gatherers in a Pipeline
Because gatherers are intermediate, they chain naturally.
Stream.of(1, 2, 3, 4, 5, 6)
.gather(Gatherers.windowFixed(3)) // group into [1,2,3], [4,5,6]
.map(w -> w.stream().mapToInt(Integer::intValue).sum()) // sum each window
.forEach(System.out::println);
Output:
6
15
Window → sum → continue. The pipeline reads exactly like what it does. No loops. No intermediate lists. No break in the functional style.
When to Use Gatherers (and When Not To)
Reach for Gatherers when you need:
- Sliding or fixed windows over stream elements
- Rolling aggregations like running totals or moving averages
- Stateful filtering where elements depend on what came before
- Buffering elements for batch processing mid-pipeline
- Custom deduplication or ordering logic inline
Stick with Collectors when you need:
- A simple terminal accumulation —
toList(),toMap(),groupingBy() - A
MaporSetas your final result - Anything where you don’t need to continue the pipeline afterward
Gatherers are not a replacement for Collectors. They’re a complement. Gatherers handle the middle of the pipeline. Collectors handle the end.
Common Mistakes
Forgetting downstream.push() — If you don't call this, nothing is emitted. The element is consumed by the gatherer and silently disappears. Always be explicit about what you're sending forward.
Skipping the finisher — If your gatherer buffers anything, you need a finisher to emit the remainder when the stream closes. No finisher means silent data loss on the last batch.
Blocking operations inside mapConcurrent — Synchronized blocks or heavy I/O inside a concurrent gatherer kills the concurrency benefit. Keep the transformation function fast and non-blocking.
Bad state initialization — Using null or an improperly initialized supplier will throw NullPointerException at runtime. Always provide a complete, valid state supplier like HashSet::new or () -> new ArrayList<>().
The Bottom Line
Before Gatherers, stateful stream operations required breaking out of the pipeline. External variables, intermediate collections, custom Collector boilerplate that took thirty lines to express a simple idea.
Stream Gatherers replace all of that with a single composable mechanism that fits naturally into a functional pipeline.
Rolling metrics? Gatherers. Batch processing in chunks? Gatherers. Stateful filtering that remembers history? Gatherers. All of it, inline, without a loop in sight.
Java 22 didn’t just add a utility class. It closed a genuine gap in the Stream API that developers have been working around for years. If you’re on Java 22 or later and you’re still reaching for external loops when streams get complex — this is the API you’ve been waiting for.
메타데이터
- post_id
- 242e52d7bbbb
- slug
- java-streams-were-missing-one-thing-java-22-finally-fixed-it-242e52d7bbbb
- url
- https://medium.com/javarevisited/java-streams-were-missing-one-thing-java-22-finally-fixed-it-242e52d7bbbb
- canonical_url
- https://medium.com/javarevisited/java-streams-were-missing-one-thing-java-22-finally-fixed-it-242e52d7bbbb
- author_url
- https://medium.com/@ashish-choudhary
- status
- ok
- fetched_at
- 2026-07-11 22:57:18