AI InterviewSearch…Search problems, topics, or pages…Ctrl KGet started
Skip to content

15 Streams / Lesson 4 of 7

Collectors — toMap, joining, groupingBy, partitioningBy

16 min · +10 XP · 2 exercises · 6 questions

Read the text16 min
Exercises0/2
Quiz6 q

Quick answer

How does groupingBy work in Java streams?

Collectors.groupingBy in Java streams puts elements into a map keyed by what a classifier function returns: groupingBy(String::length) gives a Map<Integer, List<String>>. A second argument, a downstream collector such as counting() or mapping(...), replaces each list with a count, sum or other summary, and a three-argument form names the map type, such as TreeMap::new.

collect is the terminal that builds something: a list, a set, a map, a string, a summary. Its argument is a Collector — a recipe with a supplier (make the container), an accumulator (add one element), a combiner (merge two containers, for parallel) and a finisher (convert at the end). You will write your own perhaps once a year; you will use the ones in java.util.stream.Collectors every day, and groupingBy with a downstream collector is the single most useful line in the whole streams API. This lesson is that toolbox.

The basics

import static java.util.stream.Collectors.*;

List<String> list   = s.collect(toList());
Set<String> set     = s.collect(toSet());                      // a HashSet: no order
Set<String> ordered = s.collect(toCollection(TreeSet::new));   // any collection you name
Deque<String> dq    = s.collect(toCollection(ArrayDeque::new));

Static-import Collectors.* in code that uses several; a pipeline reads far better as groupingBy(..., counting()) than with the class name repeated four times.

toMap — and the two traps

Map<String, Integer> lengths = words.stream().collect(toMap(w -> w, String::length));

Trap one: duplicate keys throw IllegalStateException: Duplicate key. Fix with a merge function:

Map<String, Integer> counts = words.stream().collect(toMap(w -> w, w -> 1, Integer::sum));
Map<Character, String> byInitial = words.stream().collect(toMap(w -> w.charAt(0), w -> w, (a, b) -> a));  // keep the first

Trap two: the map is a HashMap, so iteration order is unspecified. Name the map type with the four-argument form:

Map<String, Integer> inOrder = words.stream().collect(toMap(w -> w, String::length, (a, b) -> a, LinkedHashMap::new));
TreeMap<String, Integer> sorted = words.stream().collect(toMap(w -> w, String::length, (a, b) -> a, TreeMap::new));

Also: toMap rejects null values with a NullPointerException (it uses Map.merge internally). And Function.identity() is the idiomatic spelling of w -> w for the key.

joining — strings without a StringBuilder

String csv    = names.stream().collect(joining(", "));                  // "ann, bob, cy"
String pretty = names.stream().collect(joining(", ", "[", "]"));        // "[ann, bob, cy]"
String all    = names.stream().collect(joining());                       // "annbobcy"

Elements must be CharSequences — map(String::valueOf) or map(Object::toString) first for anything else. String.join(", ", list) does the same without a stream when there is nothing to filter or map.

groupingBy — the one to master

groupingBy(classifier) puts elements into a Map<K, List<T>> keyed by what the classifier returns:

Map<Integer, List<String>> byLength = words.stream().collect(groupingBy(String::length));
// {3=[ann, bob], 5=[carol]}

Add a downstream collector to say what to do with each group instead of listing it:

Map<Integer, Long> countByLength     = words.stream().collect(groupingBy(String::length, counting()));
Map<String, Integer> totalByCity     = people.stream().collect(groupingBy(Person::city, summingInt(Person::age)));
Map<String, Double> avgAgeByCity     = people.stream().collect(groupingBy(Person::city, averagingInt(Person::age)));
Map<String, Set<String>> namesByCity = people.stream().collect(groupingBy(Person::city, mapping(Person::name, toSet())));
Map<String, Optional<Person>> oldest = people.stream().collect(groupingBy(Person::city, maxBy(comparingInt(Person::age))));
Map<String, List<String>> sortedNames = people.stream().collect(groupingBy(Person::city, TreeMap::new, mapping(Person::name, toList())));

The three-argument form names the map (TreeMap::new for sorted keys, LinkedHashMap::new for first-seen order). Groups can nest: groupingBy(Person::country, groupingBy(Person::city, counting())) gives Map<String, Map<String, Long>>.

The keys are whatever the classifier returns, so groupingBy(w -> w.charAt(0)) groups by first letter, groupingBy(n -> n % 3) by remainder, and groupingBy(p -> p.age() / 10 * 10) by decade. A null classifier result throws — map it to a sentinel first.

partitioningBy — exactly two groups

Map<Boolean, List<Integer>> evenOdd = nums.stream().collect(partitioningBy(n -> n % 2 == 0));
Map<Boolean, Long> passFail = scores.stream().collect(partitioningBy(s -> s >= 50, counting()));

A partitioningBy map always has both keys, true and false, even when one side is empty — groupingBy on a boolean would omit an empty group. That is the reason to prefer it for yes/no splits.

Downstream collectors worth knowing

CollectorProduces
counting()Long
summingInt/Long/Double(f)the sum
averagingInt/Long/Double(f)Double
minBy(cmp), maxBy(cmp)Optional<T>
mapping(f, downstream)apply f first, then collect
filtering(pred, downstream) (Java 9)filter inside the group
flatMapping(f, downstream) (Java 9)flatten inside the group
reducing(identity, op)a fold per group
summarizingInt(f)IntSummaryStatistics: count, sum, min, max, average in one pass
collectingAndThen(downstream, finisher)post-process: collectingAndThen(toList(), Collections::unmodifiableList)
teeing(c1, c2, merger) (Java 12)run two collectors over the same elements and merge

teeing deserves an example, because it removes the "two passes for two facts" problem:

record MinMax(int min, int max) {}
MinMax mm = nums.stream().collect(teeing(
    minBy(Integer::compare), maxBy(Integer::compare),
    (lo, hi) -> new MinMax(lo.orElseThrow(), hi.orElseThrow())));

Writing a collector

Collector<String, StringBuilder, String> concat =
    Collector.of(StringBuilder::new, StringBuilder::append, StringBuilder::append, StringBuilder::toString);

Supplier, accumulator, combiner, finisher. You will rarely need it — but knowing the shape explains why collect is allowed to mutate (the container is yours) while reduce is not.

collect versus reduce, settled

reduce folds immutable values: new value in, new value out. collect fills a mutable container. Reducing into an ArrayList "works" sequentially and breaks in parallel; collecting into one is exactly what the combiner is for. Rule: if the accumulator mutates its first argument, it is a collector.

Interview angle

  • "What happens on duplicate keys in toMap?" IllegalStateException — supply a merge function.
  • "groupingBy returns…?" Map<K, List<T>> by default; a downstream collector replaces the List.
  • "partitioningBy versus groupingBy on a boolean?" partitioningBy always has both keys.
  • "How do you count occurrences of each word?" groupingBy(identity(), counting()) — or toMap(w -> w, w -> 1, Integer::sum).
  • "Why does collect accept a mutable container when reduce does not?" A collector has a combiner that merges containers, so parallel chunks never share one.

Key takeaways

  • toMap throws on duplicates and gives a HashMap; add a merge function and a map supplier when you care.
  • joining(sep, prefix, suffix) for strings; String.join when there is no pipeline.
  • groupingBy(classifier, mapFactory, downstream) — memorise the shape; counting, mapping, summingInt, maxBy as downstreams.
  • partitioningBy for two-way splits; both keys always present.
  • teeing for two facts in one pass; collectingAndThen to finish; Collector.of when the library has no fit.

Common questions

How do you handle duplicate keys in Collectors.toMap?

