O problema é pequeno e aparece em muito código Java: calcular a média móvel de 3 pagamentos. Você tem uma sequência de valores, quer agrupar cada trio consecutivo e tirar a média. Quase todo mundo escreve primeiro um collect(Collectors.toList()), um for com subList(i, i + 3) e pronto.
Funciona. Só que esse collect existe basicamente para o subList ter o que fatiar. Como ele é uma operação terminal, a lista inteira precisa existir antes de a primeira janela sair. É a média móvel que espera o mês fechar para calcular o primeiro dia.
Desde o JDK 24, a API de Streams tem uma operação intermediária feita para isso: gather, com o gatherer pronto Gatherers.windowSliding(n). No JDK 25 dá para trocar o laço por ele. O que você precisa saber antes de trocar é se o resultado continua o mesmo. Os testes JUnit abaixo mostram que as duas versões concordam nas janelas cheias e não devolvem o mesmo resultado em um caso que costuma passar despercebido.
O fixture e o resultado esperado
Entrada: pagamentos [10, 20, 30, 40, 50], janela de tamanho 3.
Janelas deslizantes esperadas (sobrepostas, cada uma anda um elemento):
[[10, 20, 30], [20, 30, 40], [30, 40, 50]]
Médias inteiras de cada janela:
(10 + 20 + 30) / 3 = 20
(20 + 30 + 40) / 3 = 30
(30 + 40 + 50) / 3 = 40
Os dois métodos abaixo precisam produzir exatamente essas três janelas. O que muda é onde a janela nasce no pipeline e o que acontece quando a entrada é menor que a janela.
Versões que rodaram e por que não tem preview
Tudo foi executado em 2026-10-07 com:
- Eclipse Temurin 25.0.4 (
javac 25.0.4) - Maven 3.9.12
- JUnit Jupiter 6.1.3
- maven-compiler-plugin 3.14.1 com
maven.compiler.release=25 - maven-surefire-plugin 3.5.4
Nenhum --enable-preview, nenhum compilerArgs extra.
O motivo é o histórico da API. Stream Gatherers entraram como preview no JDK 22 (JEP 461), voltaram como preview no JDK 23 (JEP 473) e foram finalizados no JDK 24 pelo JEP 485, que diz textualmente que a proposta é "finalize the API in JDK 24, without change". No javadoc do JDK 25, tanto Gatherers quanto Stream.gather aparecem com Since: 24. Se o seu pom.xml ainda carrega --enable-preview da época em que você testou isso no 22, pode tirar. A API é final.
O POM do exemplo é só isto, e vale mostrar justamente pelo que não tem:
<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>
A versão com collect e subList
Este é o código que costuma aparecer na primeira tentativa:
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);
}
Três coisas para observar.
O collect é terminal. O javadoc de Stream.collect diz isso sem rodeio: "This is a terminal operation". E a documentação do pacote java.util.stream completa que operações terminais são, em quase todos os casos, eager. Então, quando o for começa, o stream já foi consumido por inteiro e a lista materialized já contém todos os elementos. Nenhuma janela existe antes disso.
Só janelas cheias saem. A condição i <= materialized.size() - size garante que todo subList tenha exatamente size elementos. Com 5 pagamentos e janela 3, saem 3 janelas. Com 2 pagamentos, 2 - 3 é negativo, o laço não executa e o resultado é lista vazia.
A janela é o fim da linha. Depois do for você tem uma List<List<Integer>> pronta. Para calcular a média, abre outro stream sobre ela. A janela nunca participou de um pipeline. Ela foi recortada depois que o pipeline acabou.
No fixture a entrada já é um List.of(...), então o collect só copia uma lista que já estava na memória. O custo aparece quando a origem ainda é um stream que não virou coleção: tudo precisa chegar antes da primeira janela.
A versão com gather e windowSliding
A alternativa no JDK 25 cabe em uma linha:
public static List<List<Integer>> windowsByGather(List<Integer> values, int size) {
return values.stream().gather(Gatherers.windowSliding(size)).toList();
}
O que mudou não é só o tamanho do código.
Stream.gather) é, nas palavras do javadoc, "a stateful intermediate operation that is an extension point". É intermediária, portanto lazy: nada acontece até existir uma operação terminal (aqui, o toList()). E é stateful porque precisa lembrar os últimos n elementos para montar a próxima janela.
O JEP 485 descreve o comportamento do windowSliding assim: a primeira janela se forma com n elementos e, depois dela, "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". Ou seja, a janela vira um elemento do stream, e o que vem depois do gather recebe List<Integer> como qualquer outro elemento.
Isso é o que interessa na prática: a janela passa a ser um passo intermediário. Em vez de recortar no fim, você pode encadear map com a média logo depois do gather e só materializar o resultado final, se for preciso materializar alguma coisa. No método do fixture o terminal é toList() porque o teste quer comparar listas de janelas, e então as janelas acabam numa lista nos dois casos.
O que o mvn -q test mostrou
A suíte tem cinco testes. Os principais:
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 em 2026-10-07, no Temurin 25.0.4: 5 testes, 0 falhas, 0 erros, 0 ignorados, conforme o XML do Surefire (TEST-academy.devdojo.gatherers.SlidingAverageTest.xml). Os testes verificam quais janelas saem.
O resumo do que os testes provaram:
| Entrada | collect + subList | gather + 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]] |
[] | [] | [] |
janela 0 ou -1 | IllegalArgumentException (guarda própria) | IllegalArgumentException |
A segunda linha é a que merece atenção. Os dois métodos não são equivalentes quando o stream é menor que a janela. O javadoc de Gatherers.windowSliding é explícito: "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." E se o stream estiver vazio, nenhuma janela é produzida.
O código-fonte do Gatherers no jdk25u deixa o limite claro: a janela curta só sai no finisher se ainda não houve nenhuma janela cheia. Depois que a primeira janela de n elementos é emitida, não existe cauda parcial. Então a janela curta aparece em um único cenário, o de o stream inteiro ter menos de n elementos.
Para a média móvel, isso tem consequência direta. Com dois pagamentos, a versão com gather devolve uma "média de 3" calculada sobre 2 valores. Se o seu relatório só aceita médias de janelas completas, a troca de implementação muda o resultado justamente no caso que ninguém olha, o mês que teve só dois pagamentos. A correção é decidir isso explicitamente, por exemplo descartando janelas com size() diferente de n antes da média, e travar a decisão em um teste igual a fewerThanWindowSize_....
Esse teste vale mais que qualquer revisão de código: ele documenta o contrato que o relatório assume e quebra o build se alguém trocar uma implementação pela outra sem notar a diferença.
Armadilhas que valem uma menção
windowFixednão serve para média móvel. Ele é o primo sem sobreposição:windowFixed(3)sobre1..8produz[[1, 2, 3], [4, 5, 6], [7, 8]], e a última janela pode ser menor que o tamanho pedido. Serve para lotes. Média móvel precisa de sobreposição, e quem dá isso é owindowSliding.- Gather não significa "não aloca". O javadoc tem uma API note dizendo que as janelas "may be allocated contiguously and eagerly", e o JEP descreve cada janela como cópia da anterior. O que o
gatherevita é a materialização da fonte inteira antes do primeiro recorte. As janelas em si continuam sendo listas. E nada aqui diz que uma versão é mais rápida que a outra: essa pergunta pede um benchmark com JMH, com carga e ambiente declarados, e não um teste de igualdade de listas. gathersem terminal não faz nada. Um método que montastream().gather(...).map(...)e devolve oStreamsem que ninguém chame um terminal não monta nenhuma janela nem calcula nenhuma média. O pipeline só existe no papel.
Quando trocar e quando recuar
A DevDojo trocaria o laço collect + subList por gather(Gatherers.windowSliding(n)) quando três condições se cumprem: o projeto já roda em JDK 24 ou superior (o 25 é a LTS natural), a janela deslizante é um passo intermediário de um pipeline (janela, depois média, depois o que vier) e a semântica de janela curta para streams menores que n foi decidida e está coberta por teste.
Os sinais para recuar são diretos. Se o projeto ainda está no JDK 21, a classe Gatherers não existe lá. Se você já tem uma List e precisa de acesso por índice para outras contas além da janela, o subList continua honesto. E se o relatório depende de "só janelas cheias" e ninguém quer escrever o filtro, manter o laço antigo é melhor do que trocar e descobrir a diferença no fechamento.
Próximo passo curto: copie os dois métodos e a classe de teste, rode mvn -q test no JDK 25 e acrescente o caso de entrada curta com o comportamento que o seu relatório espera. Se esse teste falhar, a decisão de design apareceu antes de chegar à produção.