Java Module 11 – Streams and stream gatherers
Stream gatherers: custom intermediate operations
Java stream gatherers on JDK 25: windowFixed, windowSliding, scan, fold and mapConcurrent, then your own Gatherer with state, an early stop and a finisher.
What you will learn
- Use the built-in gatherers windowFixed, windowSliding, fold, scan and mapConcurrent
- Write a custom Gatherer with an initializer and an integrator
- Explain where gatherers fit compared with collectors
Before you start
What you’ll learn · Before you start
What you will learn
- Use the built-in gatherers windowFixed, windowSliding, fold, scan and mapConcurrent
- Write a custom Gatherer with an initializer and an integrator
- Explain where gatherers fit compared with collectors
Before you start
On this page
A gatherer is an intermediate operation you write yourself. stream.gather(gatherer) sits in the middle of a
pipeline, like map or filter, but what it does is up to you: it can turn one element into none, one or several
results, remember earlier elements, stop the stream early, and emit something at the end. Gatherers became a final
feature in JDK 24 (JEP 485), after previews in JDK 22 and 23, so JDK 25 LTS has them without any flag. The class
java.util.stream.Gatherers ships five ready-made ones: windowFixed and windowSliding group elements into
lists, scan emits a running result, fold emits one result at the end, and mapConcurrent runs a function on
virtual threads with a limit on how many calls run at once. Where a collector finishes a pipeline with one result, a
gatherer keeps it going. This lesson runs all five on JDK 25 and builds three gatherers of its own.
Windows: windowFixed and windowSliding
Gatherers.windowFixed(n) cuts the stream into lists of n elements, the last list possibly shorter.
Gatherers.windowSliding(n) moves a window of n elements along the stream one element at a time, so each window
shares all but one element with the one before:
import java.util.List;
import java.util.stream.Gatherers;
import java.util.stream.IntStream;
import java.util.stream.Stream;
class Windows {
static double average(List<Integer> window) {
return window.stream().mapToInt(Integer::intValue).average().orElseThrow();
}
static void main() {
// Fixed windows: groups of 3, the last one shorter.
IO.println("windowFixed(3): " + IntStream.rangeClosed(1, 7).boxed().gather(Gatherers.windowFixed(3)).toList());
// Sliding windows: each window drops its oldest element and adds the next one.
IO.println("windowSliding(3): " + IntStream.rangeClosed(1, 7).boxed().gather(Gatherers.windowSliding(3)).toList());
// Daily maximum temperatures in °C over ten days (made-up readings), and their 3-day moving average.
List<Integer> maxima = List.of(38, 40, 41, 39, 43, 44, 42, 40, 37, 36);
List<String> moving = maxima.stream()
.gather(Gatherers.windowSliding(3))
.map(w -> "%.1f".formatted(average(w)))
.toList();
IO.println("3-day averages: " + moving);
// The first 3 days whose average reaches 43 °C: findFirst stops the windows there.
IO.println("first hot spell: " + maxima.stream()
.gather(Gatherers.windowSliding(3))
.filter(w -> average(w) >= 43)
.findFirst()
.orElseThrow());
// Fewer elements than the window size: one window holding all of them, not none.
IO.println("2 days, window 3: " + Stream.of(38, 40).gather(Gatherers.windowSliding(3)).toList());
// Windows are unmodifiable lists.
List<Integer> first = maxima.stream().gather(Gatherers.windowFixed(3)).findFirst().orElseThrow();
try {
first.add(45);
} catch (UnsupportedOperationException e) {
IO.println("adding to a window: " + e);
}
// A gatherer can end an infinite stream: windows of 3 from 0, 1, 2, ... and only the first two.
IO.println("infinite source: " + Stream.iterate(0, i -> i + 1).gather(Gatherers.windowFixed(3)).limit(2).toList());
}
} from session import run, version
version()
run("""
java Windows.java
""") Output
# openjdk version "25.0.4.1" LTS $ java Windows.java windowFixed(3): [[1, 2, 3], [4, 5, 6], [7]] windowSliding(3): [[1, 2, 3], [2, 3, 4], [3, 4, 5], [4, 5, 6], [5, 6, 7]] 3-day averages: [39.7, 40.0, 41.0, 42.0, 43.0, 42.0, 39.7, 37.7] first hot spell: [43, 44, 42] 2 days, window 3: [[38, 40]] adding to a window: java.lang.UnsupportedOperationException infinite source: [[0, 1, 2], [3, 4, 5]]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 windows_session.py
windowFixed(3) over 1 to 7 gave [[1, 2, 3], [4, 5, 6], [7]], and windowSliding(3) gave the five windows that
fit. A sliding window is the natural shape for a moving average: ten days of readings make eight 3-day averages,
and filter plus findFirst found the first three days averaging 43 °C or more, then stopped reading.
Three behaviours from the output deserve a note:
- A short stream still makes one window. With two readings and a window of 3,
windowSlidingproduced one window of two elements, as its Javadoc says. A “3-day average” computed from it covers only two days, so checkw.size() == 3when partial windows would mislead. - Windows cannot be changed. Each window is an unmodifiable list, and
addthrewUnsupportedOperationException. Copy a window into a newArrayListif you need to edit it. - Infinite streams work.
Stream.iterate(0, i -> i + 1)never ends, yetgather(windowFixed(3)).limit(2)returned two windows, becauselimittells the gatherer to stop. A collector could not do this: it would have to read the whole stream first.
Running results: scan and fold
Gatherers.scan(initial, f) emits a result after every element: the running total of a wallet, the balance after
each transaction. Gatherers.fold(initial, f) emits only the last one, like reduce, but with two differences that
matter: the result may be of another type than the elements, and the function runs strictly in order, so it may
depend on order:
import java.util.List;
import java.util.stream.Gatherers;
class Scans {
static void main() {
// A wallet: money added (+) and spent (-). scan emits the balance after each change.
List<Integer> changes = List.of(500, -120, -80, 1000, -650);
IO.println("balances: " + changes.stream().gather(Gatherers.scan(() -> 0, Integer::sum)).toList());
// fold emits one result at the end. Its function runs in order, so it may depend on order:
// the digits 1 to 9 become the number 123456789.
List<Integer> digits = List.of(1, 2, 3, 4, 5, 6, 7, 8, 9);
IO.println("fold: " + digits.stream()
.gather(Gatherers.fold(() -> 0, (number, d) -> number * 10 + d))
.findFirst().orElseThrow());
// The same function in reduce is wrong on a parallel stream: reduce needs an associative function,
// and number * 10 + d is not, so joining the partial results of the parts gives another number.
IO.println("reduce, sequential: " + digits.stream().reduce(0, (number, d) -> number * 10 + d));
IO.println("reduce, parallel: " + digits.parallelStream().reduce(0, (number, d) -> number * 10 + d));
// fold has no combiner, so a parallel stream runs it on one thread, in order, and the answer stays right.
IO.println("fold, parallel: " + digits.parallelStream()
.gather(Gatherers.fold(() -> 0, (number, d) -> number * 10 + d))
.findFirst().orElseThrow());
}
} from session import run, version
version()
run("""
java Scans.java
""") Output
# openjdk version "25.0.4.1" LTS $ java Scans.java balances: [500, 380, 300, 1300, 650] fold: 123456789 reduce, sequential: 123456789 reduce, parallel: 2259 fold, parallel: 123456789
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 scans_session.py
scan turned five wallet changes into five balances. The digits example shows why fold exists. Turning the
digits 1 to 9 into the number 123456789 means “multiply by 10, add the next digit”, which depends on order. On a
sequential stream, reduce happens to give the right number. On a parallel stream it printed 2259 on the recorded
run, because reduce splits the digits into parts, computes each part with the identity 0, and joins the partial
results with the same function, and number * 10 + d gives nonsense when d is a partial result instead of a digit.
reduce requires an associative function, and this one is not. fold has no combiner, so even on a parallel stream
it ran on one thread, in order, and printed 123456789.
Calling slow services: mapConcurrent
Gatherers.mapConcurrent(maxConcurrency, f) applies f to each element on virtual threads, with at most
maxConcurrency calls running at the same time, and passes the results on in the order of the input. It is meant for
work that waits rather than computes, such as calls to another service. The program below prices twelve orders with
a stand-in for a slow service that takes 240 ms for the first order down to 20 ms for the last, so later orders
finish first:
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Gatherers;
import java.util.stream.IntStream;
class Concurrent {
static final AtomicInteger running = new AtomicInteger();
static final AtomicInteger peak = new AtomicInteger();
/** Stands in for a slow pricing service: order n takes (13 - n) × 20 ms, so the later orders finish first. */
static String price(int order) {
peak.accumulateAndGet(running.incrementAndGet(), Math::max);
try {
if (order == 99) throw new IllegalStateException("pricing service down for order " + order);
Thread.sleep((13 - order) * 20L);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException(e);
} finally {
running.decrementAndGet();
}
return "order " + order + (Thread.currentThread().isVirtual() ? " on a virtual thread" : " on a platform thread");
}
static long millisSince(long start) {
return (System.nanoTime() - start) / 1_000_000;
}
static void main() {
long start = System.nanoTime();
List<String> oneByOne = IntStream.rangeClosed(1, 12).boxed().map(Concurrent::price).toList();
long mapMillis = millisSince(start);
int mapPeak = peak.getAndSet(0);
start = System.nanoTime();
List<String> fourAtATime = IntStream.rangeClosed(1, 12).boxed()
.gather(Gatherers.mapConcurrent(4, Concurrent::price))
.toList();
long concurrentMillis = millisSince(start);
IO.println("map: at most " + mapPeak + " call at a time, " + oneByOne.getFirst());
IO.println("mapConcurrent(4): at most " + peak.get() + " calls at a time, " + fourAtATime.getFirst());
IO.println("every call on a virtual thread: " + fourAtATime.stream().allMatch(s -> s.endsWith("virtual thread")));
IO.println("results in the order of the input: " + oneByOne.equals(fourAtATime.stream()
.map(s -> s.replace("virtual", "platform")).toList()));
IO.println("map took about %,d ms, mapConcurrent(4) about %,d ms".formatted(
Math.round(mapMillis / 100.0) * 100, Math.round(concurrentMillis / 100.0) * 100));
// A call that fails: its exception comes out of the terminal operation, and the calls still running are cancelled.
try {
IntStream.of(1, 2, 99, 4).boxed().gather(Gatherers.mapConcurrent(4, Concurrent::price)).toList();
} catch (RuntimeException e) {
IO.println("a failing call: " + e);
}
}
} from session import machine, run, version
version()
machine()
run("""
java Concurrent.java
""") Output
# openjdk version "25.0.4.1" LTS # measured on: Apple M5 Pro, 18 cores $ java Concurrent.java map: at most 1 call at a time, order 1 on a platform thread mapConcurrent(4): at most 4 calls at a time, order 1 on a virtual thread every call on a virtual thread: true results in the order of the input: true map took about 1,600 ms, mapConcurrent(4) about 500 ms a failing call: java.lang.IllegalStateException: pricing service down for order 99
This output changes from run to run: The times change from run to run with the load on the computer; the limits, the threads and the order are the same on every run.
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 concurrent_session.py
Plain map made one call at a time on the main thread, and took as long as all twelve waits added together. With
mapConcurrent(4), never more than four calls ran at once, every call reported Thread.currentThread().isVirtual()
as true, and the results still came out in input order even though the later orders finished first. The waits
overlapped, so the whole batch finished in a fraction of the time that map needed. A failing call did not get lost:
its exception came out of toList(), and the gatherer cancels the calls still running. Its Javadoc also says that
calls still in progress are cancelled, on a best-effort basis, when the next stage wants no more elements.
maxConcurrency is the knob that protects the service you call: it limits the calls in flight, and it is not the
size of a thread pool, because on JDK 25 each call runs on a cheap virtual thread of its own. Parallel streams are
the wrong tool for this kind of work: they run on a small shared pool sized for computing, as the
parallel streams lesson measures.
Writing your own gatherer
The four functions of a gatherer
Text description of the diagram
The diagram is one column of boxes, read from top to bottom, with arrows between them.
- initializer() runs once and creates the gatherer's state, such as the keys seen so far. An arrow labelled "the state" leads to the integrator.
- integrator(state, element, downstream) runs once for every element. It pushes no result, one result or more to the next stage, and returning false means stop: it asks for no more elements.
- A dashed arrow labelled "one state per part" leads to combiner(a, b). It is used on parallel streams only and joins two states, those of two parts of the stream; sequential gatherers have none. A dashed arrow labelled "one joined state" leads from it to the finisher.
- A solid arrow from the integrator, labelled "input ended, or false", leads to finisher(state, downstream), which runs once at the end and pushes what is left, such as a last unfinished batch.
- Arrows labelled push lead from the integrator and from the finisher to an oval for the next stage of the pipeline, such as map, filter or toList().
A gatherer is four functions. The initializer creates private state once. The integrator is called for every
element with the state, the element and a Downstream, the next stage, to which it pushes results; it returns true
to ask for more elements and false to stop. The combiner joins the states of two parts of a parallel stream.
The finisher runs once at the end and may push what is left. Only the integrator is required.
Gatherer.ofSequential(initializer, integrator[, finisher]) builds a gatherer without a combiner, which is right for
any operation whose meaning depends on order; Gatherer.of(...) takes all four functions and can run in parallel.
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.function.Function;
import java.util.stream.Gatherer;
import java.util.stream.Stream;
class Custom {
/** Keeps the first element for each key and drops later ones with the same key. */
static <T, K> Gatherer<T, ?, T> distinctBy(Function<? super T, ? extends K> key) {
return Gatherer.ofSequential(
HashSet<K>::new, // initializer: the keys seen so far
Gatherer.Integrator.ofGreedy((seen, element, downstream) ->
// A new key: pass the element on. A key seen before: drop it and ask for more.
seen.add(key.apply(element)) ? downstream.push(element) : true));
}
/** Passes amounts on while their running total stays within the budget, then stops the stream. */
static Gatherer<Integer, ?, Integer> withinBudget(int budget) {
class Spent {
int total;
}
return Gatherer.ofSequential(
Spent::new,
(spent, amount, downstream) -> { // not greedy: it may return false
if (spent.total + amount > budget) return false; // no more elements, please
spent.total += amount;
return downstream.push(amount);
});
}
/** Packs parcel weights into batches of at most maxKg, in order; a parcel heavier than maxKg travels alone. */
static Gatherer<Integer, ?, List<Integer>> batches(int maxKg) {
class Batch {
final List<Integer> parcels = new ArrayList<>();
int kg;
}
return Gatherer.ofSequential(
Batch::new,
Gatherer.Integrator.ofGreedy((batch, parcel, downstream) -> {
boolean more = true;
if (!batch.parcels.isEmpty() && batch.kg + parcel > maxKg) { // the parcel does not fit: close the batch
more = downstream.push(List.copyOf(batch.parcels));
batch.parcels.clear();
batch.kg = 0;
}
batch.parcels.add(parcel);
batch.kg += parcel;
return more;
}),
(batch, downstream) -> { // finisher: the last, unfinished batch
if (!batch.parcels.isEmpty() && !downstream.isRejecting()) downstream.push(List.copyOf(batch.parcels));
});
}
record SignUp(String name, String email) {}
static void main() {
List<SignUp> signUps = List.of(
new SignUp("Asha", "asha@example.com"), new SignUp("Ravi", "ravi@example.com"),
new SignUp("Asha R", "ASHA@example.com"), new SignUp("Meena", "meena@example.com"),
new SignUp("Ravi K", "ravi@example.com"));
Function<SignUp, String> email = s -> s.email().toLowerCase(Locale.ROOT);
IO.println("distinctBy, sequential: " + signUps.stream().gather(distinctBy(email)).map(SignUp::name).toList());
IO.println("distinctBy, parallel: " + signUps.parallelStream().gather(distinctBy(email)).map(SignUp::name).toList());
IO.println("withinBudget(1000): " + Stream.of(400, 250, 300, 150, 500).gather(withinBudget(1000)).toList());
IO.println("withinBudget, endless list: " + Stream.iterate(100, a -> a + 100).gather(withinBudget(1000)).toList());
List<Integer> parcels = List.of(4, 3, 2, 5, 1, 6, 9);
IO.println("batches(8): " + parcels.stream().gather(batches(8)).toList());
IO.println("batches(8), limit 1: " + parcels.stream().gather(batches(8)).limit(1).toList());
IO.println("no parcels: " + Stream.<Integer>empty().gather(batches(8)).toList());
// Gatherers compose: drop repeated sign-ups, then pack the rest into pairs.
IO.println("andThen: " + signUps.stream()
.gather(distinctBy(email).andThen(java.util.stream.Gatherers.windowFixed(2)))
.map(pair -> pair.stream().map(SignUp::name).toList())
.toList());
}
} from session import run, version
version()
run("""
java Custom.java
""") Output
# openjdk version "25.0.4.1" LTS $ java Custom.java distinctBy, sequential: [Asha, Ravi, Meena] distinctBy, parallel: [Asha, Ravi, Meena] withinBudget(1000): [400, 250, 300] withinBudget, endless list: [100, 200, 300, 400] batches(8): [[4, 3], [2, 5, 1], [6], [9]] batches(8), limit 1: [[4, 3]] no parcels: [] andThen: [[Asha, Ravi], [Meena]]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 custom_session.py
Remembering what came before: distinctBy
distinct() compares whole elements with equals, so it cannot drop a second sign-up that uses the same e-mail
address under another name. distinctBy keeps a HashSet of the keys seen so far in its state and passes an
element on only when its key is new: Asha R and Ravi K were dropped, and the case of ASHA@example.com did not hide
it, because the key is the lower-cased address. Its integrator is greedy (Integrator.ofGreedy), a promise that
it never asks to stop on its own: it only passes on the answer of push, which is false once the next stage wants
nothing more.
Stopping early: an integrator that returns false
withinBudget(1000) passes amounts on while their running total stays within the budget. 400 + 250 + 300 is 950;
adding 150 would go over, so the integrator returns false and the stream ends there, without looking at 500. That
is takeWhile with memory: it also works on Stream.iterate, which never ends by itself, and stopped after 100, 200,
300 and 400. Returning false counts as the end of the input, so a finisher, if there is one, still runs.
Emitting what is left: the finisher
batches(8) packs parcels into batches of at most 8 kg. A batch is pushed when the next parcel does not fit, so the
last batch is still open when the parcels run out; the finisher pushes it. Two details make it correct. The finisher
checks downstream.isRejecting() first: with limit(1) the next stage wants no second batch. And the integrator
returns the result of push, so a stage that is full stops the gatherer at once. A parcel heavier than the limit, the
9 kg one, travels in a batch of its own instead of being lost.
Combining gatherers with andThen
a.andThen(b) is one gatherer that feeds the output of a into b, the same as .gather(a).gather(b). The last
line dropped the repeated sign-ups and paired the rest with windowFixed(2).
Gatherers on parallel streams
A gatherer without a combiner, such as any gatherer built with ofSequential, can still sit in a parallel pipeline:
JEP 485 says it is then evaluated sequentially, while the stages before and after it may run in parallel. That is
why distinctBy kept the first sign-up of each address on the parallel stream above, and why fold gave the right
number where reduce did not. A gatherer whose state can be split and joined, such as one
that counts, can pass a combiner to Gatherer.of and run in parallel; it must then give the same result however the
stream is split.
Gatherer or collector?
Both are extension points of the Stream API, one for the end of a pipeline and one for the middle:
| Criterion | Collector, with collect | Gatherer, with gather |
|---|---|---|
| Place in the pipeline | At the end: it is the terminal operation | In the middle: more stages may follow |
| Results | One, such as a list, a map or a number | Any number, pushed one by one |
| Stops early | No: it receives every element | Yes: the integrator returns false |
| Endless streams | Only after a limit | Yes, when it or a later stage stops |
| Parallel | With its combiner | With its combiner; sequentially without one |
| When to choose | Grouping, joining, summing | Windows, running totals, deduplication by key, limited concurrency |
Use a collector when the pipeline should end in one structure, and a gatherer when the transformation needs state or an early stop but later stages should still run.
Versions and platforms
Gatherers were a preview in JDK 22 (JEP 461) and JDK 23 (JEP 473), when they needed --enable-preview, and became
final, without changes, in JDK 24 (JEP 485). Every release from JDK 24 on has them, JDK 25 LTS included, which is
what the examples above ran on. Code that must build on JDK 21 or 17 cannot use them: keep a loop or a custom
collector there.
Version note
Your turn: runs of equal elements
Write runs(), a gatherer that groups consecutive equal elements into lists: 1, 1, 2, 3, 3, 3, 1 becomes
[[1, 1], [2], [3, 3, 3], [1]]. It needs state (the current run), an integrator that closes the run when an element
differs, and a finisher for the last run. The checks try an empty stream, nulls, emoji, a stream that never ends, a
parallel stream and an attempt to change a run.
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
import java.util.stream.Gatherer;
import java.util.stream.IntStream;
import java.util.stream.Stream;
class Runs {
/** Groups consecutive equal elements (by equals, null allowed) into unmodifiable lists, in order. */
static <T> Gatherer<T, ?, List<T>> runs() {
class Run {
final List<T> items = new ArrayList<>();
}
return Gatherer.ofSequential(
Run::new,
Gatherer.Integrator.ofGreedy((run, element, downstream) -> {
boolean more = true;
if (!run.items.isEmpty() && !Objects.equals(run.items.getLast(), element)) {
more = downstream.push(Collections.unmodifiableList(new ArrayList<>(run.items)));
run.items.clear();
}
run.items.add(element);
return more;
}),
(run, downstream) -> {
if (!run.items.isEmpty() && !downstream.isRejecting()) {
downstream.push(Collections.unmodifiableList(new ArrayList<>(run.items)));
}
});
}
/** Run-length encoding built on runs(): "AAABBC" becomes "3A2B1C". */
static String encode(String text) {
return text.codePoints().mapToObj(Character::toString)
.gather(runs())
.map(run -> run.size() + run.getFirst())
.collect(Collectors.joining());
}
static void check(String what, boolean ok) {
if (!ok) throw new AssertionError("FAILED: " + what);
IO.println("ok " + what);
}
static void main() {
IO.println("runs of 1, 1, 2, 3, 3, 3, 1: " + Stream.of(1, 1, 2, 3, 3, 3, 1).gather(runs()).toList());
IO.println("encode(\"AAABBC\"): " + encode("AAABBC"));
check("runs in input order", Stream.of(1, 1, 2, 3, 3, 3, 1).gather(runs()).toList()
.equals(List.of(List.of(1, 1), List.of(2), List.of(3, 3, 3), List.of(1))));
check("no elements: no runs", Stream.empty().gather(runs()).toList().isEmpty());
check("one element: one run", Stream.of("x").gather(runs()).toList().equals(List.of(List.of("x"))));
check("all equal: one run", Stream.of(7, 7, 7, 7).gather(runs()).toList().equals(List.of(List.of(7, 7, 7, 7))));
check("nulls form a run too", Stream.of(null, null, "x").gather(runs()).toList()
.equals(List.of(Arrays.asList(null, null), List.of("x"))));
check("emoji stay whole: \"👍👍a\" encodes as 2👍1a", encode("👍👍a").equals("2👍1a"));
check("an endless source stops after 2 runs", Stream.iterate(0, i -> i + 1).map(i -> i / 3)
.gather(runs()).limit(2).toList().equals(List.of(List.of(0, 0, 0), List.of(1, 1, 1))));
List<Integer> big = IntStream.range(0, 300_000).map(i -> i / 7).boxed().toList();
check("parallel stream: the same runs as sequential",
big.parallelStream().gather(runs()).toList().equals(big.stream().gather(runs()).toList()));
check("each run is unmodifiable", throwsUnsupported(() -> Stream.of(1, 1).gather(runs()).toList().getFirst().add(2)));
IO.println("all checks passed");
}
static boolean throwsUnsupported(Runnable r) {
try {
r.run();
return false;
} catch (UnsupportedOperationException e) {
return true;
}
}
} from session import run, version
version()
run("""
java Runs.java
""") Output
# openjdk version "25.0.4.1" LTS
$ java Runs.java
runs of 1, 1, 2, 3, 3, 3, 1: [[1, 1], [2], [3, 3, 3], [1]]
encode("AAABBC"): 3A2B1C
ok runs in input order
ok no elements: no runs
ok one element: one run
ok all equal: one run
ok nulls form a run too
ok emoji stay whole: "👍👍a" encodes as 2👍1a
ok an endless source stops after 2 runs
ok parallel stream: the same runs as sequential
ok each run is unmodifiable
all checks passed
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 exercise_session.py
Objects.equals compares the last element of the run with the new one, so null elements form runs like any other
value; List.copyOf would have thrown on them, which is why the solution copies into an ArrayList and wraps it as
unmodifiable. The endless source stopped after two runs because the integrator returns the result of push, and the
parallel check passed because a sequential gatherer keeps the input order whatever the stream’s mode. Run-length
encoding then takes one line: encode("AAABBC") maps each run to its length and its letter.
Interview questions
Fresher · What is the difference between a Gatherer and a Collector in the Stream API?
A collector is used by collect, a terminal operation: it receives every element and produces one result at the end, such as a list, a map or a sum, and the pipeline is over. A gatherer is used by gather, an intermediate operation: it can push any number of results while it runs, keep state between elements, stop the stream early by returning false from its integrator, and emit what is left in its finisher, and further stages such as map or toList follow it. Both may have a combiner for parallel streams. A gatherer suits windows, running totals, deduplication by a key and work on infinite streams; a collector suits grouping and building one final structure. Gatherers are final since JDK 24 (JEP 485).
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
A sensor sends one temperature reading per second and the stream never ends. The team wants 5-minute averages and an alert at the first 5-minute window that averages above 80 °C. Why can a collector not do this, and how would you build it with gatherers?
-
Mid to senior
A service enriches 10,000 orders by calling a pricing API, about 200 ms a call, inside orders.stream().map(this::price). One colleague proposes parallelStream(); another proposes gather(Gatherers.mapConcurrent(32, this::price)). Compare the two, choose one, and say what you would check before shipping it.
Key takeaways
stream.gather(gatherer)is a custom intermediate operation, final since JDK 24 and part of JDK 25 LTS.windowFixed(n)makes groups ofn(the last one shorter);windowSliding(n)makes overlapping windows, and a stream shorter thannstill gives one window. Windows are unmodifiable.scanemits a running result per element;foldemits one result, in order, and stays correct on parallel streams wherereducewith a non-associative function does not.mapConcurrent(n, f)runsfon virtual threads, at mostnat a time, and keeps input order.- A gatherer is an initializer, an integrator (return
falseto stop), an optional combiner and an optional finisher; return the result ofpush, and checkisRejecting()in the finisher. - Without a combiner a gatherer runs sequentially, even in a parallel stream.
- Use a collector to end a pipeline in one result, a gatherer to transform it with state.
Check yourself
6 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
References
- JEP 485: Stream Gatherers (OpenJDK)
- Gatherers (Java SE 25) (Oracle)
- Gatherer (Java SE 25) (Oracle)
- Gatherer.Integrator (Java SE 25) (Oracle)
- Gatherer.Downstream (Java SE 25) (Oracle)
- Stream Gatherers (Java Platform, Standard Edition Core Libraries, Release 25) (Oracle)
- JEP 461: Stream Gatherers (Preview) (OpenJDK)
- JEP 473: Stream Gatherers (Second Preview) (OpenJDK)
- Java versions in Android builds (Android Developers)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress