AlgoMaster Logo

Collectors

High Priority27 min readUpdated September 21, 2026
Listen to this chapter
Unlock Audio

Collectors is a utility class in java.util.stream that supplies ready-made implementations of the Collector interface for Stream.collect. The previous lesson introduced collect(Collector) as a terminal operation; this lesson covers what you actually pass to it. We'll walk through collecting into lists, sets, and maps, grouping and partitioning, downstream collectors that summarize each group, joining strings, the teeing collector that fans a single stream into two summaries, and summary statistics that compute count, sum, min, max, and average in one pass.

The Collector Shape

A Collector<T, A, R> is built from four pieces. T is the input element type. A is the type of an intermediate accumulator (usually a mutable container like ArrayList or StringBuilder). R is the final result type. The four pieces are:

  1. Supplier: creates a new, empty accumulator.
  2. Accumulator: folds an input element into the accumulator.
  3. Combiner: merges two accumulators into one (used for parallel streams).
  4. Finisher: turns the accumulator into the final result.

The diagram shows the lifecycle of a collector during a collect call. The stream pulls a fresh accumulator from the supplier, calls the accumulator on every element, optionally combines partial accumulators across threads, and finally hands the accumulator (or its transformed form) back through the finisher.

You almost never write these four pieces by hand. The Collectors class gives you a few dozen factories that build the appropriate shape for the job. You pick a factory, pass it to collect, and get a list, a set, a map, a count, a sum, a joined string, or whatever else you need.

Collecting Into a List

The simplest collector is Collectors.toList(). It accumulates stream elements into a List and returns it.

The list comes back mutable: we successfully added "Marker" after collecting. That's the historical contract of Collectors.toList(). The Javadoc doesn't promise mutability, but in practice it returns an ArrayList, and existing code has relied on that for years.

Compare this with Stream.toList() (Java 16). Stream.toList() returns an unmodifiable list. Trying to add to it throws UnsupportedOperationException. If you want a mutable list, use Collectors.toList() (or Collectors.toCollection(ArrayList::new)). If you want an unmodifiable list, prefer Stream.toList() or Collectors.toUnmodifiableList().

toUnmodifiableList() is a clear signal in the API: "this list is read-only, don't even try". It also rejects null elements, which the mutable form silently allows.

FactoryReturnsMutable?Allows nulls?
Collectors.toList()List<T> (typically ArrayList)YesYes
Stream.toList()List<T> (unmodifiable)NoYes
Collectors.toUnmodifiableList()List<T> (unmodifiable)NoNo

Collecting Into a Set

Collectors.toSet() works the same way but accumulates into a Set, removing duplicates. There's no order guarantee, because it's typically a HashSet.

The print order isn't guaranteed; what is guaranteed is that duplicates are gone. If you need both deduplication and a predictable order, use Collectors.toCollection(LinkedHashSet::new) (covered shortly) or sort the stream first.

Collectors.toUnmodifiableSet() parallels toUnmodifiableList: it returns a read-only set and rejects nulls.

Collecting Into a Specific Collection

When the built-in toList and toSet don't fit, use Collectors.toCollection(Supplier). You pass a constructor reference (or any Supplier), and the collector accumulates into that collection type.

TreeSet deduplicates and keeps elements in natural order. LinkedHashSet::new would deduplicate and preserve insertion order. ArrayDeque::new would give you a queue. You pick the shape; the collector handles the wiring.

toCollection(TreeSet::new) is O(n log n) overall because every insert into a TreeSet is O(log n). toCollection(HashSet::new) is O(n) on average. Use a sorted set only when you actually need the ordering.

Collecting Into a Map

Collectors.toMap(keyMapper, valueMapper) is where things get interesting. You pass two functions: one extracts the key from each element, the other extracts the value.

The keyMapper extracts the product ID; the valueMapper returns the product itself (p -> p, or equivalently Function.identity()). You get back a Map<String, Product> you can use as a lookup table.

Duplicate Keys Throw an Exception

If two elements produce the same key, toMap throws IllegalStateException. It won't overwrite, it won't keep one, it'll fail loudly.

What's wrong with this code?

Two products share the "Stationery" category. toMap doesn't know which one you want, so it refuses the call.

