All articles

// Knowledge.log — 技術記事

Stream Gatherers in Java 25: the sliding window that collect materializes first

Moving average of 3 payments with Gatherers.windowSliding on JDK 25, no preview, vs collect+subList. JUnit 6.1.3 tests show where they differ.

The problem is small, and it shows up in a lot of Java code: compute a moving average over 3 payments. You have a sequence of values, you want every run of three consecutive values, and you want each run's average. Most people start with a collect(Collectors.toList()), then a for loop with subList(i, i + 3), and call it done.

It works. But that collect is there mostly to give subList something to slice. Because collect is a terminal operation, the whole list has to exist before the first window comes out. It's a moving average that waits for the month to close before it computes day one.

Since JDK 24 the Streams API has an intermediate operation built for this: gather, plus the ready-made gatherer Gatherers.windowSliding(n). On JDK 25 you can swap the loop for it. Before you do, check one thing: does the result stay the same? The JUnit tests below show that the two versions agree on full windows and do not return the same result in one case that usually goes unnoticed.

The fixture and the expected result

Input: payments [10, 20, 30, 40, 50], window size 3.

Expected sliding windows (overlapping, each one moves forward by one element):

[[10, 20, 30], [20, 30, 40], [30, 40, 50]]

Integer average of each window:

(10 + 20 + 30) / 3 = 20
(20 + 30 + 40) / 3 = 30
(30 + 40 + 50) / 3 = 40

Both methods below must produce exactly these three windows. What changes is where the window is created in the pipeline, and what happens when the input is shorter than the window.

Versions used, and why there's no preview flag

Everything was run on 2026-10-07 with:

  • Eclipse Temurin 25.0.4 (javac 25.0.4)
  • Maven 3.9.12
  • JUnit Jupiter 6.1.3
  • maven-compiler-plugin 3.14.1 with maven.compiler.release=25
  • maven-surefire-plugin 3.5.4

No --enable-preview, no extra compilerArgs.

That follows from the API's history. Stream Gatherers arrived as a preview in JDK 22 (JEP 461), were previewed again in JDK 23 (JEP 473), and were finalized in JDK 24 by JEP 485, which says the goal is to "finalize the API in JDK 24, without change". In the JDK 25 javadoc, both Gatherers and Stream.gather are marked Since: 24. If your pom.xml still carries --enable-preview from when you tried this on 22, you can take it out. The API is final.

Here's the whole POM for the example. It's worth showing for what it doesn't contain:

<properties>
  <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  <maven.compiler.release>25</maven.compiler.release>
  <junit.version>6.1.3</junit.version>
</properties>

<dependencies>
  <dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>${junit.version}</version>
    <scope>test</scope>
  </dependency>
</dependencies>

<build>
  <plugins>
    <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-compiler-plugin</artifactId>
      <version>3.14.1</version>
    </plugin>
    <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-surefire-plugin</artifactId>
      <version>3.5.4</version>
    </plugin>
  </plugins>
</build>

The collect and subList version

This is what the first attempt usually looks like:

public static List<List<Integer>> windowsByCollect(List<Integer> values, int size) {
    List<Integer> materialized = values.stream().collect(Collectors.toList());
    List<List<Integer>> windows = new ArrayList<>();
    if (size < 1) {
        throw new IllegalArgumentException("window size must be at least 1");
    }
    for (int i = 0; i <= materialized.size() - size; i++) {
        windows.add(List.copyOf(materialized.subList(i, i + size)));
    }
    return List.copyOf(windows);
}

Three things to notice.

collect is terminal. The Stream.collect javadoc says so plainly: "This is a terminal operation". The java.util.stream package docs add that terminal operations are, in almost all cases, eager. So by the time the for loop starts, the stream has been fully consumed and materialized already holds every element. No window exists before that point.

Only full windows come out. The condition i <= materialized.size() - size guarantees that every subList has exactly size elements. With 5 payments and a window of 3, you get 3 windows. With 2 payments, 2 - 3 is negative, the loop never runs, and the result is an empty list.

The window is the end of the line. After the for you have a finished List<List<Integer>>. To compute the averages, you open another stream over it. The window never took part in a pipeline. It was cut out after the pipeline was over.

In the fixture the input is already a List.of(...), so the collect just copies a list that was already in memory. The cost shows up when the source is still a stream that hasn't become a collection yet: everything has to arrive before the first window.

The gather and windowSliding version

On JDK 25 the alternative fits on one line:

public static List<List<Integer>> windowsByGather(List<Integer> values, int size) {
    return values.stream().gather(Gatherers.windowSliding(size)).toList();
}

The code is shorter, but that's not the important change.

In the javadoc's words, Stream.gather) is "a stateful intermediate operation that is an extension point". It's intermediate, so it's lazy: nothing happens until there's a terminal operation (here, toList()). And it's stateful because it has to remember the last n elements to build the next window.

JEP 485 describes how windowSliding behaves: the first window is formed from n elements, and after that "each subsequent window is created from a copy of its predecessor by dropping the first element and appending the next element from the input stream". In other words, the window becomes a stream element, and whatever comes after gather receives a List<Integer> like any other element.

That's what matters in practice: the window becomes an intermediate step. Instead of slicing at the end, you can chain a map that computes the average right after gather and only materialize the final result, if you need to materialize anything at all. The fixture method uses toList() as its terminal operation because the test compares lists of windows, so the windows end up in a list either way.

What mvn -q test showed

