DEV Community

Cover image for Dominando Java Streams: Um Guia Completo para Processamento de Dados Moderno
Franklin Pereira
Franklin Pereira

Posted on

Dominando Java Streams: Um Guia Completo para Processamento de Dados Moderno

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:

  1. Fonte de dados: Uma coleção, um array, uma função geradora, ou um recurso de E/S.
  2. Operações intermediárias: Transformam um Stream em outro Stream (como filter, map, sorted).
  3. 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);
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

A partir de arrays

String[] array = {"Java", "Python", "JavaScript"};
Stream<String> streamDeArray = Arrays.stream(array);
Enter fullscreen mode Exit fullscreen mode

Usando Stream.of()

Stream<String> streamDireto = Stream.of("Java", "Python", "JavaScript");
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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());
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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]}
*/
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

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());
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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")
        );
Enter fullscreen mode Exit fullscreen mode

Filtrar pessoas com idade acima de 30

        List<Pessoa> pessoasAcimaDe30 = pessoas.stream()
                .filter(p -> p.getIdade() > 30)
                .toList();
Enter fullscreen mode Exit fullscreen mode

Extrair apenas os nomes para uma lista

        List<String> nomesApenas = pessoas.stream()
                .map(Pessoa::getNome)
                .toList();
Enter fullscreen mode Exit fullscreen mode

Agrupar pessoas por cidade com groupingBy

        Map<String, List<Pessoa>> pessoasPorCidade = pessoas.stream()
                .collect(Collectors.groupingBy(Pessoa::getCidade));
Enter fullscreen mode Exit fullscreen mode

Encontrar a idade média

        double idadeMedia = pessoas.stream()
                .mapToInt(Pessoa::getIdade)
                .average()
                .orElse(0.0);
Enter fullscreen mode Exit fullscreen mode

Encontrar a pessoa mais velha com comparator

        Optional<Pessoa> pessoaMaisVelha = pessoas.stream()
                .max(Comparator.comparing(Pessoa::getIdade));
Enter fullscreen mode Exit fullscreen mode

Ordenar pessoas por nome

        List<Pessoa> pessoasOrdenadasPorNome = pessoas.stream()
                .sorted(Comparator.comparing(Pessoa::getNome))
                .toList();
Enter fullscreen mode Exit fullscreen mode

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 (como map).
// 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());
Enter fullscreen mode Exit fullscreen mode

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());
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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());
Enter fullscreen mode Exit fullscreen mode

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());
Enter fullscreen mode Exit fullscreen mode

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());
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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());
Enter fullscreen mode Exit fullscreen mode

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.

AWS GenAI LIVE image

How is generative AI increasing efficiency?

Join AWS GenAI LIVE! to find out how gen AI is reshaping productivity, streamlining processes, and driving innovation.

Learn more

Top comments (0)

Sentry image

See why 4M developers consider Sentry, “not bad.”

Fixing code doesn’t have to be the worst part of your day. Learn how Sentry can help.

Learn more

👋 Kindness is contagious

Please leave a ❤️ or a friendly comment on this post if you found it helpful!

Okay