Fix: use the three-argument toMap and provide a merge function. The merge function takes the existing value and the new value and returns whatever you want to keep.

The merge function says "keep the one with the lower price". For the two stationery products, the Pen wins. Other common merge choices: (a, b) -> a (keep first), (a, b) -> b (keep last), Integer::sum if you're collecting numeric values, (a, b) -> a + ", " + b if you're concatenating strings.

toMap without a merge function is convenient but fragile. If the data ever produces a duplicate key, the entire collect fails. In code that ingests external data (uploads, third-party feeds), assume duplicates are possible and use the three-argument form.

Choosing the Map Type

The four-argument toMap adds a map supplier so you can pick the implementation. By default, toMap returns a HashMap, which has no order guarantees. If you want insertion order, ask for LinkedHashMap. If you want sorted keys, ask for TreeMap.

In this run both happen to print the same order, but the guarantee is different. LinkedHashMap keeps the iteration order in which keys were first inserted. TreeMap keeps keys in natural (or Comparator-defined) order. Insert "P-301" first and the two outputs diverge.

Collectors.toUnmodifiableMap is the read-only cousin: it returns an unmodifiable map and rejects null keys and values. It also throws on duplicate keys unless you pass a merge function.

Grouping

Collectors.groupingBy(classifier) is one of the most used methods on Collectors. The classifier extracts a grouping key, and the collector returns a Map<K, List<T>> where each key maps to the list of elements that produced it.

Two products land in "Stationery", two in "Toys". The single-argument form always groups into lists, which is the right default. When you want something other than a list (a count, a sum, the most expensive product per group), use a downstream collector.

Grouping With a Downstream Collector

The two-argument form groupingBy(classifier, downstream) runs a second collector on each group. You can count, sum, average, find the minimum, build a set, build a map, anything Collectors can do.

Collectors.counting() counts the number of elements that reached it. Combined with groupingBy, it produces a Map<K, Long> of counts per group. That's a one-line answer to "how many products in each category".

The diagram shows the two-stage shape of grouped collection. The classifier routes each element to a group. The downstream collector then runs on each group independently. The final result is a map from group key to whatever the downstream produced.

You can chain groupingBy calls to build nested maps. The downstream of one groupingBy can be another groupingBy.

Two levels of grouping: first by category, then by in-stock status. Each leaf is a list of products that share both keys.

Choosing the Map Type for groupingBy

The three-argument form groupingBy(classifier, mapFactory, downstream) lets you pick the outer map type. Default is HashMap. Pass TreeMap::new to sort by group key, or LinkedHashMap::new to preserve the first-seen order.

The keys come out alphabetically because the outer map is now a TreeMap. With the default HashMap, the order would be whatever the hash function happens to produce.

groupingBy(..., TreeMap::new, ...) adds an O(log k) cost per insert (k = number of groups). For small numbers of groups this is negligible; for thousands of groups it adds up. Stick with HashMap unless you actually need sorted keys.

Partitioning

Collectors.partitioningBy(predicate) is groupingBy specialized for predicates. Instead of a classifier that returns any key, it takes a Predicate<T> and groups elements into exactly two buckets: true and false.

The result is always a Map<Boolean, List<T>> with exactly the two keys true and false, even if one of them maps to an empty list. That's the guarantee that distinguishes partitioningBy from groupingBy(p -> p.stock > 0).

partitioningBy is faster than groupingBy on a boolean classifier because it doesn't use a Map internally; it uses a fixed two-slot structure. For boolean partitioning, prefer it over groupingBy.

If groupingBy had been used on the same predicate and one side were empty, that key would simply be missing from the map. partitioningBy keeps both keys present, which is convenient when downstream code expects to find both entries.

The two-argument form partitioningBy(predicate, downstream) accepts a downstream collector, exactly like groupingBy.

A clean one-liner for "how many products are in stock versus out of stock".

Counting, Summing, and Averaging

These three are the most commonly used downstream collectors. They turn a group into a number.

Collectors.counting() counts elements and returns a Long. summingInt, summingLong, summingDouble apply a numeric function to each element and add the results. averagingInt, averagingLong, averagingDouble do the same but return a Double average.

