Table of Contents

Introduction to the Collectors class

  • Last Updated: April 10, 2024
  • By: javahandson
  • Series
img

Introduction to the Collectors class

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.

1. Introduction

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.

1.1 What This Article Covers

Here is the plan, from simple to advanced:

  • What the Collectors class is, and how it differs from Collector and collect()
  • Why collectors beat hand-written loops, with a before-and-after example
  • Mutable reduction, the idea that powers every collector
  • The anatomy of a Collector: type parameters, four functions, and characteristics
  • A quick tour of the whole family, with links to the deep-dive articles
  • Composability and reusability, the two design ideas behind the API
  • When a plain stream method beats a collector
  • Methods added after Java 8, common mistakes, and interview questions

2. What Is the Collectors Class?

Let us start with a simple picture before any code.

2.1 A Toolbox of Ready-Made Recipes

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.

2.2 Where the Class Lives

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:

  • It is a final class, so nobody can extend it.
  • Its constructor is private, so you never write new Collectors().
  • Every useful member is a static factory method that returns a fresh Collector.
  • It follows the same utility-class style as Collections and Arrays.

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.

2.3 Collector, Collectors, and collect()

Three names look almost the same. Beginners mix them up all the time, so let us pin them down.

NameWhat it isKitchen analogy
CollectorAn interface that describes how to build a resultThe idea of a recipe card
CollectorsA utility class full of ready-made Collector objectsThe box of printed cards
collect()A terminal method on Stream that runs a CollectorThe 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.

3. Why Collectors Exist

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”.

3.1 Grouping Students the Old Way

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.

3.2 The Same Job With One Collector

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.

3.3 What You Actually Gain

Shorter code is nice, but it is not the main win. Here is what really changes:

  • Intent on the surface. The method name tells the reader the goal.
  • No hand-made null checks. The collector creates each bucket for you.
  • Fewer places for bugs. Tested JDK code replaces your own bookkeeping.
  • Easy to change. Want counts instead of lists? Swap one small piece, as section 7 shows.
  • Parallel-ready. The same collector also works on a parallel stream, with no locks in your code.

That last point matters more than it looks. Section 5 explains why a collector can run safely on many threads.

4. Mutable Reduction in Plain Words

The Java docs describe collect() as a mutable reduction. That phrase sounds scary. Luckily, the idea behind it is simple.

4.1 Filling One Basket

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.

4.2 Why Not Just Use 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.

5. The Anatomy of a Collector

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.

5.1 The Three Type Parameters

The interface is declared as Collector<T, A, R>. Each letter has a clear job:

  • T is the type of each input element, such as Student.
  • A is the type of the working container, used while collecting.
  • R is the type of the final result that collect() hands back.

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.

5.2 Four Functions and a Set of Flags

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:

MethodReturnsJob
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.

5.3 Running a Collector by Hand

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.

5.4 Why the Finisher Exists

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.

5.5 The Three Characteristics

The characteristics() method returns a set of flags from the Collector.Characteristics enum. There are only three:

  • IDENTITY_FINISH means the finisher does nothing, so the stream may skip it and cast the container straight to R.
  • UNORDERED means the result does not depend on the order of the elements.
  • CONCURRENT means many threads may add to one shared container at the same time.

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.

5.6 Collectors and Parallel Streams

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.

6. A Tour of the Collectors Family

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.

6.1 The Student Data

All examples below use the six students from section 3. Here they are again, with their marks, so you can check each output yourself:

RollNameGradeMarks
101SurajB72
102IqbalA91
103AmarB68
104AmitC55
105SuchitC49
106KartikA88

6.2 Collecting Into Containers

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.

6.3 Crunching Numbers

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.5

The 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.

6.4 Building Text

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.

6.5 Splitting Into Groups

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.

6.6 Adapters That Wrap Other Collectors

The last family is different. These methods do not build a result on their own. Instead, they take another collector and tweak it.

  • mapping changes each element before passing it on.
  • filtering (Java 9) drops elements before passing them on.
  • flatMapping (Java 9) expands each element into many.
  • collectingAndThen applies one more function to the final result.
  • reducing is a collector version of the reduce method.
  • teeing (Java 12) feeds every element to two collectors at once.

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.

6.7 The Whole Family in One Table

Keep this table close while you learn. It maps each goal to the right family.

FamilyMain methodsTypical result
ContainerstoList, toSet, toMap, toCollectionList, Set, Map
Numberscounting, summingInt, averagingInt, summarizingInt, minBy, maxByLong, Integer, Double, statistics, Optional
TextjoiningString
GroupsgroupingBy, groupingByConcurrent, partitioningByMap of buckets
Adaptersmapping, filtering, flatMapping, collectingAndThen, reducing, teeingWhatever the wrapped collector builds

7. Composability: Collectors Inside Collectors

