Introdução aos Java Streams
Uma introdução à Java Stream API para processar sequências de elementos com operações de estilo funcional.
Um stream é um pipeline que conduz os elementos de uma fonte por uma sequência de operações e produz um resultado. Ele não é uma estrutura de dados — não armazena nada. É uma receita declarativa para processar dados, avaliada de forma lazy e executada uma única vez. Os Streams chegaram no Java 8 junto com lambdas, e os dois foram projetados para se encaixar: cada operação de stream recebe uma função, e a linguagem ofereceu uma forma limpa de escrevê-la.
O formato que você escreverá centenas de vezes:
double avgAdultAge = people.stream()
.filter(p -> p.age() >= 18)
.mapToInt(Person::age)
.average()
.orElse(0.0);Três coisas a observar. O pipeline é lido de cima para baixo como etapas que descrevem o que você quer, não como iterar. Cada etapa recebe uma função — um Predicate, um ToIntFunction — exatamente o vocabulário estabelecido nos capítulos anteriores. E o resultado sai de uma única operação terminal; não há loop, não há acumulador, não há continue antecipado.
O formato do pipeline: fonte → intermediária → terminal
Todo pipeline de stream tem três partes:
- Uma fonte. De onde os elementos vêm. Geralmente uma coleção (
coll.stream()), ocasionalmente um literal (Stream.of(\"a\", \"b\")), um array (Arrays.stream(arr)), um intervalo deIntStream(IntStream.range(0, 100)), uma fonte de I/O (Files.lines(path)), ou um gerador (Stream.iterate,Stream.generate). O próximo capítulo é dedicado a todos eles. - Zero ou mais operações intermediárias. Cada uma retorna outro stream, portanto podem ser encadeadas. As mais comuns:
filter,map,flatMap,distinct,sorted,limit,skip,peek. Elas são lazy — chamarfilternão testa nada ainda; apenas registra o predicado. - Exatamente uma operação terminal. Dispara o pipeline. Exemplos:
forEach,collect,toList,count,sum,min,max,reduce,findFirst,anyMatch. O terminal produz um valor (ou um efeito colateral paraforEach) e consome o stream — você não pode reutilizá-lo.
list.stream() // SOURCE
.filter(...) // intermediate
.map(...) // intermediate
.sorted() // intermediate
.toList(); // TERMINAL — runs the pipelineSem o terminal, nada acontece. Um stream que você constrói e nunca termina é peso morto — nenhum trabalho é feito, nenhum efeito colateral ocorre, os lambdas não executam.
Lazy por design
As operações intermediárias são lazy porque a JVM não sabe quais elementos você realmente precisa até que o terminal solicite. Isso possibilita duas otimizações importantes:
Fusão. Intermediárias adjacentes executam juntas em uma única passagem, não uma passagem por operação. stream.filter(p).map(f) não constrói uma lista filtrada intermediária e depois a mapeia; ele testa um elemento e, se ele passar, o mapeia, tudo em uma única etapa.
Short-circuiting. Um terminal como findFirst, anyMatch ou limit(n) interrompe o pipeline assim que tem sua resposta. Combinado com a avaliação lazy, isso significa que você pode executar um pipeline "encontre o primeiro quadrado par maior que 100" sobre um stream infinito e obter uma resposta em microssegundos:
int answer = Stream.iterate(1, n -> n + 1) // 1, 2, 3, 4, ...
.map(n -> n * n) // 1, 4, 9, 16, ...
.filter(n -> n % 2 == 0 && n > 100) // first match wins
.findFirst()
.orElseThrow();
// answer = 144Stream.iterate(1, n -> n + 1) é infinito, mas findFirst solicitou elementos apenas até que um correspondesse. O pipeline testou 12 quadrados (1, 4, 9, ..., 144) e parou.
De uso único, como um Iterator
Um Stream pode ser percorrido uma vez. O terminal o consome e, depois disso, o objeto stream é fechado; chamar outro terminal nele lança IllegalStateException:
Stream<String> s = list.stream();
long c1 = s.count(); // ok
long c2 = s.count(); // throws IllegalStateException — stream has already been operated uponSe você precisar processar os mesmos dados duas vezes, construa o stream duas vezes:
long c1 = list.stream().count();
long c2 = list.stream().count();Isso corresponde ao funcionamento do Iterator. O objeto stream é o cursor em movimento, não os dados. Os dados são a fonte — fazer streaming novamente é gratuito.
Streams vs coleções — funções diferentes
| Aspecto | Collection | Stream |
|---|---|---|
| Armazena dados? | Sim | Não |
| Reutilizável? | Sim | Não (um terminal) |
| Eager ou lazy? | Eager | Lazy até o terminal |
| Modifica a fonte? | Sim (ex.: list.add) | Não — pipelines são somente leitura |
| Itera explicitamente? | Frequentemente (for, iterator()) | Não — o pipeline conduz a iteração |
| Modelo de custo | Bookkeeping por elemento | Uma passagem pela fonte |
Uma coleção é um contêiner; um stream é uma computação sobre um contêiner (ou outra fonte). Eles se complementam: você busca de uma coleção, executa um pipeline de stream e coleta de volta em uma coleção (geralmente diferente).
Três pequenos exemplos que você escreverá o tempo todo
Contando elementos que correspondem a um predicado:
long adults = people.stream().filter(p -> p.age() >= 18).count();Construindo uma lista de valores transformados:
List<String> names = people.stream().map(Person::name).toList();Reduzindo a um único valor:
int totalAge = people.stream().mapToInt(Person::age).sum();Esses três padrões — count, map-to-list, reduce-to-scalar — cobrem a maioria dos usos da API. O restante da parte é um tour pelas operações que preenchem o como para cada um.
Três coisas que os streams não são
- Não são uma substituição para loops
forem geral. Um loop que constrói algo com controle de fluxo não trivial, que precisa debreakcom efeitos colaterais, ou que muta várias variáveis, ainda é mais claro como um loop. Os Streams brilham quando o trabalho é um pipeline de operações puras. - Não são um ganho de desempenho em dados pequenos. Um pipeline de stream aloca alguns objetos pequenos; um loop de 10 elementos vai superá-lo. Os ganhos vêm da clareza em qualquer dado e do paralelismo em grandes volumes de dados.
- Não são substitutos para
Iterator/Iterablequando outro código os espera. Um stream produz valores; se você precisa intercalar o consumo (umforaprimorado, umListretornado de um método), usetoList()primeiro.
Sequencial por padrão, paralelo sob demanda
Todo stream que você escreverá neste capítulo é sequencial — os elementos fluem pelo pipeline um de cada vez, em ordem. Há também coll.parallelStream() (e stream.parallel()) que agenda o pipeline na ForkJoinPool comum para trabalho multi-core. Streams paralelos são um capítulo posterior — eles fazem várias suposições sobre o pipeline (ele deve ser associativo, sem estado, sem efeitos colaterais) que os pipelines de "introdução" deste capítulo naturalmente satisfazem, portanto a atualização geralmente é uma mudança de um token.
Um exemplo prático: um pipeline completo, laziness e a regra de uso único
O programa abaixo constrói uma pequena lista de registros Person, executa o formato canônico de pipeline (filter → map → sorted → collect), prova a laziness com peek, demonstra o short-circuiting em um Stream.iterate infinito e mostra o IllegalStateException que você obtém ao reutilizar um stream.
O que observar na execução:
- O pipeline canônico de quatro etapas —
stream→filter→map→toList— produziu uma lista ordenada de nomes de adultos sem nenhum loop explícito, sem coleção temporária e sem gerenciamento de nullabilidade. peekimprimiu uma vez por elemento puxado.findFirstpuxou elementos até que um satisfizessen*n > 50(o que acontece emn = 8, quadrado64) e então parou. É a laziness e o short-circuiting trabalhando juntos: as operações upstream fizeram exatamente o trabalho necessário e nada mais.- O pipeline "primeiro quadrado par acima de 100" executou sobre uma fonte infinita. Sem short-circuiting isso seria um loop infinito; com ele o pipeline testou 12 valores e produziu
144. - O segundo
s.count()lançouIllegalStateException. Streams são de uso único; se você precisar de uma segunda passagem, construa um stream novo da fonte. - O pipeline "sem terminal" no final não imprimiu nada de dentro do seu
peek. Sem um terminal, os intermediários não executam — o stream é apenas uma receita que ninguém pediu para executar.
O que vem a seguir
Você conhece o formato do pipeline, a divisão fonte/intermediária/terminal, o contrato de laziness e a regra de uso único. O próximo capítulo, Criando Java Streams, é o catálogo de fontes — Collection.stream(), Stream.of, Arrays.stream, IntStream.range, Stream.iterate, Stream.generate, Files.lines, String.chars(), Stream.empty e a API Stream.Builder. Com o capítulo de fontes concluído, você terá tudo o que precisa para começar, e o restante da parte preencherá as operações intermediárias e terminais.