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
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.
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
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
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);
}
}
joining(", ", "[", "]")places the delimiter between elements and wraps the whole string in the prefix and suffix.There is no trailing delimiter after
blue.
Given a stream of strings, what does stream.collect(Collectors.groupingBy(s -> s.length())) return?
groupingBy uses its classifier to choose the key, and by default collects each group into a List. Here the keys are lengths, and each value is the list of strings sharing that length.
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)));
}
}
appleandantstart witha,bananawithb,cherryandcarwithc.counting()turns each bucket into a count: a is 2, b is 1, c is 2.The keys are sorted before printing, so they appear as a, b, c.
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?
groupingBy returns a HashMap by default, whose iteration order is defined by hash buckets and is not guaranteed. To get a predictable order, sort the keys when reading them or pass TreeMap::new as the map supplier.
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));
}
}
partitioningByputs elements the predicate rejects underfalseand those it accepts undertrue.Each bucket is a List, so it prints with brackets and commas in encounter order.
Recap
collectwith aCollectormaterialises a stream into a container;CollectorsshipstoList,toSet,toMap,joining,counting, andgroupingBy.toListpreserves order and duplicates;toSetdrops both;joiningflattens to oneString.toMapthrows 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 ascounting()changes what each bucket holds.- A
groupingBymap's iteration order is unspecified — sort the keys or passTreeMap::newwhen order matters. partitioningBy(predicate)makes exactly two buckets, keyedtrueandfalse.
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.