The Collectors API rests on two design ideas. The first is composability: small collectors plug into bigger ones, like Lego bricks.

7.1 The Downstream Idea

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.

7.2 Reading Nested Collectors Inside Out

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:

  • maxBy finds the student with the highest marks, wrapped in an Optional.
  • collectingAndThen takes that Optional and pulls out the name.
  • groupingBy runs all of the above once for each grade.

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.

7.3 Two Collectors Side by Side With teeing

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 Iqbal

The 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.

8. Reusability: A Collector Is a Recipe

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.

8.1 Streams Die, Collectors Live On

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.

8.2 Keeping a Collector in a Constant

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.

8.3 A Method That Returns a Collector

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.

9. Collectors Versus Stream Shortcuts

Several collectors have a twin method right on Stream itself. So which one should you call?

9.1 Two Ways to Count

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.

9.2 The Comparison Table

GoalStream shortcutCollector
Count elementscount() returns longcounting() returns Long
Largest elementmax(comparator)maxBy(comparator)
Sum of intsmapToInt(…).sum() returns intsummingInt(…) returns Integer
Average of intsmapToInt(…).average() returns OptionalDoubleaveragingInt(…) returns Double, 0.0 when empty
Make a ListtoList(), Java 16+, unmodifiableCollectors.toList(), modifiable in practice
Join textNo good shortcutjoining()

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.

9.3 A Simple Rule

Here is a rule of thumb that works almost every time:

  • At the top level of a pipeline, prefer the Stream shortcut when one exists.
  • Inside a downstream slot, use the collector, since nothing else fits there.
  • For text, always use joining, because reduce copies strings over and over.

10. How Collectors Grew After Java 8

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 versionAdditionWhat it solves
Java 9Collectors.filteringDrop elements inside a group, yet keep empty groups as keys
Java 9Collectors.flatMappingExpand each element into many, inside a group
Java 10toUnmodifiableList, toUnmodifiableSet, toUnmodifiableMapRead-only results that reject null elements
Java 12Collectors.teeingTwo collectors over one pass, then merge
Java 16Stream.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.

11. Common Mistakes and Pitfalls

These mistakes show up again and again in code reviews. Each one is easy to fix once you spot it.

11.1 Mixing Up Collector and Collectors

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.

11.2 Collecting Only to Stream Again

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.

11.3 Filling a List From forEach

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.

11.4 Using a Collector Where a Shortcut Fits

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.

11.5 Nesting Too Deep

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.

12. A Practical Walkthrough

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.

12.1 The Problem

A class teacher wants a one-screen report for the six students. It needs:

  • How many students there are, and the class average
  • The names in each grade, with grades in alphabetical order
  • How many passed and failed, with 60 as the pass mark
  • The topper’s name, and a roll call of everyone

12.2 The Code

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
*/

12.3 Reading the Report

Walk through the program line by line, and you will spot every idea from this article:

  • count() and max() are top-level shortcuts. No collector needed there, as section 9 advised.
  • averagingInt comes from the number family. Its finisher turns a hidden total and count into 70.5.
  • groupingBy with TreeMap::new keeps grades in A, B, C order, and the NAMES recipe fills each bucket.
  • partitioningBy with counting() shows composability. Four students scored 60 or more, and two did not.
  • NAMES runs twice, once per grade and once for the roll call. That is reusability at work.

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.

13. Interview Questions

Q: What is the Collectors class in Java 8?

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.

Q: What is the difference between Collector, Collectors, and collect()?

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.

Q: What is mutable reduction?

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.

Q: What do the type parameters in Collector<T, A, R> mean?

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>>.

Q: What are the four functions of a Collector?

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.

Q: What are Collector characteristics?

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.

Q: What is a downstream collector?

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.

Q: Can you reuse a Collector object?

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.

Q: What is the difference between count() and Collectors.counting()?

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.

Q: Is Collectors.toList() safe on a parallel stream even though ArrayList is not thread-safe?

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.

Q: Which Collectors methods were added after Java 8?

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.

Q: Why is collect() better than reduce() for building a List?

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.

14. Conclusion

Let us wrap up what we covered.

  • The Collectors class in Java is a box of ready-made recipes. You pass one to collect() to end a stream.
  • Collector is the interface, Collectors is the utility class, and collect() is the method that runs them.
  • Collectors do mutable reduction: one container, filled in place, with no copying.
  • Inside, every collector has a supplier, an accumulator, a combiner, a finisher, and a set of characteristics.
  • The methods fall into five families: containers, numbers, text, groups, and adapters.
  • Composability lets small collectors plug into big ones through downstream slots.
  • Reusability lets you store a collector in a constant or build one in a method.
  • Stream shortcuts win at the top level, and collectors win inside a group.

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.

Further Reading

Leave a Comment