Java Stream Collectors
Reduza streams Java em coleções e outros resultados com java.util.stream.Collectors.
collect é o terminal que adiamos. Ele recebe um Collector<T, A, R> — uma receita para acumular elementos do stream em um resultado R por meio de um contêiner intermediário A — e o executa. As receitas estão na classe de fábrica java.util.stream.Collectors, e cobrem a maior parte do que você escreveria manualmente com um laço for, um Map e algumas chamadas compute*. Quando você consegue ler groupingBy(..., counting()), a API deixa de parecer enigmática.
O capítulo percorre a caixa de ferramentas pelo que você quer que o resultado seja: uma lista, um conjunto, um mapa, um único número, uma string ou — por meio do padrão downstream — uma combinação aninhada de qualquer um deles.
Listas, conjuntos e coleções específicas
Os dois básicos:
List<String> list = words.stream().collect(Collectors.toList());
Set<String> set = words.stream().collect(Collectors.toSet());Notas:
Collectors.toList()retorna algumaList— geralmente mutável, mas sem garantia. Para a forma não modificável que você precisa na maioria das vezes, usestream.toList()(o terminal, não o collector).Collectors.toSet()é não ordenado — tipicamenteHashSet. Se você precisar de uma ordem de iteração estável, solicite explicitamente comtoCollection(LinkedHashSet::new).Collectors.toUnmodifiableList()etoUnmodifiableSet()(Java 10+) retornam resultados imutáveis — estes são os equivalentes em forma de collector dostream.toList().
Para uma implementação específica, use toCollection:
ArrayDeque<String> queue = words.stream()
.collect(Collectors.toCollection(ArrayDeque::new));
TreeSet<String> sorted = words.stream()
.collect(Collectors.toCollection(TreeSet::new));O supplier é uma referência de construtor; o collector conecta tudo, esvazia o stream nele e o retorna.
toMap — associa cada elemento a uma chave
toMap(keyMapper, valueMapper) transforma cada elemento em um Map.Entry e os acumula:
Map<String, Integer> nameAge = people.stream()
.collect(Collectors.toMap(Person::name, Person::age));Chaves duplicadas lançam IllegalStateException. Essa é a regra que pega todo mundo na primeira vez. Se dois Persons compartilham um nome, o toMap padrão falha. A correção é a sobrecarga com função de mesclagem:
Map<String, Integer> sumAgePerName = people.stream()
.collect(Collectors.toMap(
Person::name,
Person::age,
Integer::sum)); // merge: existingAge + newAgePara um tipo de mapa específico — LinkedHashMap para preservar a ordem de inserção, TreeMap para manter as chaves ordenadas — passe um supplier:
Map<String, Integer> ordered = people.stream()
.collect(Collectors.toMap(
Person::name, Person::age,
(a, b) -> a, // keep first on collision
LinkedHashMap::new));toUnmodifiableMap é a variante imutável (Java 10+).
groupingBy — divide em grupos por chave
O collector ao qual todos recorrem quando percebem que toMap não é a ferramenta certa:
Map<String, List<Person>> byRole = people.stream()
.collect(Collectors.groupingBy(Person::role));Para cada elemento, o classificador produz uma chave e o elemento é adicionado ao grupo dessa chave (downstream padrão: toList()). Compare com toMap:
| Produz | Em chave duplicada | |
|---|---|---|
toMap | Map<K, V> (um V por K) | Lança exceção a menos que você forneça um merger |
groupingBy | Map<K, List<V>> (um grupo por K) | Adiciona ao grupo |
Use toMap quando há no máximo um valor por chave por design (id → registro, código → rótulo). Use groupingBy quando há muitos.
O pleno poder do groupingBy vem do seu parâmetro downstream, que diz o que fazer com os elementos que compartilham uma chave. O padrão é toList; você pode substituí-lo por outro collector — e esse collector pode ser ele mesmo um groupingBy. A seção "downstream" do capítulo abaixo é onde a API realmente se abre.
partitioningBy — agrupa por um predicado
Um groupingBy especializado para predicados binários. Retorna um Map<Boolean, List<T>>:
Map<Boolean, List<Person>> adultsOrNot = people.stream()
.collect(Collectors.partitioningBy(p -> p.age() >= 18));
List<Person> adults = adultsOrNot.get(true);
List<Person> minors = adultsOrNot.get(false);partitioningBy sempre contém as chaves true e false, mesmo que um grupo esteja vazio. Isso é o que ele oferece a mais em relação a groupingBy(p -> p.age() >= 18) — que omitiria a chave se o grupo estivesse vazio.
Assim como groupingBy, partitioningBy aceita um collector downstream.
counting, summingInt, averagingDouble, minBy, maxBy
Os collectors downstream que produzem um único número por grupo:
Map<String, Long> headcount = people.stream()
.collect(Collectors.groupingBy(Person::role, Collectors.counting()));
Map<String, Integer> totalAgePerRole = people.stream()
.collect(Collectors.groupingBy(Person::role,
Collectors.summingInt(Person::age)));
Map<String, Double> avgAgePerRole = people.stream()
.collect(Collectors.groupingBy(Person::role,
Collectors.averagingDouble(Person::age)));
Map<String, Optional<Person>> oldestPerRole = people.stream()
.collect(Collectors.groupingBy(Person::role,
Collectors.maxBy(Comparator.comparingInt(Person::age))));counting()—Long, o tamanho do grupo.summingInt/Long/Double(toX)— soma do primitivo projetado.averagingInt/Long/Double(toX)— média emDouble.minBy(cmp)/maxBy(cmp)— extremo emOptional<T>.summarizingInt/Long/Double(toX)—IntSummaryStatistics/ etc., o pacote completo de contagem/soma/mínimo/máximo/média.
joining — concatena strings
Para streams de CharSequence:
String csv = words.stream().collect(Collectors.joining(","));
String pretty = words.stream().collect(Collectors.joining(", ", "[", "]"));Três sobrecargas: sem argumento (apenas concatena), um argumento delimitador, três argumentos delimitador + prefixo + sufixo. Mais rápido que reduce("", String::concat) porque usa um StringBuilder internamente e não aloca quadraticamente. A ferramenta certa sempre que o resultado do pipeline é uma única string.
mapping — transforma e então coleta
Envolve outro collector para que os elementos sejam transformados primeiro. O uso mais comum é dentro de groupingBy quando você quer agrupar por algo e coletar uma projeção dos elementos em vez dos próprios elementos:
Map<String, List<String>> namesByRole = people.stream()
.collect(Collectors.groupingBy(
Person::role,
Collectors.mapping(Person::name, Collectors.toList())));Sem mapping, o toList() downstream coletaria Persons inteiros; com mapping(Person::name, ...), coleta apenas os nomes. Use-o sempre que, de outra forma, escreveria groupingBy(...).entrySet().stream().map(...).collect(...) em duas passagens seguidas.
filtering (Java 9+) é o invólucro correspondente para "descartar alguns antes de coletar":
Map<String, List<Person>> adultsByRole = people.stream()
.collect(Collectors.groupingBy(
Person::role,
Collectors.filtering(p -> p.age() >= 18, Collectors.toList())));A diferença em relação a stream.filter(...) antes do collector: filtering mantém a chave no mapa de resultado mesmo quando nenhum elemento passa — seu grupo fica simplesmente vazio.
reducing — redução geral completa como collector
A forma collector de reduce, usada como downstream quando os padrões não se encaixam:
Map<String, Optional<Person>> oldestPerRole = people.stream()
.collect(Collectors.groupingBy(
Person::role,
Collectors.reducing(BinaryOperator.maxBy(Comparator.comparingInt(Person::age)))));Há três sobrecargas (um argumento, dois argumentos com identidade, três argumentos com identidade + mapeador + acumulador) correspondendo às três formas de reduce do capítulo anterior. A forma com dois argumentos é a mais comum como downstream, pois retorna um T simples em vez de Optional<T>.
Raramente você escreve reducing no topo de um pipeline — reduce é o terminal para isso. Você o escreve como downstream de groupingBy/partitioningBy quando quer redução por grupo.
collectingAndThen — pós-processa o resultado
Envolve um collector com uma função finalizadora. O uso padrão é tornar uma List/Map coletada não modificável, ou extrair um valor final de um resultado summarizing*:
List<String> immutableNames = people.stream()
.map(Person::name)
.collect(Collectors.collectingAndThen(
Collectors.toList(),
Collections::unmodifiableList));
Map<String, Long> immutableCounts = people.stream()
.collect(Collectors.collectingAndThen(
Collectors.groupingBy(Person::role, Collectors.counting()),
Collections::unmodifiableMap));Também é assim que você transforma groupingBy(..., minBy(...)) em um valor simples em vez de Optional<T> — o finalizador desencapsula o Optional com um padrão conhecido.
teeing — executa dois collectors em uma única passagem
(Java 12+) Alimenta cada elemento em dois collectors ao mesmo tempo e combina seus resultados:
record Range(int min, int max) {}
Range range = nums.stream()
.collect(Collectors.teeing(
Collectors.minBy(Integer::compare),
Collectors.maxBy(Integer::compare),
(lo, hi) -> new Range(lo.orElseThrow(), hi.orElseThrow())));Os dois collectors filhos veem cada elemento; o merger recebe seus dois resultados. Útil quando, de outra forma, você faria stream duas vezes — por exemplo, calcular a média e identificar os valores discrepantes.
Escolhendo o collector certo
| Você quer que o resultado seja | Use |
|---|---|
List<T> (imutável, caso comum) | stream.toList() (terminal) |
List<T> (mutável) | Collectors.toList() ou toCollection(ArrayList::new) |
Set<T> | Collectors.toSet() ou toCollection(LinkedHashSet::new) |
| Coleção específica | Collectors.toCollection(supplier) |
Map<K, V> um-para-um | Collectors.toMap(k, v) (+ merger se necessário) |
Map<K, List<T>> grupos | Collectors.groupingBy(k) |
Map<Boolean, List<T>> | Collectors.partitioningBy(pred) |
| String única | Collectors.joining(delim, pre, suf) |
| Contagem/soma/média por grupo | groupingBy(k, counting() / summingInt(...) / ...) |
| Projeção por grupo | groupingBy(k, mapping(proj, toList())) |
| Extremo por grupo | groupingBy(k, minBy(cmp) / maxBy(cmp)) |
| Redução personalizada por grupo | groupingBy(k, reducing(...)) |
| Dois resultados em uma passagem | Collectors.teeing(c1, c2, merger) |
| Tornar o resultado não modificável | envolva em collectingAndThen(c, Collections::unmodifiableList) |
Um exemplo prático: cada collector em um único conjunto de dados
O programa abaixo cria uma lista de registros Person e executa cada formato de collector sobre ela.
O que observar na execução:
- O
toMap(Person::name, Person::age)sem proteção no final lançouIllegalStateExceptionporque doisPersons compartilham o nome "Alice". A correção padrão é um terceiro argumento: umBinaryOperator<V>que diz como mesclar valores quando as chaves colidem. Escolha o merger que corresponda à sua semântica (manter o primeiro, manter o último, somar, concatenar) — é o que a chamada anteriorageByNamefez com(a, b) -> a. groupingBy(Person::role)produziu umMap<String, List<Person>>gratuitamente. Substituir o downstream padrãotoList()porcounting(),summingInt(...),averagingDouble(...)oumaxBy(...)transformou o resultado por grupo de "uma lista" em um único número — mesma forma de pipeline, receita diferente no slot downstream.mapping(Person::name, toList())é a resposta para "quero agrupar por função, mas meus grupos devem conter apenas nomes, nãoPersons inteiros." Pré-projetar downstream é quase sempre mais limpo do que coletar registros inteiros e depois mapear os valores.partitioningByretornou as chavestrueefalsemesmo quando uma metade poderia ter ficado vazia. Essa previsibilidade é sua razão de ser em comparação comgroupingBy(predicado).teeingcoletouminemaxem uma única passagem, depois entregou ambos osOptionals a um merger que construiu o registroRange. Sempre que você faria stream duas vezes para obter dois resumos, recorra ateeing.collectingAndThen(toList(), Collections::unmodifiableList)é o truque clássico de finalizador; a mesma forma desencapsula umgroupingBy(..., maxBy(...))deMap<K, Optional<V>>paraMap<K, V>quando você já provou que todo grupo é não vazio.
O que vem a seguir
Cada collector e intermediário da parte até agora roda sequencialmente por padrão — um elemento por vez, na ordem de encontro, na thread chamante. O próximo capítulo, Java Parallel Streams, apresenta o agendamento alternativo — parallelStream() e stream().parallel() — o que é seguro colocar dentro de um pipeline paralelo, o que não é (mutação de estado compartilhado, forEach sensível à ordem, reduce não associativo), e como saber se o paralelismo realmente ajuda ou torna o programa mais lento.