Pass a merge function as the third argument. Without one, toMap throws IllegalStateException: Duplicate key; with toMap(w -> w, w -> 1, Integer::sum) duplicates are combined, and (a, b) -> a keeps the first. A fourth argument, such as LinkedHashMap::new, chooses the map type.

What is the difference between partitioningBy and groupingBy?

partitioningBy splits elements by a predicate into exactly two groups, and its map always has both keys, true and false, even when one side is empty. groupingBy makes one group per classifier value, and a value with no elements simply has no key.

How do you count occurrences of each word with Java streams?

Group by the word itself and count: words.stream().collect(groupingBy(Function.identity(), counting())) returns a Map<String, Long>. toMap(w -> w, w -> 1, Integer::sum) does the same with Integer counts.

How do you join strings with a separator in a Java stream?

Use Collectors.joining: names.stream().collect(joining(", ")) gives "ann, bob, cy", and joining(", ", "[", "]") adds a prefix and suffix. Elements must be CharSequences, so map others with String::valueOf first. Without a pipeline, String.join(", ", list) is simpler.

What is the difference between collect and reduce in Java streams?

reduce folds immutable values — each step returns a new value. collect fills a mutable container, such as a list or map, and its collector has a combiner that merges the containers built by parallel chunks. If the accumulator mutates its first argument, you need collect.

Mark as readSign in to keep your place.

Java Stream Collectors: GroupingBy, ToMap and Joining

Java Collectors build results from streams: toMap with a merge function for duplicate keys, joining, groupingBy with downstreams, partitioningBy and teeing.

  • Course: Java study plan
  • Module: Streams
  • Kind: Lesson
  • Reading time: 16 min
  • Runtime: OpenJDK 18

How does groupingBy work in Java streams?

Collectors.groupingBy in Java streams puts elements into a map keyed by what a classifier function returns: groupingBy(String::length) gives a Map<Integer, List<String>>. A second argument, a downstream collector such as counting() or mapping(...), replaces each list with a count, sum or other summary, and a three-argument form names the map type, such as TreeMap::new.

Lesson

collect is the terminal that builds something: a list, a set, a map, a string, a summary. Its argument is a Collector — a recipe with a supplier (make the container), an accumulator (add one element), a combiner (merge two containers, for parallel) and a finisher (convert at the end). You will write your own perhaps once a year; you will use the ones in java.util.stream.Collectors every day, and groupingBy with a downstream collector is the single most useful line in the whole streams API. This lesson is that toolbox.

The basics

import static java.util.stream.Collectors.*;

List<String> list   = s.collect(toList());
Set<String> set     = s.collect(toSet());                      // a HashSet: no order
Set<String> ordered = s.collect(toCollection(TreeSet::new));   // any collection you name
Deque<String> dq    = s.collect(toCollection(ArrayDeque::new));

Static-import Collectors.* in code that uses several; a pipeline reads far better as groupingBy(..., counting()) than with the class name repeated four times.

toMap — and the two traps

Map<String, Integer> lengths = words.stream().collect(toMap(w -> w, String::length));

Trap one: duplicate keys throw IllegalStateException: Duplicate key. Fix with a merge function:

Map<String, Integer> counts = words.stream().collect(toMap(w -> w, w -> 1, Integer::sum));
Map<Character, String> byInitial = words.stream().collect(toMap(w -> w.charAt(0), w -> w, (a, b) -> a));  // keep the first

Trap two: the map is a HashMap, so iteration order is unspecified. Name the map type with the four-argument form:

Map<String, Integer> inOrder = words.stream().collect(toMap(w -> w, String::length, (a, b) -> a, LinkedHashMap::new));
TreeMap<String, Integer> sorted = words.stream().collect(toMap(w -> w, String::length, (a, b) -> a, TreeMap::new));

Also: toMap rejects null values with a NullPointerException (it uses Map.merge internally). And Function.identity() is the idiomatic spelling of w -> w for the key.

