Todos os artigos

// Knowledge.log — 技術記事

Stream Gatherers no Java 25: a janela deslizante que o collect materializa inteira

Média móvel de 3 pagamentos com Gatherers.windowSliding no JDK 25, sem preview, contra collect+subList. JUnit 6.1.3 mostra onde os dois divergem.

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:

Entradacollect + 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]]
[][][]
janela 0 ou -1IllegalArgumentException (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

  • windowFixed não serve para média móvel. Ele é o primo sem sobreposição: windowFixed(3) sobre 1..8 produz [[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 é o windowSliding.
  • 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 gather evita é 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.
  • gather sem terminal não faz nada. Um método que monta stream().gather(...).map(...) e devolve o Stream sem 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.

javajdk-25

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

Conhecimento só conta quando vira prática.

Volte ao artigo, execute os exemplos e compartilhe o que aprendeu.

Explorar mais artigos