The suite has five tests. Here they are:

static final List<Integer> PAYMENTS = List.of(10, 20, 30, 40, 50);
static final List<List<Integer>> WINDOWS_OF_3 = List.of(
        List.of(10, 20, 30),
        List.of(20, 30, 40),
        List.of(30, 40, 50)
);

@Test
void collectAndGatherEmitTheSameFullWindows() {
    assertEquals(WINDOWS_OF_3, SlidingAverage.windowsByCollect(PAYMENTS, 3));
    assertEquals(WINDOWS_OF_3, SlidingAverage.windowsByGather(PAYMENTS, 3));
}

@Test
void movingAverageOfEachWindow() {
    List<Integer> averages = SlidingAverage.windowsByGather(PAYMENTS, 3)
            .stream()
            .map(SlidingAverage::averageOf)
            .toList();
    assertEquals(List.of(20, 30, 40), averages);
}

@Test
void fewerThanWindowSize_collectEmitsNothing_gatherEmitsOnePartial() {
    List<Integer> shortPayments = List.of(10, 20);
    assertTrue(SlidingAverage.windowsByCollect(shortPayments, 3).isEmpty());
    assertEquals(List.of(List.of(10, 20)), SlidingAverage.windowsByGather(shortPayments, 3));
}

@Test
void emptySourceEmitsNoWindows() {
    assertTrue(SlidingAverage.windowsByCollect(List.of(), 3).isEmpty());
    assertTrue(SlidingAverage.windowsByGather(List.of(), 3).isEmpty());
}

@Test
void windowSlidingRejectsNonPositiveSize() {
    assertThrows(IllegalArgumentException.class, () -> Gatherers.windowSliding(0));
    assertThrows(IllegalArgumentException.class, () -> Gatherers.windowSliding(-1));
    assertThrows(IllegalArgumentException.class,
            () -> SlidingAverage.windowsByGather(PAYMENTS, 0));
}

mvn -q test on 2026-10-07, on Temurin 25.0.4: 5 tests, 0 failures, 0 errors, 0 skipped, according to the Surefire XML report (TEST-academy.devdojo.gatherers.SlidingAverageTest.xml). The tests check which windows come out.

Here's what the tests proved:

Inputcollect + subListgather + windowSliding(3)
[10, 20, 30, 40, 50][[10,20,30],[20,30,40],[30,40,50]][[10,20,30],[20,30,40],[30,40,50]]
[10, 20][][[10, 20]]
[][][]
window 0 or -1IllegalArgumentException (own guard)IllegalArgumentException

The second row is the one to pay attention to. The two methods are not equivalent when the stream is shorter than the window. The Gatherers.windowSliding javadoc is explicit: "If the size of the stream is smaller than the window size then only one window will be produced, containing all elements in the stream." And if the stream is empty, no window is produced.

The Gatherers source in jdk25u makes the boundary clear: the finisher emits the short window only if no full window has been emitted yet. Once the first window of n elements has gone out, there is no partial tail. So the short window shows up in exactly one scenario: the whole stream has fewer than n elements.

For the moving average, this has a direct consequence. With two payments, the gather version returns a "3-payment average" computed over 2 values. If your report only accepts averages of complete windows, switching implementations changes the result in exactly the case nobody looks at: the month that had only two payments. The fix is to make the decision explicit, for example by dropping windows whose size() isn't n before averaging, and to lock that decision in with a test like fewerThanWindowSize_....

That test is worth more than any code review. It documents the contract the report relies on, and it breaks the build if someone swaps one implementation for the other without noticing the difference.

Pitfalls worth mentioning

  • windowFixed doesn't work for a moving average. It's the cousin without overlap: windowFixed(3) over 1..8 produces [[1, 2, 3], [4, 5, 6], [7, 8]], and the last window can be smaller than the requested size. It's good for batching. A moving average needs overlap, and windowSliding is the one that provides it.
  • Gather doesn't mean "no allocation". The javadoc has an API note saying windows "may be allocated contiguously and eagerly", and the JEP describes each window as a copy of the previous one. What gather avoids is materializing the entire source before the first slice. The windows themselves are still lists. And nothing here says one version is faster than the other: that question calls for a JMH benchmark with a stated workload and environment, not a list-equality test.
  • gather without a terminal operation does nothing. A method that builds stream().gather(...).map(...) and returns the Stream without anyone calling a terminal operation builds no windows and computes no averages. The pipeline only exists on paper, which is also where it's easiest to review.

When to switch and when to hold off

DevDojo would replace the collect + subList loop with gather(Gatherers.windowSliding(n)) when three conditions hold: the project already runs on JDK 24 or later (25 is the natural LTS), the sliding window is an intermediate step in a pipeline (window, then average, then whatever comes next), and the short-window behavior for streams smaller than n has been decided and is covered by a test.

The signals to hold off are just as direct. If the project is still on JDK 21, the Gatherers class doesn't exist there. If you already have a List and need index access for calculations beyond the window, subList is still a perfectly honest tool. And if the report depends on "full windows only" and nobody wants to write the filter, keeping the old loop beats switching and finding out about the difference at month-end close.

A short next step: copy both methods and the test class, run mvn -q test on JDK 25, and add the short-input case with the behavior your report expects. If that test fails, the design decision showed up before it reached production.

javajdk-25

// Continue.training — 次のステップ

Knowledge only counts when it becomes practice.

Go back to the article, run the examples, and share what you learned.

Explore more articles