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
| Collector | Produces |
|---|---|
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. - "
groupingByreturns…?"Map<K, List<T>>by default; a downstream collector replaces theList. - "
partitioningByversusgroupingByon a boolean?"partitioningByalways has both keys. - "How do you count occurrences of each word?"
groupingBy(identity(), counting())— ortoMap(w -> w, w -> 1, Integer::sum). - "Why does
collectaccept a mutable container whenreducedoes not?" A collector has a combiner that merges containers, so parallel chunks never share one.
Key takeaways
toMapthrows on duplicates and gives aHashMap; add a merge function and a map supplier when you care.joining(sep, prefix, suffix)for strings;String.joinwhen there is no pipeline.groupingBy(classifier, mapFactory, downstream)— memorise the shape;counting,mapping,summingInt,maxByas downstreams.partitioningByfor two-way splits; both keys always present.teeingfor two facts in one pass;collectingAndThento finish;Collector.ofwhen 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=1toMap without the traps
Read an integer n and n words. Print:
- One line per first letter, in letter order:
<letter>=<words starting with it, in input order, joined by +>— atoMapwhose merge function joins duplicates and whose map supplier isTreeMap::new. lengths=thetoStringof aLinkedHashMapfrom each distinct word to its length, first occurrence wins (a four-argumenttoMap).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
- Streams — pipelines over data
- Intermediate operations — map, filter, flatMap and friends
- Terminal operations — reduce, match, find, count
- Collectors — toMap, joining, groupingBy, partitioningBy (this lesson)
- Optional — a return type, not a null replacement
- Primitive streams — IntStream, LongStream, DoubleStream
- Parallel streams, ordering and performance
- Checkpoint — Streams
← Terminal operations — reduce, match, find, count · Optional — a return type, not a null replacement →