Introduction to the Collectors class
-
Last Updated: April 10, 2024
-
By: javahandson
-
Series

The Collectors class in Java 8 is the toolbox you reach for at the end of almost every stream. You build a pipeline, and then a collector turns the flowing elements into something you can keep: a list, a total, a sentence, or a map of groups. This introduction explains what the Collectors class is, how a Collector works inside, and why the whole design makes your stream code short, readable, and reusable.
Every stream pipeline has to end somewhere. You filter, you map, you sort, and then what? The elements are still flowing. Nothing has landed in a variable yet.
That last step is the job of a collector. You call collect() and hand it a recipe. The recipe says what to build: a list of names, an average mark, or a map of students by grade. Java then runs the recipe over every element.
The Collectors class in Java is where those recipes live. It ships dozens of them, ready to use. Most real stream code you read will end with one.
This article is the doorway to the collectors part of our Java 8 series. We will not dig deep into every single method here. Each big family has its own article. Instead, we will learn how collectors think, so every later article clicks into place.
Here is the plan, from simple to advanced:
Let us start with a simple picture before any code.
Think of a kitchen. The stream is a conveyor belt of ingredients. The collect() call is the cook at the end of the belt. A collector is the recipe card the cook follows.
Would you write a new recipe every time you boil pasta? Of course not. You grab a card from the box. The Collectors class is that box of cards. It holds tested recipes for the jobs developers do every day.
Some cards put elements into a container, like a List or a Set. Some crunch numbers, like a sum or an average. Others build text or split elements into groups. You pick a card, pass it to collect(), and get your result.
The class sits in the java.util.stream package, right next to Stream itself. It arrived in Java 8 along with the rest of the Stream API.
A few facts about its shape are worth knowing:
Because every method is static, many developers use a static import. The call then reads almost like English.
import static java.util.stream.Collectors.toList;
List<String> names = Stream.of("Suraj", "Iqbal", "Amar")
.collect(toList());
System.out.println(names); // Output: [Suraj, Iqbal, Amar]Both styles work. We will write Collectors.toList() in full in this article, so you can always see where a method comes from.
Three names look almost the same. Beginners mix them up all the time, so let us pin them down.
| Name | What it is | Kitchen analogy |
|---|---|---|
| Collector | An interface that describes how to build a result | The idea of a recipe card |
| Collectors | A utility class full of ready-made Collector objects | The box of printed cards |
| collect() | A terminal method on Stream that runs a Collector | The cook who follows the card |
So the flow is always the same. You take a Collector from Collectors and pass it to collect(). The stream supplies the ingredients, and the Collector says what to do with them.
Why did Java need a whole class for this? A plain loop can build any result. The answer shows up the moment a task gets slightly bigger than “add everything to a list”.
Say we have a list of students, and we want them grouped by grade. Here is a small Student class we will use for the rest of the article.
package com.javahandson.collectors;
public class Student {
private final int rollNumber;
private final String name;
private final char grade;
private final int marks;
public Student(int rollNumber, String name, char grade, int marks) {
this.rollNumber = rollNumber;
this.name = name;
this.grade = grade;
this.marks = marks;
}
public int getRollNumber() { return rollNumber; }
public String getName() { return name; }
public char getGrade() { return grade; }
public int getMarks() { return marks; }
@Override
public String toString() {
return name;
}
}Now the grouping, written with loops and if-checks the way we did before Java 8.
List<Student> students = Arrays.asList(
new Student(101, "Suraj", 'B', 72),
new Student(102, "Iqbal", 'A', 91),
new Student(103, "Amar", 'B', 68),
new Student(104, "Amit", 'C', 55),
new Student(105, "Suchit", 'C', 49),
new Student(106, "Kartik", 'A', 88));
Map<Character, List<Student>> byGrade = new HashMap<>();
for (Student student : students) {
List<Student> bucket = byGrade.get(student.getGrade());
if (bucket == null) {
bucket = new ArrayList<>();
byGrade.put(student.getGrade(), bucket);
}
bucket.add(student);
}
System.out.println(byGrade);
// Output: {A=[Iqbal, Kartik], B=[Suraj, Amar], C=[Amit, Suchit]}It works. But look at how much code a one-sentence idea needs. First you create an empty map. Next comes a bucket lookup and a null check. Then a new list gets created and stored. Only after all that do you add the student.
Could a new teammate tell what this loop does at a glance? Probably not. They have to run it in their head first.
Here is the same result with a collector from the Collectors class.
Map<Character, List<Student>> byGrade = students.stream()
.collect(Collectors.groupingBy(Student::getGrade));
System.out.println(byGrade);
// Output: {A=[Iqbal, Kartik], B=[Suraj, Amar], C=[Amit, Suchit]}One line of real work. It reads just like the requirement: group the students by grade. You say what you want, and the collector handles how.
Do not worry about groupingBy itself yet. It has a full article of its own, Grouping in Java 8. For now, just notice the difference in shape.
Shorter code is nice, but it is not the main win. Here is what really changes:
That last point matters more than it looks. Section 5 explains why a collector can run safely on many threads.
The Java docs describe collect() as a mutable reduction. That phrase sounds scary. Luckily, the idea behind it is simple.
A reduction takes many elements and boils them down to one result. A sum is a reduction. So is a list, if you think of the list as “one result”.
Now picture shopping. You walk the aisles and drop each item into the same basket. You do not buy a new, bigger basket for every item you pick up. That is mutable reduction: one container, changed again and again.
A collector works the same way. It creates one empty container at the start. Then it drops each element into that container. At the end, the full container becomes your result.
Old articles call this a “mutable fold”. Same thing, different name. Fold is just another word for reduce.
Streams already have a reduce() method. So why add collect() as well?
The reduce method is built for immutable values. Each step creates a brand-new result from the old one. That suits numbers well. Two ints go in, a new int comes out.
Try the same trick with a list, though, and it gets ugly. You would copy the whole list at every step, just to add one element. That is slow and wasteful. The collect method avoids the copying by filling one container in place.
We compare the two in detail, side by side, in Reduce Method in Java 8 Streams. The short version: use reduce for values, and use a collector for containers.
You can use collectors for years without opening one up. Still, a peek inside explains a lot of odd behaviour. It also answers several classic interview questions.
The interface is declared as Collector<T, A, R>. Each letter has a clear job:
Often A and R are the same. For toList(), the working container is a List, and the result is that same List. Sometimes they differ, and we will see why in section 5.4.
Have you seen a question mark in a signature like Collector<T, ?, List<T>>? That wildcard sits in the A slot. The JDK hides its working container on purpose. You care about what goes in and what comes out, not the scratch space in between.
Every Collector hands the stream four small functions, plus a set of flags. Our article on collect to List, Set, and Map introduces the first three. Here is the full set:
| Method | Returns | Job |
|---|---|---|
| supplier() | Supplier<A> | Creates a fresh, empty container |
| accumulator() | BiConsumer<A, T> | Drops one element into a container |
| combiner() | BinaryOperator<A> | Merges two partial containers into one |
| finisher() | Function<A, R> | Turns the container into the final result |
| characteristics() | Set<Characteristics> | Flags that tell the stream how it may optimise |
Notice something? These are the same functional interfaces you already know from java.util.function. A Collector is just a bundle of lambdas with a name.
The best way to understand those functions is to call them yourself. The small method below does what collect() does on a plain, single-threaded stream.
static <T, A, R> R runByHand(Collector<T, A, R> collector, List<? extends T> items) {
A container = collector.supplier().get(); // 1. empty container
for (T item : items) {
collector.accumulator().accept(container, item); // 2. add each element
}
return collector.finisher().apply(container); // 3. final result
}
public static void main(String[] args) {
List<String> names = Arrays.asList("Suraj", "Iqbal", "Amar");
List<String> list = runByHand(Collectors.toList(), names);
String line = runByHand(Collectors.joining(" | "), names);
System.out.println(list); // Output: [Suraj, Iqbal, Amar]
System.out.println(line); // Output: Suraj | Iqbal | Amar
}That is the whole secret. The supplier makes the basket. Each element goes in through the accumulator. At the end, the finisher hands you the result.
Why does the helper take List<? extends T>? The joining collector accepts any CharSequence, so its T is CharSequence. Our list holds Strings. The wildcard lets a list of Strings feed a collector of CharSequences.
We skipped the combiner on purpose. A single thread only ever has one container, so there is nothing to merge. Section 5.6 shows when the combiner wakes up.
For toList(), the finisher does nothing. It returns the list it gets. So why have a finisher at all?
Because the best working container is often not the best result. Take joining(). Gluing text piece by piece works best in a mutable text buffer. But you want a plain String at the end. The finisher does that last conversion once, instead of on every element.
Averages are another good case. The averagingInt collector keeps a small array inside, holding a running total and a count. Only at the very end does its finisher divide one by the other. You never see the array. You just get a Double.
So remember the pattern. A is whatever makes collecting fast. R is whatever is nice to use. The finisher bridges the two.
The characteristics() method returns a set of flags from the Collector.Characteristics enum. There are only three:
You can print the flags of any collector. Try it with a few from the JDK:
System.out.println(Collectors.toList().characteristics()); // Output: [IDENTITY_FINISH] System.out.println(Collectors.toSet().characteristics()); // Output: [UNORDERED, IDENTITY_FINISH] System.out.println(Collectors.joining().characteristics()); // Output: [] System.out.println(Collectors.groupingByConcurrent(Student::getGrade).characteristics()); // Output: [CONCURRENT, UNORDERED, IDENTITY_FINISH]
Each result makes sense once you know the flags. A list returns its container as is, so it has IDENTITY_FINISH. A set has no order, so it adds UNORDERED. The joining collector must turn its buffer into a String, so it gets no flags at all.
You rarely set these flags yourself. Still, they explain why toSet() on a parallel stream never pays the cost of keeping order. They also explain why some collectors are faster in parallel than others.
Here is where the combiner earns its place. A parallel stream splits the data into chunks. Each thread calls the supplier and gets its own container. No two threads touch the same ArrayList.
When the threads finish, the combiner merges their containers, two at a time. That design is why toList() stays safe in parallel, even though ArrayList is not thread-safe. Nobody shares anything until the merge.
A CONCURRENT collector takes the other path. All threads write into one shared, thread-safe container, such as a ConcurrentHashMap. That skips the merge step entirely. The trade-off is lost ordering, which is why these collectors are also UNORDERED.
We cover the costs and traps of parallel work in Parallel Stream in Java 8. The key point for now: a well-behaved collector needs no locks in your code.
In Java 8, the Collectors class holds 37 methods, counting overloads. That list looks huge at first. It gets much smaller once you sort the methods into five families.
This section is a map, not a manual. We show one quick example per family and link to the article that covers it in depth.
All examples below use the six students from section 3. Here they are again, with their marks, so you can check each output yourself:
| Roll | Name | Grade | Marks |
|---|---|---|---|
| 101 | Suraj | B | 72 |
| 102 | Iqbal | A | 91 |
| 103 | Amar | B | 68 |
| 104 | Amit | C | 55 |
| 105 | Suchit | C | 49 |
| 106 | Kartik | A | 88 |
The first family puts elements into a collection or a map. It is the family you will use most.
List<String> names = students.stream()
.map(Student::getName)
.collect(Collectors.toList());
System.out.println(names);
// Output: [Suraj, Iqbal, Amar, Amit, Suchit, Kartik]Its members are toList, toSet, toMap, toCollection, and toConcurrentMap. The duplicate-key trap in toMap and the “which List do I get?” question both live in Java 8 Stream collect to List, Set, and Map.
The second family boils elements down to a number. Counting, summing, averaging, and finding the top element all belong here.
Integer total = students.stream()
.collect(Collectors.summingInt(Student::getMarks));
Double average = students.stream()
.collect(Collectors.averagingInt(Student::getMarks));
System.out.println(total); // Output: 423
System.out.println(average); // Output: 70.5The family includes counting, summingInt/Long/Double, averagingInt/Long/Double, summarizingInt/Long/Double, minBy, and maxBy. Two articles go deep here. Summing Method in Collectors API covers totals and overflow. Java 8 Summarizing Methods gets count, sum, min, max, and average in one pass.
The third family is small. It has just one method, joining, with three overloads. It glues CharSequence elements into one String.
String roll = students.stream()
.map(Student::getName)
.collect(Collectors.joining(", ", "[", "]"));
System.out.println(roll);
// Output: [Suraj, Iqbal, Amar, Amit, Suchit, Kartik]The three arguments are the separator, the prefix, and the suffix. Both joining and counting will get their own article in this series.
The fourth family splits elements into buckets. You have already met groupingBy. Its little sibling, partitioningBy, splits by a yes-or-no test.
Map<Boolean, List<Student>> passed = students.stream()
.collect(Collectors.partitioningBy(s -> s.getMarks() >= 60));
System.out.println(passed);
// Output: {false=[Amit, Suchit], true=[Suraj, Iqbal, Amar, Kartik]}This family holds groupingBy, groupingByConcurrent, and partitioningBy. Each has several overloads. Grouping in Java 8 and Partitioning in Java 8 walk through every one.
The last family is different. These methods do not build a result on their own. Instead, they take another collector and tweak it.
Adapters make the most sense inside a grouping collector, as section 7 shows. A later article in this series covers mapping and collectingAndThen in depth, along with writing your own collector.
Keep this table close while you learn. It maps each goal to the right family.
| Family | Main methods | Typical result |
|---|---|---|
| Containers | toList, toSet, toMap, toCollection | List, Set, Map |
| Numbers | counting, summingInt, averagingInt, summarizingInt, minBy, maxBy | Long, Integer, Double, statistics, Optional |
| Text | joining | String |
| Groups | groupingBy, groupingByConcurrent, partitioningBy | Map of buckets |
| Adapters | mapping, filtering, flatMapping, collectingAndThen, reducing, teeing | Whatever the wrapped collector builds |
The Collectors API rests on two design ideas. The first is composability: small collectors plug into bigger ones, like Lego bricks.
By default, groupingBy puts each group into a List. But what if you want something else for each group? Most grouping methods accept a second collector, called the downstream collector. It decides what happens inside each bucket.
Map<Character, Long> countByGrade = students.stream()
.collect(Collectors.groupingBy(Student::getGrade, Collectors.counting()));
Map<Character, Double> averageByGrade = students.stream()
.collect(Collectors.groupingBy(Student::getGrade,
Collectors.averagingInt(Student::getMarks)));
System.out.println(countByGrade); // Output: {A=2, B=2, C=2}
System.out.println(averageByGrade); // Output: {A=89.5, B=70.0, C=52.0}See what happened? The outer collector stayed the same. We only swapped the inner one. One small change gave us counts, then averages. Try doing that to the loop from section 3 without rewriting half of it.
This is why the number family matters so much. Its members are rarely used alone. They shine as downstream collectors inside a group.
Nested collectors can look dense at first. The trick is to read them from the inside out, like peeling an onion in reverse.
Map<Character, String> topperByGrade = students.stream()
.collect(Collectors.groupingBy(Student::getGrade,
Collectors.collectingAndThen(
Collectors.maxBy(Comparator.comparingInt(Student::getMarks)),
best -> best.get().getName())));
System.out.println(topperByGrade); // Output: {A=Iqbal, B=Suraj, C=Amit}Start at the deepest line and work outward:
Why is calling get() safe here? A grade only appears as a key if at least one student has it. So every bucket holds someone, and maxBy never comes back empty.
Nesting stacks collectors vertically. Java 12 added a way to place them side by side. The teeing collector sends every element to two collectors, then merges their answers.
// Java 12 and later
String range = students.stream()
.collect(Collectors.teeing(
Collectors.minBy(Comparator.comparingInt(Student::getMarks)),
Collectors.maxBy(Comparator.comparingInt(Student::getMarks)),
(low, high) -> low.get().getName() + " to " + high.get().getName()));
System.out.println(range); // Output: Suchit to IqbalThe stream runs only once, yet you get two answers. On Java 8, you would need two separate streams, or summarizingInt when both answers are numbers.
The second design idea is reusability. A collector is a plain object. You can store it, pass it around, and use it again and again.
A stream is single-use. Once a terminal operation runs, the stream is spent. Try to use it again and Java throws an IllegalStateException.
A collector has no such limit. It holds no data between runs. Every time collect() starts, it calls the supplier and gets a brand-new, empty container. So one Collector object can serve a thousand streams, one after another.
Think of the recipe card again. Cooking a dinner uses up the ingredients. The card stays in the box, ready for tomorrow.
Because collectors are reusable, you can build one once and name it. A good name makes every call site easier to read.
static final Collector<Student, ?, Map<Character, Long>> COUNT_BY_GRADE =
Collectors.groupingBy(Student::getGrade, Collectors.counting());
// Same collector, two different streams
Map<Character, Long> everyone = students.stream()
.collect(COUNT_BY_GRADE);
Map<Character, Long> highScorers = students.stream()
.filter(s -> s.getMarks() >= 70)
.collect(COUNT_BY_GRADE);
System.out.println(everyone); // Output: {A=2, B=2, C=2}
System.out.println(highScorers); // Output: {A=2, B=1}The two results do not leak into each other. Each collect() call got its own fresh map from the supplier. Notice the wildcard in the type again. We name T and R, and let the JDK keep its container type private.
A constant is fixed. What if the recipe needs a setting, like a separator? Wrap it in a method that takes the setting and returns a Collector.
static Collector<Student, ?, String> namesJoinedBy(String separator) {
return Collectors.mapping(Student::getName, Collectors.joining(separator));
}
// Used at the top level
String all = students.stream().collect(namesJoinedBy(" / "));
System.out.println(all);
// Output: Suraj / Iqbal / Amar / Amit / Suchit / Kartik
// Used as a downstream collector
Map<Character, String> namesByGrade = students.stream()
.collect(Collectors.groupingBy(Student::getGrade, namesJoinedBy(" & ")));
System.out.println(namesByGrade);
// Output: {A=Iqbal & Kartik, B=Suraj & Amar, C=Amit & Suchit}Here both ideas meet. The method builds its collector by composing mapping and joining. Then the result gets reused twice, once alone and once inside groupingBy. That is the whole Collectors API in miniature.
Note that we only combined existing collectors here. Writing a brand-new collector from scratch, with your own supplier and accumulator, is a separate topic. A later article in this series covers it.
Several collectors have a twin method right on Stream itself. So which one should you call?
Counting shows the difference clearly.
long viaShortcut = students.stream().count(); Long viaCollector = students.stream().collect(Collectors.counting()); System.out.println(viaShortcut); // Output: 6 System.out.println(viaCollector); // Output: 6
Same answer, but the shortcut is shorter. It also returns a primitive long, so nothing gets boxed. So why does counting() exist at all?
Because a shortcut cannot go inside groupingBy. Only a Collector fits in the downstream slot. You saw this in section 7.1, where counting() gave us a count per grade.
| Goal | Stream shortcut | Collector |
|---|---|---|
| Count elements | count() returns long | counting() returns Long |
| Largest element | max(comparator) | maxBy(comparator) |
| Sum of ints | mapToInt(…).sum() returns int | summingInt(…) returns Integer |
| Average of ints | mapToInt(…).average() returns OptionalDouble | averagingInt(…) returns Double, 0.0 when empty |
| Make a List | toList(), Java 16+, unmodifiable | Collectors.toList(), modifiable in practice |
| Join text | No good shortcut | joining() |
Watch the average row closely. The shortcut returns an OptionalDouble, so an empty stream shows up as “no value”. The collector returns 0.0 instead. Is that a real zero, or just an empty list? Keep this in mind when an empty result could matter.
Here is a rule of thumb that works almost every time:
The Collectors class did not freeze in 2014. Later releases filled real gaps. If your project runs on a newer JDK, these are worth knowing.
| Java version | Addition | What it solves |
|---|---|---|
| Java 9 | Collectors.filtering | Drop elements inside a group, yet keep empty groups as keys |
| Java 9 | Collectors.flatMapping | Expand each element into many, inside a group |
| Java 10 | toUnmodifiableList, toUnmodifiableSet, toUnmodifiableMap | Read-only results that reject null elements |
| Java 12 | Collectors.teeing | Two collectors over one pass, then merge |
| Java 16 | Stream.toList() | A short, unmodifiable list without any collector |
Note the last row. Stream.toList() is a method on Stream, not on Collectors. It still belongs in this table, because it replaces the most common collector call of all.
Everything else in this article works on plain Java 8. We marked the newer examples clearly where they appear.
These mistakes show up again and again in code reviews. Each one is easy to fix once you spot it.
Writing Collector.toList() is the classic typo. The compiler says it cannot find the symbol, and beginners stare at the line for minutes.
Remember the table from section 2.3. Collector, without the s, is the interface. The ready-made methods live on Collectors, with the s. The same rule holds for Collection and Collections.
Here is a pattern that looks clever but wastes work:
// Wasteful: builds a full list, then streams it again to filter
List<Integer> evens = numbers.stream()
.collect(Collectors.collectingAndThen(Collectors.toList(),
list -> list.stream()
.filter(n -> n % 2 == 0)
.collect(Collectors.toList())));
// Better: filter first, collect once
List<Integer> evensFixed = numbers.stream()
.filter(n -> n % 2 == 0)
.collect(Collectors.toList());The first version builds a list of every number, then walks it a second time. The fixed version walks the data once. Put filtering in the pipeline, where it belongs. Our article on filtering in streams covers that step.
Do you really need to filter inside a group? On Java 9 and later, use Collectors.filtering as the downstream collector instead.
Some developers skip collectors and add to an outside list from forEach.
// Risky: shared mutable state
List<String> names = new ArrayList<>();
students.parallelStream().forEach(s -> names.add(s.getName()));
// Safe: let the collector own the container
List<String> safeNames = students.parallelStream()
.map(Student::getName)
.collect(Collectors.toList());On a sequential stream the first version happens to work. On a parallel stream, several threads call add() on one ArrayList at once. You can lose elements or even get an exception. The collector version gives each thread its own list, as section 5.6 explained.
Writing collect(Collectors.counting()) at the top level is not wrong. It is just longer, and it boxes the result into a Long. IntelliJ IDEA even suggests replacing it with count().
Save the collector versions for downstream slots. Section 9 has the full list of twins.
Composability is powerful, but it has a limit. Four collectors nested inside each other turn into a puzzle. Nobody can read it a week later.
When a collector chain grows past two or three levels, pull pieces into named constants or methods. Section 8 showed how. A name like namesJoinedBy tells the reader more than any comment could.
Let us tie everything together in one small program. It touches every family from section 6, and it uses both design ideas from sections 7 and 8.
A class teacher wants a one-screen report for the six students. It needs:
package com.javahandson.collectors;
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.TreeMap;
import java.util.stream.Collector;
import java.util.stream.Collectors;
public class GradeReport {
// A reusable recipe: student names joined with commas
static final Collector<Student, ?, String> NAMES =
Collectors.mapping(Student::getName, Collectors.joining(", "));
public static void main(String[] args) {
List<Student> students = Arrays.asList(
new Student(101, "Suraj", 'B', 72),
new Student(102, "Iqbal", 'A', 91),
new Student(103, "Amar", 'B', 68),
new Student(104, "Amit", 'C', 55),
new Student(105, "Suchit", 'C', 49),
new Student(106, "Kartik", 'A', 88));
long total = students.stream().count();
double classAverage = students.stream()
.collect(Collectors.averagingInt(Student::getMarks));
Map<Character, String> namesByGrade = students.stream()
.collect(Collectors.groupingBy(Student::getGrade, TreeMap::new, NAMES));
Map<Boolean, Long> passFail = students.stream()
.collect(Collectors.partitioningBy(s -> s.getMarks() >= 60,
Collectors.counting()));
Optional<Student> topper = students.stream()
.max(Comparator.comparingInt(Student::getMarks));
String rollCall = students.stream().collect(NAMES);
System.out.println("Students : " + total);
System.out.println("Class average : " + classAverage);
System.out.println("By grade : " + namesByGrade);
System.out.println("Pass / fail : " + passFail);
System.out.println("Topper : " + topper.map(Student::getName).orElse("none"));
System.out.println("Roll call : " + rollCall);
}
}
/*
Output:
Students : 6
Class average : 70.5
By grade : {A=Iqbal, Kartik, B=Suraj, Amar, C=Amit, Suchit}
Pass / fail : {false=2, true=4}
Topper : Iqbal
Roll call : Suraj, Iqbal, Amar, Amit, Suchit, Kartik
*/Walk through the program line by line, and you will spot every idea from this article:
Six questions, six short statements, and not one hand-written loop. Picture this report written with plain for-loops. It would easily take three times the code.
A: Collectors is a final utility class in java.util.stream. It offers static factory methods that return ready-made Collector objects. You pass one of them to Stream.collect() to build a list, a map, a number, or a string from the stream’s elements.
A: Collector is the interface that describes how to build a result. Collectors is the utility class full of ready-made Collector objects. The collect() method is the terminal operation on Stream that runs a Collector over the elements.
A: Mutable reduction boils many elements into one result by filling a single container in place. The collector creates one empty container and adds each element to it. The reduce method works differently: it creates a new value at every step.
A: T is the type of each input element. A is the type of the working container used during collection. R is the type of the final result. The JDK usually hides A behind a wildcard, as in Collector<T, ?, List<T>>.
A: First, the supplier creates an empty container. Next, the accumulator adds one element to it. In parallel runs, the combiner merges two partial containers. Last, the finisher converts the container into the final result, such as a text buffer into a String.
A: They are flags from the Collector.Characteristics enum. IDENTITY_FINISH means the finisher does nothing. UNORDERED means element order does not affect the result. CONCURRENT means many threads may add to one shared container. The stream uses these flags to optimise its work.
A: A downstream collector is a collector passed into another collector, such as groupingBy or partitioningBy. It decides what happens to the elements inside each group. For example, groupingBy(Student::getGrade, counting()) counts the students in each grade.
A: Yes. A collector holds no data between runs. Each collect() call asks the supplier for a fresh container. So you can store a collector in a constant and use it on many streams. The stream itself is single-use, though.
A: Both count the elements. The count() method is a terminal shortcut that returns a primitive long. The counting() collector returns a boxed Long. Use count() at the top level, and counting() as a downstream collector inside groupingBy or partitioningBy.
A: Yes. Each thread gets its own ArrayList from the supplier, so no two threads share a list. When the threads finish, the combiner merges their lists. Adding to one shared list from forEach, by contrast, is not safe.
A: Java 9 added filtering and flatMapping. One release later, Java 10 brought toUnmodifiableList, toUnmodifiableSet, and toUnmodifiableMap. Then teeing arrived in Java 12. Java 16 also added Stream.toList(), which lives on Stream rather than on Collectors.
A: The reduce method expects immutable values, so building a list with it means copying the list at every step. The collect method fills one container in place, which is faster and simpler. Use reduce for single values like sums, and collect for containers.
Let us wrap up what we covered.
With this mental model in place, every other collectors article in the series becomes easier. Pick a family from section 6 and dive in. Grouping is a great next step, since so many of the other collectors shine inside it.