The downstream summingInt(p -> p.stock) adds the stock field of each product in the group. Stationery has 50 + 200 = 250 units; Toys has 12 + 30 = 42 units.

A single program that uses count, sum, and average together:

The Toys average is (49.99 + 9.99) / 2 = 29.99. The Stationery average is (4.99 + 1.49) / 2 = 3.24.

Downstream collectorReturnsUse
counting()LongHow many elements per group
summingInt(toInt)IntegerSum of an int-valued function
summingLong(toLong)LongSum of a long-valued function
summingDouble(toDouble)DoubleSum of a double-valued function
averagingInt(toInt)DoubleAverage of an int-valued function
averagingLong(toLong)DoubleAverage of a long-valued function
averagingDouble(toDouble)DoubleAverage of a double-valued function

Finding Min and Max in Groups

Collectors.minBy(Comparator) and Collectors.maxBy(Comparator) find the smallest or largest element in a group. Both return an Optional<T> because a group might (in principle) be empty.

The Optional wrapping is annoying when you know the groups are non-empty.

Mapping and Filtering Within a Group

Sometimes you don't want the raw elements in each group; you want some transformation of them. Collectors.mapping(mapper, downstream) applies a function to each element before handing it to the downstream collector.

The classifier groups by category, but the downstream collector pulls the name out of each product before adding it to the list. Without mapping, you'd get a Map<String, List<Product>>. With mapping, you get a Map<String, List<String>>.

Collectors.filtering(predicate, downstream) (added in Java 9) is the mirror image. It runs a predicate against each element and forwards only the ones that match.

Note that Stationery is still in the result map, with an empty list. That's the important distinction from filtering the stream upfront with .filter. If you'd filtered before grouping, the entire Stationery group would have vanished because no Stationery product passes the predicate. Collectors.filtering keeps the group, drops only the non-matching elements.

Reducing Within a Group

Collectors.reducing is the most general downstream. It works like Stream.reduce but as a collector. There are three overloads:

  • reducing(BinaryOperator<T>) returns Optional<T>.
  • reducing(T identity, BinaryOperator<T>) returns T.
  • reducing(U identity, Function<T, U> mapper, BinaryOperator<U>) returns U.

The three-argument reducing takes an identity (0.0), a mapper (price * stock), and a binary operator (Double::sum). It's essentially a generalized summingDouble, but it can produce non-numeric results too (concatenate strings, build a custom object, anything associative).

For straight numeric sums, prefer summingInt/Long/Double because they're clearer at the call site. Use reducing when the operation isn't a sum or an average.

collectingAndThen: Post-Processing

Collectors.collectingAndThen(downstream, finisher) wraps another collector and runs a function on the result. The most common use is making a collected list unmodifiable.

The list is built mutably by toList(), then wrapped by Collections::unmodifiableList in the finisher step. Since Java 10, Collectors.toUnmodifiableList() does the same job more directly, but the pattern is still useful when you need to wrap something with no dedicated factory.

The other classic use is stripping Optional off the result of maxBy / minBy:

The downstream maxBy returns Optional<Product> for each group. The finisher (opt -> opt.orElse(null)) unwraps the Optional. Since groupingBy never produces an empty group (a group only exists if at least one element classified into it), the Optional is always non-empty and the unwrap is safe.

Joining Strings

Collectors.joining concatenates string elements into one big string. It has three overloads:

  • joining(): no separator.
  • joining(CharSequence delimiter): separator between elements.
  • joining(CharSequence delimiter, CharSequence prefix, CharSequence suffix): separator plus prefix and suffix.

joining only works on Stream<? extends CharSequence>. If you have a stream of products, you'll need a .map(p -> p.name) first (or use Collectors.mapping(p -> p.name, joining(", ")) as a downstream).

A common use case: build a comma-separated list of product names per category.

The mapping downstream pulls names out of each product, and the joining collector glues them together with commas. Useful for printing summary lines, building log messages, or generating quick CSV-style output.

joining uses a StringBuilder internally, so it's O(n) in the total character count. Don't do stream.reduce("", String::concat) for the same job; that's O(n^2) because each concatenation allocates a new string.

