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:
- Uma fonte que produz os elementos:
list.stream(),Arrays.stream(array),Stream.of(a, b, c),IntStream.range(0, 10),Files.lines(path). - 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. - 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ção | O que faz | Uso típico |
|---|---|---|
filter(Predicate) | Mantém os elementos que atendem ao predicado | Selecionar um subconjunto |
map(Function) | Substitui cada elemento por um novo elemento | Extrair um campo, converter o tipo |
flatMap(Function) | Substitui cada elemento por uma stream e achata o resultado | Coleções aninhadas |
mapMulti(BiConsumer) | Como flatMap, sem criar uma stream interna (Java 16) | Expandir elementos com baixo custo |
distinct() | Remove duplicatas segundo equals | Deduplicação |
sorted() / sorted(Comparator) | Ordena; exige guardar todos os elementos em buffer | Ordenar antes da saída |
limit(n) / skip(n) | Trunca ou descarta um prefixo | Paginação, "top N" |
takeWhile / dropWhile | Prefixo definido por um predicado (Java 9) | Dados ordenados |
peek(Consumer) | Executa uma ação sem alterar o elemento | Apenas 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
takeWhilenem emanyMatch. - O corpo tem vários efeitos colaterais ou altera várias variáveis — as streams empurram você para um
forEachcom 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ãoLinkedListnem 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,distinctelimitem 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,distinctelimitcobrem a maioria das transformações; streams primitivas evitam boxing em trabalho numérico.- Collectors — especialmente
groupingBycom um downstream collector — substituem a montagem manual de mapas. Lembre-se das regras detoMappara 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
reduceprecisa 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.