Module 9 · Modern Java: Functional Style & Concurrency ⏱ 19 min

Collectors and Grouping

By the end of this lesson you will be able to:
  • Predict the result of collect with toList, joining, and counting collectors
  • Use groupingBy to bucket stream elements into a map and read its structure
  • Explain why a groupingBy map's iteration order is unspecified and how to make output deterministic

reduce folds a stream down to one value, but very often you do not want a single value — you want a collection. You want the filtered items gathered into a List, the names stitched into one string, or the records bucketed by category. Doing that by hand means a loop, a mutable accumulator, and a dozen chances to get the bookkeeping wrong.

collect is the terminal operation for gathering elements into something. You hand it a Collector that knows how to make a container, add an element, and combine two containers, and the library handles the rest. java.util.stream.Collectors ships the common ones ready-made — toList, toSet, toMap, joining, counting, summingInt, and the powerful groupingBy — so most code never writes its own collector.

flowchart LR
  S["a stream of elements"] --> CO["collect with a Collector"]
  CO --> L["a List, a Set, a Map, or a String"]
  style CO fill:#c2410c,color:#fff
collect hands a stream and a Collector to the library, which materialises the elements into a List, Set, Map, or String.

The simplest collectors build containers you already know. Collectors.toList() gathers the surviving elements into a list, preserving encounter order. Collectors.toSet() gathers them into a set, which discards duplicates and, like any HashSet, gives up on order. Collectors.joining(delimiter) concatenates strings, optionally wrapped in a prefix and suffix — handy for building a comma-separated line in one step.

These are predictable: toList keeps order and duplicates, toSet drops both, and joining flattens everything to one String with no trailing separator. The trap is not in these collectors but in the next family, groupingBy, whose result is a map — and a map's iteration order is something you must never take for granted.

Main.java — joining stitches the strings with a delimiter, prefix, and suffix. Real, compiling Java.
import java.util.stream.Collectors;
import java.util.stream.Stream;

public class Main {
    public static void main(String[] args) {
        String result = Stream.of("red", "green", "blue")
            .collect(Collectors.joining(", ", "[", "]"));
        System.out.println(result);
    }
}

Collectors.toMap() builds a map directly from your elements, taking one function for the key and another for the value. It is concise, but it hides a sharp edge: if two elements produce the same key, the default toMap does not silently overwrite — it throws IllegalStateException complaining about a duplicate key. The fix is to pass a third argument, a merge function that decides how to combine two clashing values, such as (a, b) -> a to keep the first.

This is the whole collectors family in miniature: the convenient form assumes well-behaved input, and real data is rarely well-behaved. Reach for toMap when your keys are genuinely unique, or add a merge function the moment duplicates are possible.

groupingBy: bucketing elements into a map

groupingBy is where collect earns its keep. You give it a classifier — a Function that assigns each element to a bucket key — and it returns a Map from each key to the elements that mapped there. Group a stream of words by their first letter and you get a map whose keys are letters and whose values are the lists of words starting with that letter.

The downstream collector is the optional second argument, and it decides what lands in each bucket. With no second argument, each bucket is a List of the elements. Pass Collectors.counting() and each bucket becomes a count instead of a list. Pass Collectors.summingInt(...) and each bucket holds a sum. One classifier, many possible shapes — which is what makes groupingBy both powerful and easy to misread.

flowchart LR
  A["apple"] --> KA["bucket a"]
  N["ant"] --> KA
  B["banana"] --> KB["bucket b"]
  KA --> M["Map of key to bucket"]
  KB --> M
  style M fill:#3776ab,color:#fff
groupingBy sends each element to the bucket named by its classifier; each bucket becomes a value in the resulting map.
Main.java — groupingBy with counting, then the keys are sorted before printing so the output is deterministic. Real, compiling Java.
import java.util.Map;
import java.util.stream.Collectors;
import java.util.stream.Stream;

public class Main {
    public static void main(String[] args) {
        Map<String, Long> counts = Stream.of("apple", "ant", "banana", "cherry", "car")
            .collect(Collectors.groupingBy(s -> s.substring(0, 1), Collectors.counting()));
        counts.keySet().stream().sorted()
            .forEach(k -> System.out.println(k + ": " + counts.get(k)));
    }
}

partitioningBy: exactly two buckets

A close cousin of groupingBy is partitioningBy, which buckets elements into exactly two groups using a Predicate. It returns a Map<Boolean, List<T>> with two keys — true for elements the predicate accepted, false for those it rejected. Because there are always exactly two keys, and because you usually look them up by name rather than iterating, partitioningBy sidesteps the ordering worry that haunts groupingBy.

It is the right tool whenever the question is binary: pass versus fail, even versus odd, present versus absent. Reach for groupingBy when there are many possible buckets; reach for partitioningBy when there are precisely two.

flowchart LR
  SRC["stream of 1 through 6"] --> PA["predicate n greater than 3"]
  PA --> F["bucket false<br/>1, 2, 3"]
  PA --> T["bucket true<br/>4, 5, 6"]
  style F fill:#b45309,color:#fff
  style T fill:#3776ab,color:#fff
partitioningBy splits a stream into exactly two buckets, keyed by whether the predicate matched.
Exercise

What does this program print? Read it carefully and type the exact output (one line).

import java.util.stream.Collectors;
import java.util.stream.Stream;

public class Main {
    public static void main(String[] args) {
        String result = Stream.of("red", "green", "blue")
            .collect(Collectors.joining(", ", "[", "]"));
        System.out.println(result);
    }
}
Exercise

Given a stream of strings, what does stream.collect(Collectors.groupingBy(s -> s.length())) return?

Exercise

Group and count. Words are grouped by first letter and counted; the keys are sorted before printing. Predict all three lines.

import java.util.Map;
import java.util.stream.Collectors;
import java.util.stream.Stream;

public class Main {
    public static void main(String[] args) {
        Map<String, Long> counts = Stream.of("apple", "ant", "banana", "cherry", "car")
            .collect(Collectors.groupingBy(s -> s.substring(0, 1), Collectors.counting()));
        counts.keySet().stream().sorted()
            .forEach(k -> System.out.println(k + ": " + counts.get(k)));
    }
}
Exercise

You group a stream with Collectors.groupingBy(...) and print the result with System.out.println(map). What can you say about the order of the entries in the output?

Exercise

partitioningBy. Numbers are split into two buckets by the predicate n > 3, then read by key. Predict both lines.

import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.stream.Stream;

public class Main {
    public static void main(String[] args) {
        Map<Boolean, List<Integer>> parts = Stream.of(1, 2, 3, 4, 5, 6)
            .collect(Collectors.partitioningBy(n -> n > 3));
        System.out.println("<=3: " + parts.get(false));
        System.out.println(">3: " + parts.get(true));
    }
}

Recap

  • collect with a Collector materialises a stream into a container; Collectors ships toList, toSet, toMap, joining, counting, and groupingBy.
  • toList preserves order and duplicates; toSet drops both; joining flattens to one String.
  • toMap throws on a duplicate key unless you pass a merge function to combine the clashing values.
  • groupingBy(classifier) buckets elements into a map; a downstream collector such as counting() changes what each bucket holds.
  • A groupingBy map's iteration order is unspecified — sort the keys or pass TreeMap::new when order matters.
  • partitioningBy(predicate) makes exactly two buckets, keyed true and false.

Next you will meet Optional — the type Java uses to make a possibly-absent value explicit instead of letting null lurk in your return types.

Checkpoint quiz

What does groupingBy return by default if you pass only a classifier?

Why might printing a groupingBy map directly give output whose entry order is hard to predict?

Go deeper — technical resources