Summary Statistics

When you want count, sum, min, max, and average all at once, the dedicated factories beat running separate collectors. Collectors.summarizingInt, summarizingLong, and summarizingDouble produce a single object holding all five statistics computed in one pass.

One traversal through the stream produces all five numbers. If you'd used count, sum, min, max, and average as separate operations, you'd traverse the data five times (or you'd need to collect into a list first and reuse it).

summarizingInt and summarizingLong work the same way but return IntSummaryStatistics and LongSummaryStatistics. Use them when the field is naturally an int or long (stock counts, view counts, order counts).

You can also use these as downstream collectors to get one summary per group:

For each category, the collector emits a complete DoubleSummaryStatistics. Reports, dashboards, and any "give me everything about this group" use case become one-liners.

teeing: Two Collectors in One Pass

Collectors.teeing(downstream1, downstream2, merger) was added in Java 12 and is the answer to "I want two different summaries from the same stream without iterating twice". It runs two downstream collectors in parallel and then combines their results with a merger function.

An example: compute total revenue and average price in a single pass.

One traversal, two answers. The first downstream (summingDouble) computes total revenue (price * stock per product, summed). The second downstream (averagingDouble) computes the average product price. The merger (RevenueReport::new) takes the two numbers and packages them.

teeing also pairs well with partitioningBy to compute aggregates over both buckets in one pass:

Two scans worth of work, one scan of cost. The merger takes the two Optional<Product> values and builds a clean record.

Full Reference Table

A consolidated view of the Collectors factories:

FactoryReturnsUse
toList()mutable List<T>Default list collection
toUnmodifiableList()immutable List<T>Read-only result, no nulls
toSet()mutable Set<T>Deduplicate
toUnmodifiableSet()immutable Set<T>Read-only set, no nulls
toCollection(supplier)any CollectionPick exact collection type
toMap(k, v)Map<K, V>Lookup table; throws on duplicate keys
toMap(k, v, merge)Map<K, V>Lookup table with collision handling
toMap(k, v, merge, mapSupplier)Map<K, V>Pick map type (LinkedHashMap, TreeMap)
toUnmodifiableMap(k, v[, merge])immutable Map<K, V>Read-only map, no nulls
groupingBy(classifier)Map<K, List<T>>Group by key into lists
groupingBy(classifier, downstream)Map<K, D>Group then reduce each group
groupingBy(classifier, mapSupplier, downstream)Map<K, D>Group with chosen map type
partitioningBy(predicate)Map<Boolean, List<T>>Split into two groups
partitioningBy(predicate, downstream)Map<Boolean, D>Split then reduce each side
counting()LongNumber of elements
summingInt/Long/Double(toN)Integer/Long/DoubleSum of mapped values
averagingInt/Long/Double(toN)DoubleAverage of mapped values
minBy(comparator)Optional<T>Smallest element
maxBy(comparator)Optional<T>Largest element
reducing(...)variesCustom associative reduction
mapping(mapper, downstream)depends on downstreamTransform before collecting
filtering(predicate, downstream)depends on downstreamFilter without dropping empty groups
collectingAndThen(downstream, finisher)result of finisherPost-process collected result
joining() / joining(sep) / joining(sep, prefix, suffix)StringConcatenate CharSequence elements
summarizingInt/Long/Double(toN)*SummaryStatisticscount, sum, min, max, average in one pass
teeing(d1, d2, merger)result of mergerTwo collectors, one pass

Putting It Together

A small report program that exercises most of these collectors. Group products by category, count them per group, sum their revenue, find the most expensive in each category, and produce a one-line summary string.

groupingBy splits the products by category. collectingAndThen runs a custom finisher on each group's list: compute summary statistics, find the most expensive product, and assemble a string. The result is a Map<String, String> ready to print or log.

There's another way to write this with teeing, which avoids the inner streams:

That nested teeing is uglier than the collectingAndThen version. teeing works best when you want exactly two summaries; once you need three or more pieces, collecting to a list and computing them with a small finisher is often clearer.

Collectors has more than one way to express most reductions. Pick the form that reads clearly for the problem at hand.

Quiz

Collectors Quiz

10 quizzes