joining — strings without a StringBuilder

String csv    = names.stream().collect(joining(", "));                  // "ann, bob, cy"
String pretty = names.stream().collect(joining(", ", "[", "]"));        // "[ann, bob, cy]"
String all    = names.stream().collect(joining());                       // "annbobcy"

Elements must be CharSequences — map(String::valueOf) or map(Object::toString) first for anything else. String.join(", ", list) does the same without a stream when there is nothing to filter or map.

groupingBy — the one to master

groupingBy(classifier) puts elements into a Map<K, List<T>> keyed by what the classifier returns:

Map<Integer, List<String>> byLength = words.stream().collect(groupingBy(String::length));
// {3=[ann, bob], 5=[carol]}

Add a downstream collector to say what to do with each group instead of listing it:

Map<Integer, Long> countByLength     = words.stream().collect(groupingBy(String::length, counting()));
Map<String, Integer> totalByCity     = people.stream().collect(groupingBy(Person::city, summingInt(Person::age)));
Map<String, Double> avgAgeByCity     = people.stream().collect(groupingBy(Person::city, averagingInt(Person::age)));
Map<String, Set<String>> namesByCity = people.stream().collect(groupingBy(Person::city, mapping(Person::name, toSet())));
Map<String, Optional<Person>> oldest = people.stream().collect(groupingBy(Person::city, maxBy(comparingInt(Person::age))));
Map<String, List<String>> sortedNames = people.stream().collect(groupingBy(Person::city, TreeMap::new, mapping(Person::name, toList())));

The three-argument form names the map (TreeMap::new for sorted keys, LinkedHashMap::new for first-seen order). Groups can nest: groupingBy(Person::country, groupingBy(Person::city, counting())) gives Map<String, Map<String, Long>>.

The keys are whatever the classifier returns, so groupingBy(w -> w.charAt(0)) groups by first letter, groupingBy(n -> n % 3) by remainder, and groupingBy(p -> p.age() / 10 * 10) by decade. A null classifier result throws — map it to a sentinel first.

partitioningBy — exactly two groups

Map<Boolean, List<Integer>> evenOdd = nums.stream().collect(partitioningBy(n -> n % 2 == 0));
Map<Boolean, Long> passFail = scores.stream().collect(partitioningBy(s -> s >= 50, counting()));

A partitioningBy map always has both keys, true and false, even when one side is empty — groupingBy on a boolean would omit an empty group. That is the reason to prefer it for yes/no splits.

Downstream collectors worth knowing

CollectorProduces
counting()Long
summingInt/Long/Double(f)the sum
averagingInt/Long/Double(f)Double
minBy(cmp), maxBy(cmp)Optional<T>
mapping(f, downstream)apply f first, then collect
filtering(pred, downstream) (Java 9)filter inside the group
flatMapping(f, downstream) (Java 9)flatten inside the group
reducing(identity, op)a fold per group
summarizingInt(f)IntSummaryStatistics: count, sum, min, max, average in one pass
collectingAndThen(downstream, finisher)post-process: collectingAndThen(toList(), Collections::unmodifiableList)
teeing(c1, c2, merger) (Java 12)run two collectors over the same elements and merge

teeing deserves an example, because it removes the "two passes for two facts" problem:

record MinMax(int min, int max) {}
MinMax mm = nums.stream().collect(teeing(
    minBy(Integer::compare), maxBy(Integer::compare),
    (lo, hi) -> new MinMax(lo.orElseThrow(), hi.orElseThrow())));

Writing a collector

Collector<String, StringBuilder, String> concat =
    Collector.of(StringBuilder::new, StringBuilder::append, StringBuilder::append, StringBuilder::toString);

Supplier, accumulator, combiner, finisher. You will rarely need it — but knowing the shape explains why collect is allowed to mutate (the container is yours) while reduce is not.

collect versus reduce, settled

