Quando o Java 8 foi lançado em 2014, ele introduziu várias funcionalidades revolucionárias que transformaram a forma como os desenvolvedores escrevem código Java. Entre essas inovações, a API Stream se destaca como uma das mais poderosas e transformadoras. Neste artigo, vamos explorar em profundidade o que são Java Streams, por que você deveria usá-los e como eles podem tornar seu código mais elegante, eficiente e fácil de manter.
O que são Java Streams?
Streams representam uma sequência de elementos que suportam várias operações para realizar cálculos complexos. Diferentemente das coleções tradicionais que armazenam elementos, os Streams não armazenam dados — eles apenas transmitem elementos de uma fonte de dados (como coleções) através de um pipeline de operações.
Um Stream em Java é definido como uma sequência de elementos de uma fonte que suporta operações agregadas. Os principais aspectos que caracterizam Streams são:
- Não é uma estrutura de dados: Um Stream não armazena elementos. Ele apenas obtém elementos de uma fonte como uma coleção, um array ou uma função geradora.
-
Processamento funcional: Streams são projetados para facilitar operações de estilo funcional em elementos, como
map-reduce
. - Avaliação preguiçosa (Lazy Evaluation): As operações intermediárias são executadas apenas quando uma operação terminal é invocada.
- Possivelmente ilimitados: Ao contrário das coleções, os Streams podem representar sequências infinitas.
- Consumíveis: Os elementos de um Stream são visitados apenas uma vez durante o ciclo de vida do Stream.
Anatomia de um Stream
Um pipeline de operações de Stream consiste tipicamente em:
- Fonte de dados: Uma coleção, um array, uma função geradora, ou um recurso de E/S.
-
Operações intermediárias: Transformam um Stream em outro Stream (como
filter
,map
,sorted
). -
Operação terminal: Produz um resultado ou um efeito colateral (como
collect
,reduce
,forEach
).
// A estrutura básica de um pipeline de Stream
List<Integer> numeros = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
int soma = numeros.stream() // Fonte
.filter(n -> n % 2 == 0) // Operação intermediária
.mapToInt(n -> n * n) // Operação intermediária
.sum(); // Operação terminal
System.out.println("Soma dos quadrados dos números pares: " + soma);
Criando Streams
Existem várias maneiras de criar Streams em Java:
A partir de coleções
List<String> lista = Arrays.asList("Java", "Python", "JavaScript");
Stream<String> streamDeLista = lista.stream();
A partir de arrays
String[] array = {"Java", "Python", "JavaScript"};
Stream<String> streamDeArray = Arrays.stream(array);
Usando Stream.of()
Stream<String> streamDireto = Stream.of("Java", "Python", "JavaScript");
Streams infinitos
// Gera números a partir de 1
Stream<Integer> numerosInfinitos = Stream.iterate(1, n -> n + 1);
// Limita a 10 números e imprime
numerosInfinitos.limit(10).forEach(System.out::println);
// Gera números aleatórios
Stream<Double> aleatorios = Stream.generate(Math::random);
aleatorios.limit(5).forEach(System.out::println);
Operações Intermediárias
As operações intermediárias retornam um novo Stream e são executadas apenas quando uma operação terminal é chamada. Elas são "preguiçosas", o que significa que não processam os elementos até que seja necessário.
filter()
Filtra elementos com base em um predicado (condição).
List<String> nomes = Arrays.asList("João", "Maria", "Pedro", "Ana", "Carlos");
List<String> nomesComA = nomes.stream()
.filter(nome -> nome.startsWith("A"))
.collect(Collectors.toList());
// Resultado: [Ana]
map()
Transforma cada elemento em outro objeto.
List<String> nomes = Arrays.asList("João", "Maria", "Pedro");
List<Integer> tamanhos = nomes.stream()
.map(String::length)
.collect(Collectors.toList());
// Resultado: [4, 5, 5]
flatMap()
Transforma cada elemento em um Stream e então achatá-los em um único Stream.
List<List<Integer>> listaDeListas = Arrays.asList(
Arrays.asList(1, 2, 3),
Arrays.asList(4, 5, 6),
Arrays.asList(7, 8, 9)
);
List<Integer> listaPlanificada = listaDeListas.stream()
.flatMap(Collection::stream)
.collect(Collectors.toList());
// Resultado: [1, 2, 3, 4, 5, 6, 7, 8, 9]
distinct()
Remove elementos duplicados.
List<Integer> numeros = Arrays.asList(1, 2, 2, 3, 3, 3, 4, 5, 5);
List<Integer> distintos = numeros.stream()
.distinct()
.collect(Collectors.toList());
// Resultado: [1, 2, 3, 4, 5]
sorted()
Ordena os elementos.
List<String> nomes = Arrays.asList("João", "Maria", "Pedro", "Ana", "Carlos");
List<String> nomesOrdenados = nomes.stream()
.sorted()
.collect(Collectors.toList());
// Resultado: [Ana, Carlos, João, Maria, Pedro]
// Usando comparador customizado (por tamanho do nome)
List<String> porTamanho = nomes.stream()
.sorted(Comparator.comparing(String::length))
.collect(Collectors.toList());
// Resultado: [João, Ana, Maria, Pedro, Carlos]
peek()
Permite inspecionar elementos durante o pipeline sem modificá-los.
List<String> nomes = Arrays.asList("João", "Maria", "Pedro");
List<String> maiusculas = nomes.stream()
.peek(n -> System.out.println("Original: " + n))
.map(String::toUpperCase)
.peek(n -> System.out.println("Maiúscula: " + n))
.collect(Collectors.toList());
limit() e skip()
limit(n)
limita o Stream aos primeiros n elementos, enquanto skip(n)
pula os primeiros n elementos.
List<Integer> numeros = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
// Primeiros 5 números
List<Integer> primeiros5 = numeros.stream()
.limit(5)
.collect(Collectors.toList());
// Resultado: [1, 2, 3, 4, 5]
// Pulando os primeiros 5 números
List<Integer> apos5 = numeros.stream()
.skip(5)
.collect(Collectors.toList());
// Resultado: [6, 7, 8, 9, 10]
Operações Terminais
As operações terminais produzem um resultado ou um efeito colateral e encerram o pipeline de Stream.
collect()
Acumula elementos em uma coleção ou outro resultado.
List<String> nomes = Arrays.asList("João", "Maria", "Pedro", "Ana", "Carlos");
// Coletando em uma lista
List<String> lista = nomes.stream()
.filter(n -> n.length() > 4)
.collect(Collectors.toList());
// Coletando em um conjunto
Set<String> conjunto = nomes.stream()
.filter(n -> n.length() > 4)
.collect(Collectors.toSet());
// Coletando em uma String separada por vírgulas
String resultado = nomes.stream()
.collect(Collectors.joining(", "));
// Resultado: "João, Maria, Pedro, Ana, Carlos"
// Agrupando por tamanho
Map<Integer, List<String>> porTamanho = nomes.stream()
.collect(Collectors.groupingBy(String::length));
/* Resultado:
{4=[João], 5=[Maria, Pedro, Carlos], 3=[Ana]}
*/
forEach()
Executa uma ação para cada elemento.
List<String> nomes = Arrays.asList("João", "Maria", "Pedro");
nomes.stream()
.forEach(nome -> System.out.println("Olá, " + nome));
reduce()
Combina elementos para produzir um único resultado.
List<Integer> numeros = Arrays.asList(1, 2, 3, 4, 5);
// Soma dos números
int soma = numeros.stream()
.reduce(0, (a, b) -> a + b); // ou .reduce(0, Integer::sum)
// Resultado: 15
// Multiplicação dos números
int produto = numeros.stream()
.reduce(1, (a, b) -> a * b);
// Resultado: 120
// Encontrando o maior valor
int maximo = numeros.stream()
.reduce(Integer.MIN_VALUE, Integer::max);
// Resultado: 5
count(), anyMatch(), allMatch(), noneMatch()
Operações para contar elementos ou verificar condições.
List<String> nomes = Arrays.asList("João", "Maria", "Pedro", "Ana", "Carlos");
// Contando elementos
long quantidade = nomes.stream()
.count();
// Resultado: 5
// Verificando se algum nome começa com 'J'
boolean temJ = nomes.stream()
.anyMatch(nome -> nome.startsWith("J"));
// Resultado: true
// Verificando se todos os nomes têm pelo menos 3 caracteres
boolean todosMaioresQue3 = nomes.stream()
.allMatch(nome -> nome.length() >= 3);
// Resultado: true
// Verificando se nenhum nome tem mais de 10 caracteres
boolean nenhumGrande = nomes.stream()
.noneMatch(nome -> nome.length() > 10);
// Resultado: true
findFirst() e findAny()
Encontram elementos que satisfazem uma condição.
List<String> nomes = Arrays.asList("João", "Maria", "Pedro", "Ana", "Carlos");
// Encontrando o primeiro elemento que começa com 'P'
Optional<String> primeiroComP = nomes.stream()
.filter(n -> n.startsWith("P"))
.findFirst();
// Resultado: Optional[Pedro]
// Encontrando qualquer elemento que começa com 'M'
Optional<String> qualquerComM = nomes.stream()
.filter(n -> n.startsWith("M"))
.findAny();
// Resultado: Optional[Maria]
Streams Paralelos
Uma das características mais poderosas dos Streams é a facilidade com que podemos paralelizar operações. Isso é particularmente útil para grandes conjuntos de dados onde o processamento pode ser distribuído entre múltiplos núcleos de CPU.
List<Integer> numeros = new ArrayList<>();
for (int i = 0; i < 10_000_000; i++) {
numeros.add(i);
}
// Stream sequencial
long inicio = System.currentTimeMillis();
long soma = numeros.stream()
.filter(n -> n % 2 == 0)
.mapToLong(n -> n * n)
.sum();
long fim = System.currentTimeMillis();
System.out.println("Tempo sequencial: " + (fim - inicio) + "ms");
// Stream paralelo
inicio = System.currentTimeMillis();
soma = numeros.parallelStream()
.filter(n -> n % 2 == 0)
.mapToLong(n -> n * n)
.sum();
fim = System.currentTimeMillis();
System.out.println("Tempo paralelo: " + (fim - inicio) + "ms");
Streams Especializados
Para tipos primitivos, Java oferece Streams especializados que evitam o custo de autoboxing/unboxing:
-
IntStream: Para elementos de tipo
int
-
LongStream: Para elementos de tipo
long
-
DoubleStream: Para elementos de tipo
double
// IntStream de uma faixa de valores
IntStream numeros = IntStream.rangeClosed(1, 5); // 1, 2, 3, 4, 5
// Convertendo um Stream regular para IntStream
List<String> palavras = Arrays.asList("Java", "Streams", "API");
IntStream tamanhos = palavras.stream()
.mapToInt(String::length); // 4, 7, 3
// Gerando estatísticas
IntSummaryStatistics estatisticas = tamanhos.summaryStatistics();
System.out.println("Média: " + estatisticas.getAverage());
System.out.println("Máximo: " + estatisticas.getMax());
System.out.println("Mínimo: " + estatisticas.getMin());
System.out.println("Soma: " + estatisticas.getSum());
System.out.println("Contagem: " + estatisticas.getCount());
Streams com Objetos
Streams também podem processar objetos e são particularmente poderosos quando usados com objetos porque permitem:
- Filtragem complexa baseada em propriedades dos objetos
- Transformação de objetos em outros formatos ou extração de propriedades
- Agrupamento de objetos por diferentes critérios
- Cálculos estatísticos baseados em propriedades numéricas
- Ordenação usando diferentes propriedades como critério
Criando uma classe pessoa de exemplo
class Pessoa {
private String nome;
private int idade;
private String cidade;
public Pessoa(String nome, int idade, String cidade) {
this.nome = nome;
this.idade = idade;
this.cidade = cidade;
}
// Getters e setters
Criando uma lista de pessoas
List<Pessoa> pessoas = Arrays.asList(
new Pessoa("Carlos", 32, "São Paulo"),
new Pessoa("Ana", 25, "Rio de Janeiro"),
new Pessoa("João", 41, "Salvador"),
new Pessoa("Maria", 28, "Belo Horizonte"),
new Pessoa("Pedro", 35, "São Paulo")
);
Filtrar pessoas com idade acima de 30
List<Pessoa> pessoasAcimaDe30 = pessoas.stream()
.filter(p -> p.getIdade() > 30)
.toList();
Extrair apenas os nomes para uma lista
List<String> nomesApenas = pessoas.stream()
.map(Pessoa::getNome)
.toList();
Agrupar pessoas por cidade com groupingBy
Map<String, List<Pessoa>> pessoasPorCidade = pessoas.stream()
.collect(Collectors.groupingBy(Pessoa::getCidade));
Encontrar a idade média
double idadeMedia = pessoas.stream()
.mapToInt(Pessoa::getIdade)
.average()
.orElse(0.0);
Encontrar a pessoa mais velha com comparator
Optional<Pessoa> pessoaMaisVelha = pessoas.stream()
.max(Comparator.comparing(Pessoa::getIdade));
Ordenar pessoas por nome
List<Pessoa> pessoasOrdenadasPorNome = pessoas.stream()
.sorted(Comparator.comparing(Pessoa::getNome))
.toList();
Boas Práticas e Considerações de Desempenho
Otimizando o Uso de Streams
Para obter o máximo de desempenho e eficiência ao usar Java Streams, considere as seguintes práticas recomendadas:
1. Ordem das operações
A ordem das operações em um pipeline de Stream pode ter um impacto significativo no desempenho. Como regra geral:
- Coloque operações que reduzem o tamanho do Stream (como
filter
) antes das operações que processam cada elemento (comomap
).
// Eficiente: filtra primeiro, depois mapeia
List<String> eficiente = nomes.stream()
.filter(n -> n.length() > 4) // reduz o tamanho
.map(String::toUpperCase) // processa menos elementos
.collect(Collectors.toList());
// Menos eficiente: mapeia todos, depois filtra
List<String> menosEficiente = nomes.stream()
.map(String::toUpperCase) // processa todos
.filter(n -> n.length() > 4) // filtra depois
.collect(Collectors.toList());
2. Streams paralelos: quando usar (e quando não usar)
Streams paralelos não são sempre mais rápidos. Considere usar parallelStream()
quando:
- A coleção é grande (tipicamente mais de 10.000 elementos)
- O processamento por elemento é computacionalmente intensivo
- Seu hardware tem múltiplos núcleos disponíveis
- As operações são independentes e não exigem sincronização
Evite Streams paralelos quando:
- A coleção é pequena
- As operações são simples e rápidas
- As operações dependem de ordem ou estado
- Você está trabalhando em um ambiente com recursos limitados
// Bom caso para paralelismo: coleção grande com operações intensivas
List<BigInteger> numeros = // milhões de elementos
numeros.parallelStream()
.map(n -> n.pow(10000))
.reduce(BigInteger.ZERO, BigInteger::add);
// Mau caso para paralelismo: coleção pequena com operações simples
List<String> poucasPalavras = Arrays.asList("um", "dois", "três", "quatro");
poucasPalavras.parallelStream() // overhead de paralelismo > benefício
.map(String::toUpperCase)
.collect(Collectors.toList());
3. Evite boxing/unboxing desnecessário
Use Streams especializados (IntStream
, LongStream
, DoubleStream
) para tipos primitivos para evitar o overhead de boxing/unboxing.
// Menos eficiente: usa boxing/unboxing
Stream<Integer> streamObjetos = IntStream.range(1, 1000000)
.boxed();
int soma1 = streamObjetos.mapToInt(i -> i).sum();
// Mais eficiente: usa tipos primitivos diretamente
int soma2 = IntStream.range(1, 1000000).sum();
4. Use operações de Short-circuit
Operações como limit()
, findFirst()
, findAny()
e anyMatch()
permitem que o Stream termine o processamento assim que a condição for satisfeita, sem precisar processar todos os elementos.
// Encontra o primeiro elemento que satisfaz uma condição sem processar toda a lista
Optional<String> primeiro = listaMuitoGrande.stream()
.filter(s -> s.startsWith("Z"))
.findFirst();
// Processa apenas os primeiros 100 elementos
List<String> primeiros100 = listaMuitoGrande.stream()
.limit(100)
.collect(Collectors.toList());
5. Reutilização de Streams e Suppliers
Streams não podem ser reutilizados após uma operação terminal. Se você precisar executar múltiplas operações, crie uma fábrica de Streams usando Supplier
:
// Incorreto: tenta reutilizar um Stream
Stream<String> stream = nomes.stream();
long contador = stream.count(); // operação terminal
// A linha abaixo causará erro: "stream has already been operated upon or closed"
List<String> lista = stream.collect(Collectors.toList());
// Correto: usa um Supplier para criar novos Streams quando necessário
Supplier<Stream<String>> streamSupplier = () -> nomes.stream();
long contador = streamSupplier.get().count();
List<String> lista = streamSupplier.get().collect(Collectors.toList());
6. Compreenda o custo das operações
Algumas operações de Stream são mais caras que outras:
-
sorted()
requer carregar todo o Stream na memória -
distinct()
exige manter um conjunto de elementos já vistos - Operações como
collect(Collectors.groupingBy())
podem exigir estruturas de dados intermediárias grandes
// Potencialmente problemático para Streams muito grandes
Stream<String> streamEnorme = // milhões de elementos
streamEnorme.sorted() // carrega tudo na memória!
.collect(Collectors.toList());
// Melhor abordagem para Streams gigantes: evite operações que precisam carregar tudo
streamEnorme.filter(s -> s.length() > 5)
.limit(1000)
.collect(Collectors.toList());
7. Combinando operações eficientemente
Algumas combinações de operadores de Stream podem ser mais eficientes quando expressas de maneiras específicas:
// Menos eficiente: dois loops separados (internamente)
int soma = pessoas.stream()
.map(Pessoa::getSalario)
.filter(s -> s > 5000)
.mapToInt(Integer::intValue)
.sum();
// Mais eficiente: combinando map e filter em um único passo
int soma = pessoas.stream()
.filter(p -> p.getSalario() > 5000)
.mapToInt(Pessoa::getSalario)
.sum();
8. Cuidado com o estado compartilhado
Evite modificar estado externo durante operações de Stream, especialmente em Streams paralelos:
// Perigoso: modifica estado compartilhado
List<Integer> resultados = new ArrayList<>();
numeros.parallelStream().forEach(n -> {
if (n % 2 == 0) {
resultados.add(n); // potencial problema de concorrência!
}
});
// Seguro: usa coletores para acumular resultados
List<Integer> resultados = numeros.parallelStream()
.filter(n -> n % 2 == 0)
.collect(Collectors.toList());
Aplicando essas práticas de otimização, você pode maximizar o desempenho e a eficiência de suas operações com Java Streams, aproveitando ao máximo essa poderosa ferramenta de processamento de dados.
Vantagens e Benefícios
Expressividade e legibilidade: Streams transformam operações complexas em pipelines claros e concisos, tornando o código mais legível e expressivo. O que antes exigia múltiplos loops aninhados agora pode ser expresso em poucas linhas.
Imutabilidade e previsibilidade: O design orientado a operações sem efeitos colaterais promove código mais seguro e previsível, reduzindo bugs relacionados a estado mutável.
Paralelismo simplificado: A transição de streams sequenciais para paralelos com uma simples chamada de método (parallelStream()) é uma poderosa ferramenta para aproveitar o processamento multi-core.
Interoperabilidade com o ecossistema Java: Streams se integram perfeitamente com as coleções existentes e com as novas APIs do Java, como Optional e novos métodos em classes como String e Arrays.
Abordagem declarativa: Streams permitem que você descreva o "quê" deseja fazer em vez do "como" fazer, deixando a implementação dos detalhes para a JVM.
Conclusão e Considerações Finais sobre Java Streams
Java Streams representam um marco significativo na evolução da linguagem Java, trazendo paradigmas de programação funcional para um ecossistema tradicionalmente orientado a objetos.
À medida que a computação distribuída e paralela se torna cada vez mais importante, as abstrações oferecidas pelos Streams serão ainda mais valiosas, permitindo que desenvolvedores expressem computações complexas de forma concisa e potencialmente escalável.
Em última análise, dominar Java Streams não é apenas aprender uma API — é abraçar um novo paradigma que pode transformar fundamentalmente sua abordagem ao desenvolvimento Java.
Top comments (0)