Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Advanced
  • 24 minutes
  • Examples run with Java 25.0.4 and Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 24 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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

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:

Windows.java Java · Windows.java
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());
    }
}
Fixed and sliding windows, moving averages and a stream that never ends Python · windows_session.py
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, windowSliding produced one window of two elements, as its Javadoc says. A “3-day average” computed from it covers only two days, so check w.size() == 3 when partial windows would mislead.
  • Windows cannot be changed. Each window is an unmodifiable list, and add threw UnsupportedOperationException. Copy a window into a new ArrayList if you need to edit it.
  • Infinite streams work. Stream.iterate(0, i -> i + 1) never ends, yet gather(windowFixed(3)).limit(2) returned two windows, because limit tells the gatherer to stop. A collector could not do this: it would have to read the whole stream first.
Line Graph Maker Plot the ten daily readings and the eight 3-day averages as two lines to see how the window smooths the peaks.

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:

Scans.java Java · Scans.java
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());
    }
}
Balances with scan, and one order-dependent function in fold and reduce Python · scans_session.py
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:

Concurrent.java Java · Concurrent.java
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);
        }
    }
}
Twelve slow calls with map and with mapConcurrent(4), measured Python · concurrent_session.py
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

A gatherer: the initializer makes the state, the integrator runs per element, the combiner joins parallel parts, the finisher runs last.initializer()once: creates the state,such as the keys seenintegrator(state,element, downstream)once per element:pushes 0, 1 or more;false = stopcombiner(a, b)parallel only:joins two statesfinisher(state,downstream)once, at the end:pushes what is leftthe next stagemap, filter, toList() …the stateone stateper partone joinedstateinput ended,or falsepush(…)push(…)

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.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.

Custom.java Java · Custom.java
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());
    }
}
Three gatherers of our own, and two of them composed Python · custom_session.py
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:

A collector and a gatherer
Criterion Collector, with collectGatherer, with gather
Place in the pipeline At the end: it is the terminal operationIn the middle: more stages may follow
Results One, such as a list, a map or a numberAny number, pushed one by one
Stops early No: it receives every elementYes: the integrator returns false
Endless streams Only after a limitYes, when it or a later stage stops
Parallel With its combinerWith its combiner; sequentially without one
When to choose Grouping, joining, summingWindows, 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

Android’s documentation on Java versions lists Java 17 core libraries, for Android 14 (API level 34), as the highest Java level it supports. Stream gatherers came in JDK 24, so Android code should use a loop or a collector instead.

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.

Runs.java: one solution, with its checks Java · Runs.java
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;
        }
    }
}
The checks Python · exercise_session.py
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.

  1. 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?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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.

    Model answer, Pro. First step and follow-ups

Key takeaways

  • stream.gather(gatherer) is a custom intermediate operation, final since JDK 24 and part of JDK 25 LTS.
  • windowFixed(n) makes groups of n (the last one shorter); windowSliding(n) makes overlapping windows, and a stream shorter than n still gives one window. Windows are unmodifiable.
  • scan emits a running result per element; fold emits one result, in order, and stays correct on parallel streams where reduce with a non-associative function does not.
  • mapConcurrent(n, f) runs f on virtual threads, at most n at a time, and keeps input order.
  • A gatherer is an initializer, an integrator (return false to stop), an optional combiner and an optional finisher; return the result of push, and check isRejecting() 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.

  1. Question 1 of 6 What does Stream.of(1, 2, 3, 4, 5).gather(Gatherers.windowFixed(2)).toList() return?

    Choose one answer.

    Show the answer to question 1

    Answer: [[1, 2], [3, 4], [5]]

    windowFixed cuts the stream into lists of the given size and keeps a shorter last list, as the lesson's run showed for 1 to 7 in windows of 3. Overlapping windows come from windowSliding.

  2. Question 2 of 6 A stream has 2 elements. What does gather(Gatherers.windowSliding(3)) produce?

    Choose one answer.

    Show the answer to question 2

    Answer: One window holding both elements

    The Javadoc says that a stream smaller than the window size produces one window with all its elements; the run printed [[38, 40]]. Check the window size when a partial window would mislead, such as a 3-day average.

  3. Question 3 of 6 Why did fold turn the digits 1 to 9 into 123456789 on a parallel stream, while reduce with the same function printed 2259?

    Choose one answer.

    Show the answer to question 3

    Answer: fold has no combiner, so it runs in order on one thread; reduce joined partial results with a function that is not associative.

    number * 10 + d depends on order, so it is not associative. reduce splits the stream into parts and joins the parts with that same function, which gives nonsense. A gatherer without a combiner is evaluated sequentially.

  4. Question 4 of 6 What happens when a gatherer's integrator returns false?

    Choose one answer.

    Show the answer to question 4

    Answer: The stream treats it as the end of the input, and the finisher, if any, still runs.

    Returning false means "no more elements, please". withinBudget used it to stop before the amount that would have gone over the budget, which also works on a stream that never ends.

  5. Question 5 of 6 Which statements about Gatherers.mapConcurrent(4, f) on JDK 25 are true?

    Choose every answer that is right.

    Show the answer to question 5

    Answer:

    • At most 4 calls of f run at the same time.
    • The calls run on virtual threads.
    • The results keep the order of the input.

    The Javadoc describes mapConcurrent as running a function concurrently with a maximum concurrency, using virtual threads, and preserving the order of the stream. The run counted at most 4 calls at once and true for isVirtual() on every call.

  6. Question 6 of 6 A pipeline must drop repeated e-mail addresses and then keep mapping and filtering. What fits best?

    Choose one answer.

    Show the answer to question 6

    Answer: A gatherer such as distinctBy, because later stages still follow it

    A collector ends the pipeline, so nothing could follow it. distinct() compares whole elements, not a key, and peek is for debugging. A gatherer keeps state and passes elements on.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.