reduce folds immutable values: new value in, new value out. collect fills a mutable container. Reducing into an ArrayList "works" sequentially and breaks in parallel; collecting into one is exactly what the combiner is for. Rule: if the accumulator mutates its first argument, it is a collector.

Interview angle

  • "What happens on duplicate keys in toMap?" IllegalStateException — supply a merge function.
  • "groupingBy returns…?" Map<K, List<T>> by default; a downstream collector replaces the List.
  • "partitioningBy versus groupingBy on a boolean?" partitioningBy always has both keys.
  • "How do you count occurrences of each word?" groupingBy(identity(), counting()) — or toMap(w -> w, w -> 1, Integer::sum).
  • "Why does collect accept a mutable container when reduce does not?" A collector has a combiner that merges containers, so parallel chunks never share one.

Key takeaways

  • toMap throws on duplicates and gives a HashMap; add a merge function and a map supplier when you care.
  • joining(sep, prefix, suffix) for strings; String.join when there is no pipeline.
  • groupingBy(classifier, mapFactory, downstream) — memorise the shape; counting, mapping, summingInt, maxBy as downstreams.
  • partitioningBy for two-way splits; both keys always present.
  • teeing for two facts in one pass; collectingAndThen to finish; Collector.of when the library has no fit.

Common questions

How do you handle duplicate keys in Collectors.toMap?

Pass a merge function as the third argument. Without one, toMap throws IllegalStateException: Duplicate key; with toMap(w -> w, w -> 1, Integer::sum) duplicates are combined, and (a, b) -> a keeps the first. A fourth argument, such as LinkedHashMap::new, chooses the map type.

What is the difference between partitioningBy and groupingBy?

partitioningBy splits elements by a predicate into exactly two groups, and its map always has both keys, true and false, even when one side is empty. groupingBy makes one group per classifier value, and a value with no elements simply has no key.

How do you count occurrences of each word with Java streams?

Group by the word itself and count: words.stream().collect(groupingBy(Function.identity(), counting())) returns a Map<String, Long>. toMap(w -> w, w -> 1, Integer::sum) does the same with Integer counts.

How do you join strings with a separator in a Java stream?

Use Collectors.joining: names.stream().collect(joining(", ")) gives "ann, bob, cy", and joining(", ", "[", "]") adds a prefix and suffix. Elements must be CharSequences, so map others with String::valueOf first. Without a pipeline, String.join(", ", list) is simpler.

What is the difference between collect and reduce in Java streams?

reduce folds immutable values — each step returns a new value. collect fills a mutable container, such as a list or map, and its collector has a combiner that merges the containers built by parallel chunks. If the accumulator mutates its first argument, you need collect.

Exercises

Group the roster

Read an integer n and n lines name city age. Using groupingBy into a TreeMap with downstream collectors, print for every city in natural order:

<city>: n=<people> avg=<average age, one decimal> names=<names in natural order joined by ,>

Then, using partitioningBy (both keys are always present), print adults=<count of age ≥ 18> minors=<count of age < 18>.

Example: 3 then ann oslo 30, bob oslo 20, cy rome 12 →

oslo: n=2 avg=25.0 names=ann,bob
rome: n=1 avg=12.0 names=cy
adults=2 minors=1

toMap without the traps

Read an integer n and n words. Print:

  1. One line per first letter, in letter order: <letter>=<words starting with it, in input order, joined by +> — a toMap whose merge function joins duplicates and whose map supplier is TreeMap::new.
  2. lengths= the toString of a LinkedHashMap from each distinct word to its length, first occurrence wins (a four-argument toMap).
  3. csv= the distinct words in natural order, joining(", ", "[", "]").

Example: 4 then apple bean avocado bean →

a=apple+avocado
b=bean+bean
lengths={apple=5, bean=4, avocado=7}
csv=[apple, avocado, bean]

In this module: Streams

← Terminal operations — reduce, match, find, count · Optional — a return type, not a null replacement →