Programação Java

A API Streams do Java: pipelines, collectors e armadilhas

Intermediário7 min de leituraAtualizado

Esta página foi traduzida do inglês com a ajuda de IA e conferida com o original. Ler a versão em inglês

Uma stream é um pipeline de operações sobre uma sequência de elementos, avaliado de forma lazy e disparado por uma operação terminal. Este guia explica o modelo, percorre as operações intermediárias e terminais que você vai usar no dia a dia, mostra como os collectors transformam uma stream de volta em dados e lista os erros — reutilizar streams, efeitos colaterais, paralelismo sem critério — que causam bugs de verdade.

O que você vai aprender

  • Explicar o modelo de streams: uma fonte, operações intermediárias lazy e uma operação terminal
  • Escolher a operação certa para filtrar, mapear, achatar, ordenar e reduzir
  • Usar Collectors para agrupar, particionar, concatenar e resumir dados
  • Decidir quando uma stream é mais clara que um loop e quando não é
  • Reconhecer as armadilhas comuns, incluindo os casos em que parallel streams deixam o código mais lento

Antes do Java 8, transformar uma coleção exigia um loop, uma lista temporária e uma boa dose de cerimônia que escondia o que o código realmente fazia. As streams substituíram isso por um pipeline: você descreve o que quer — filtrar estes, transformar aqueles, somar o resultado — e deixa a biblioteca cuidar da iteração. Bem usado, o código com streams se lê como uma especificação. Usado sem cuidado, fica mais lento e mais difícil de depurar do que o loop que substituiu. Este guia cobre os dois lados.

O modelo de streams

Uma stream não é uma estrutura de dados. Ela não guarda elementos próprios; é uma visão sobre uma fonte — uma coleção, um array, uma função geradora, as linhas de um arquivo — pela qual os elementos passam um de cada vez. Um pipeline de stream tem três partes:

  1. Uma fonte que produz os elementos: list.stream(), Arrays.stream(array), Stream.of(a, b, c), IntStream.range(0, 10), Files.lines(path).
  2. Zero ou mais operações intermediárias que devolvem uma nova stream: filter, map, sorted, distinct, limit. Elas são lazy: nada é executado quando você as chama.
  3. Uma operação terminal que produz um resultado ou um efeito colateral e dispara o pipeline inteiro: collect, forEach, count, findFirst, reduce, anyMatch.
List<String> names = List.of("Ada", "Grace", "Linus", "Barbara", "Ken");

List<String> result = names.stream()          // source
    .filter(n -> n.length() > 3)              // intermediate, lazy
    .map(String::toUpperCase)                 // intermediate, lazy
    .sorted()                                 // intermediate, lazy
    .toList();                                // terminal — runs everything

// [BARBARA, GRACE, LINUS]

A avaliação lazy importa por dois motivos. Primeiro, o pipeline pode fazer curto-circuito: um findFirst() depois de um filter para assim que um elemento passa, sem examinar o restante. Segundo, as operações são fundidas: cada elemento passa por filter, depois por map e segue adiante, em vez de a lista inteira ser filtrada para uma lista temporária e só depois mapeada. Acrescente um peek para ver a ordem:

Stream.of("a", "bb", "ccc")
    .peek(s -> System.out.println("filter sees " + s))
    .filter(s -> s.length() > 1)
    .peek(s -> System.out.println("map sees " + s))
    .map(String::length)
    .findFirst();

// filter sees a
// filter sees bb
// map sees bb
// (ccc is never examined)

Uma stream só pode ser consumida uma vez. Depois que a operação terminal roda, a stream fica fechada; chamar outra operação sobre ela lança IllegalStateException: stream has already been operated upon or closed. Se você precisa percorrer os dados duas vezes, crie duas streams a partir da fonte.

As operações intermediárias que você vai usar de fato

A maior parte do código com streams do dia a dia usa um vocabulário pequeno.

OperaçãoO que fazUso típico
filter(Predicate)Mantém os elementos que atendem ao predicadoSelecionar um subconjunto
map(Function)Substitui cada elemento por um novo elementoExtrair um campo, converter o tipo
flatMap(Function)Substitui cada elemento por uma stream e achata o resultadoColeções aninhadas
mapMulti(BiConsumer)Como flatMap, sem criar uma stream interna (Java 16)Expandir elementos com baixo custo
distinct()Remove duplicatas segundo equalsDeduplicação
sorted() / sorted(Comparator)Ordena; exige guardar todos os elementos em bufferOrdenar antes da saída
limit(n) / skip(n)Trunca ou descarta um prefixoPaginação, "top N"
takeWhile / dropWhilePrefixo definido por um predicado (Java 9)Dados ordenados
peek(Consumer)Executa uma ação sem alterar o elementoApenas para depuração

flatMap é a operação que as pessoas têm mais dificuldade de visualizar. Suponha que cada Order tenha uma lista de LineItem e você queira todos os itens de todos os pedidos:

List<LineItem> allItems = orders.stream()
    .flatMap(order -> order.items().stream())
    .toList();

map daria um Stream<List<LineItem>> — uma stream de listas. flatMap dá um Stream<LineItem>.

As especializações primitivas IntStream, LongStream e DoubleStream evitam boxing e acrescentam operações numéricas como sum(), average() e summaryStatistics(). Para transitar entre elas, use mapToInt, mapToObj e boxed():

int totalCents = orders.stream()
    .flatMap(o -> o.items().stream())
    .mapToInt(LineItem::priceCents)
    .sum();

Operações terminais e redução

Uma operação terminal encerra o pipeline. As mais comuns se dividem em quatro grupos:

  • Coletar em alguma estrutura: collect(Collector), toList() (Java 16), toArray().
  • Reduzir a um único valor: count(), sum(), min(Comparator), max(Comparator), reduce(identity, accumulator).
  • Buscar: findFirst(), findAny(), anyMatch(p), allMatch(p), noneMatch(p). Todas fazem curto-circuito.
  • Efeitos colaterais: forEach(action), forEachOrdered(action).

reduce merece uma análise mais cuidadosa, porque é a forma geral das demais. Ela combina os elementos dois a dois com uma função associativa, partindo de um valor identidade:

int total = IntStream.of(3, 5, 7).reduce(0, Integer::sum);      // 15
Optional<String> longest = words.stream()
    .reduce((a, b) -> a.length() >= b.length() ? a : b);        // no identity → Optional

O acumulador precisa ser associativo e a identidade precisa ser de fato uma identidade (0 para soma, 1 para produto, "" para concatenação). A biblioteca conta com isso para dividir o trabalho em parallel streams; se você errar, uma execução paralela vai produzir uma resposta diferente da sequencial.

Collectors: transformando uma stream de volta em dados

É em Collectors que as streams mostram seu valor. groupingBy e partitioningBy substituem dezenas de linhas de montagem manual de mapas.

record Employee(String name, String department, int salary) {}

// Employees per department
Map<String, List<Employee>> byDept = employees.stream()
    .collect(Collectors.groupingBy(Employee::department));

// Average salary per department
Map<String, Double> avgSalary = employees.stream()
    .collect(Collectors.groupingBy(Employee::department,
                                   Collectors.averagingInt(Employee::salary)));

// Names per department, as a single comma-separated string
Map<String, String> namesByDept = employees.stream()
    .collect(Collectors.groupingBy(Employee::department,
                                   TreeMap::new,
                                   Collectors.mapping(Employee::name, Collectors.joining(", "))));

// Split into high and low earners
Map<Boolean, List<Employee>> highEarners = employees.stream()
    .collect(Collectors.partitioningBy(e -> e.salary() > 100_000));

O padrão é sempre o mesmo: uma função classificadora escolhe a chave, e um downstream collector opcional decide o que acontece com os elementos que compartilham a mesma chave. counting(), summingInt, averagingDouble, mapping, filtering, maxBy, toSet e joining são os downstream collectors que você mais vai usar. teeing (Java 12) executa dois collectors e combina os resultados, o que é útil para calcular, por exemplo, um mínimo e um máximo em uma única passada.

Dois detalhes de toMap causam bugs em produção. Primeiro, toMap(keyFn, valueFn) lança IllegalStateException quando encontra uma chave duplicada; passe uma função de merge como terceiro argumento se duplicatas forem possíveis. Segundo, ele rejeita valores null, então converta os nulls em um valor sentinela ou filtre-os antes.

Streams ou loops?

Uma stream é a melhor escolha quando o código é uma transformação: pegar estes dados, filtrá-los, remodelá-los, agregá-los. O pipeline deixa a intenção explícita e cada etapa pode ser lida isoladamente. Um loop é a melhor escolha quando:

  • Você precisa sair antecipadamente com base em uma condição complexa que não cabe em takeWhile nem em anyMatch.
  • O corpo tem vários efeitos colaterais ou altera várias variáveis — as streams empurram você para um forEach com estado mutável capturado, que junta o pior dos dois mundos.
  • Você está lidando com checked exceptions. As interfaces funcionais padrão não podem lançá-las, então o código com streams acaba envolvendo cada chamada em um try/catch dentro da lambda, o que fica bem mais poluído que um loop.
  • O desempenho é crítico e a coleção é pequena: o overhead por elemento de uma stream é mensurável em loops apertados.

Uma regra útil: se você se pegar escrevendo uma stream com um forEach que adiciona itens a uma lista declarada fora dela, reescreva usando collect — ou como um loop.

Parallel streams

Chamar parallelStream() ou .parallel() divide a fonte em blocos processados no ForkJoinPool comum. Basta uma linha para ativar, e uma linha para se arrepender. Parallel streams ajudam quando todas estas condições são verdadeiras:

  • A fonte se divide de forma barata e equilibrada (ArrayList, arrays, IntStream.range — não LinkedList nem um iterator).
  • O trabalho por elemento é pesado em CPU e significativo, não I/O nem um passo aritmético trivial.
  • Há elementos suficientes — dezenas de milhares, não dezenas.
  • As operações não têm estado; sorted, distinct e limit em uma stream ordenada exigem merges caros.

Caso contrário, o custo de coordenação domina e a versão paralela fica mais lenta. Há uma segunda armadilha: o pool comum é compartilhado pela JVM inteira. Uma parallel stream fazendo I/O bloqueante deixa sem recursos todas as outras parallel streams e todos os CompletableFuture do processo. Mantenha trabalho bloqueante totalmente fora de parallel streams.

// Reasonable candidate: large array, pure CPU work
long primes = IntStream.rangeClosed(2, 5_000_000).parallel()
    .filter(Primes::isPrime)
    .count();

// Bad candidate: tiny list, blocking call
customers.parallelStream().forEach(c -> emailService.send(c)); // don't

Erros comuns

Efeitos colaterais em lambdas. Modificar uma coleção ou um contador compartilhado dentro de map ou filter cria uma condição de corrida com parallel() e confunde a leitura mesmo no modo sequencial. Use collectors.

Lambdas com estado. Uma lambda que depende do que já viu (um total acumulado guardado em uma posição de array, por exemplo) quebra a premissa de que os elementos são independentes. reduce ou um loop é a ferramenta certa.

Esquecer a operação terminal. Um pipeline de operações intermediárias sem chamada terminal não faz absolutamente nada. O código compila e pula o trabalho em silêncio.

Reutilizar uma stream. Guardar uma stream em uma variável e chamar duas operações terminais sobre ela lança uma exceção em tempo de execução.

Ordenar quando você só precisa do primeiro elemento. sorted().findFirst() ordena tudo; min(comparator) faz uma única passada.

Streams de Files.lines sem fechar. Streams baseadas em arquivo mantêm um handle aberto. Envolva-as em um try-with-resources.

Tratar o Optional de findFirst com get(). Use orElse, orElseThrow ou ifPresent; um get() puro sobre um resultado vazio lança NoSuchElementException.

Para onde a API está indo

O Java 22 finalizou os stream gatherers (JEP 461): Stream.gather(Gatherer) está para as operações intermediárias assim como collect está para as terminais — uma forma de escrever suas próprias etapas de janelamento, fold ou scan que se encaixam no pipeline. Os gatherers nativos Gatherers.windowFixed, windowSliding, fold e scan cobrem casos que antes obrigavam a voltar para um loop. Se você está em uma versão atual, aprenda-os antes de partir para um iterator escrito à mão.

Principais pontos

  • Uma stream é um pipeline lazy: fonte → operações intermediárias → uma operação terminal. Nada roda até a chamada terminal.
  • filter, map, flatMap, sorted, distinct e limit cobrem a maioria das transformações; streams primitivas evitam boxing em trabalho numérico.
  • Collectors — especialmente groupingBy com um downstream collector — substituem a montagem manual de mapas. Lembre-se das regras de toMap para chaves duplicadas e valores null.
  • Use streams para transformações e loops para saídas antecipadas, checked exceptions e alteração de várias variáveis.
  • Parallel streams são uma ferramenta de desempenho apenas para trabalho grande, divisível e pesado em CPU; nunca coloque I/O bloqueante em uma delas.
  • Streams são de uso único, lambdas devem ser livres de efeitos colaterais e o acumulador passado para reduce precisa ser associativo.

Fontes e leituras complementares

Perguntas frequentes

Streams são mais rápidas que loops for?
Em geral não, e costumam ser um pouco mais lentas em coleções pequenas por causa dos objetos do pipeline e das chamadas de lambda. Streams existem para dar clareza e composição, não velocidade bruta. Parallel streams podem ser mais rápidas em trabalho grande, pesado em CPU e fácil de dividir, mas também podem ficar mais lentas: meça antes de adotar.
Posso reutilizar uma stream depois de chamar uma operação terminal?
Não. Uma stream só pode ser percorrida uma vez. Chamar qualquer operação sobre uma stream já consumida lança IllegalStateException. Crie uma nova stream a partir da fonte a cada vez, ou colete os resultados em uma coleção se precisar iterar duas vezes.
Qual é a diferença entre map e flatMap?
map transforma cada elemento em exatamente um novo elemento. flatMap transforma cada elemento em uma stream de zero ou mais elementos e concatena essas streams, por isso é a ferramenta para achatar coleções aninhadas — por exemplo, transformar uma stream de pedidos em uma stream com todos os itens desses pedidos.
Quando usar Collectors.toList() e quando usar Stream.toList()?
Stream.toList(), adicionado no Java 16, devolve uma lista não modificável e é a opção mais curta quando você não precisa alterar o resultado. A especificação de Collectors.toList() não garante nada sobre mutabilidade, embora a implementação atual devolva um ArrayList. Prefira Stream.toList(), a menos que precise de uma lista mutável; nesse caso, use Collectors.toCollection(ArrayList::new) para deixar isso explícito.