Table of Contents

Grouping in Java 8

  • Last Updated: May 7, 2024
  • By: javahandson
  • Series
img

Grouping in Java 8

Grouping in Java 8 takes a flat list and sorts it into labelled piles, like employees by department or orders by customer. One call to Collectors.groupingBy builds a map from each label to its matching elements. In this guide we cover all three groupingBy overloads, the downstream collectors that count, sum and summarise each group, multi-level grouping, and the concurrent version for parallel streams.

1. Introduction

How many employees work in each department? What is the total salary per team? Who earns the most in each city? These questions share one shape. First you split the data into groups. Then you ask something about each group.

Before Java 8, that meant a HashMap, a loop, and a null check for every new key. It worked, but the real question got buried in bookkeeping.

Grouping in Java 8 turns the whole job into one readable line. You tell Collectors.groupingBy how to pick a key. It builds the map for you. Add a second collector, and it will count, sum, or average each group too.

If collectors are new to you, read Introduction to the Collectors Class first. It explains what a collector is and how collect() runs one. This article focuses on grouping alone.

1.1 What This Article Covers

  • What grouping means, and the role of the classifier function
  • All three groupingBy overloads, signature by signature
  • A cookbook of downstream collectors: counting, summing, averaging, maxBy, mapping, joining, and more
  • Picking the map type, for sorted keys or first-seen order
  • Multi-level grouping and grouping by computed keys
  • groupingByConcurrent, and how both versions behave on parallel streams
  • Common mistakes, a full walkthrough, and interview questions

2. What Is Grouping?

Let us build the idea up slowly, starting away from code.

2.1 Sorting Laundry Into Piles

Picture a basket of laundry. You pick up each item, look at its colour, and drop it on the right pile. Whites here, darks there, colours over there. At the end, every item sits in exactly one pile.

That is grouping. You look at one property of each element and use it as a label. Elements with the same label land together.

If you know SQL, you have seen this before. It is the GROUP BY clause. And just like SQL pairs GROUP BY with COUNT, SUM, and MAX, Java pairs groupingBy with downstream collectors. We get to those in section 5.

2.2 The Classifier Function

In the laundry story, “look at the colour” is the rule that picks a pile. Java calls that rule the classifier. It is a plain Function that takes one element and returns its key.

  • Employee::getDepartment groups employees by department.
  • String::length groups words by how many letters they have.
  • order -> order.getDate().getYear() groups orders by year.

The classifier runs once per element. Whatever it returns becomes a key in the result map. Every element that produced that key ends up in the same group.

2.3 Six Methods, Two Families

The Collectors class offers six grouping methods. They come as two families of three:

OverloadgroupingBygroupingByConcurrent
Classifier onlyMap of ListsConcurrentMap of Lists
Classifier + downstreamMap of any resultConcurrentMap of any result
Classifier + map factory + downstreamAny Map type you chooseAny ConcurrentMap type you choose

Learn the three groupingBy overloads, and you already know the concurrent ones. They take the same arguments. Section 10 covers what changes.

3. The Sample Data

Every example in this article uses the same eight employees. Here is the class:

package com.javahandson.grouping;

public class Employee {

    private final int id;
    private final String name;
    private final String department;
    private final String city;
    private final int salary;

    public Employee(int id, String name, String department, String city, int salary) {
        this.id = id;
        this.name = name;
        this.department = department;
        this.city = city;
        this.salary = salary;
    }

    public int getId()            { return id; }
    public String getName()       { return name; }
    public String getDepartment() { return department; }
    public String getCity()       { return city; }
    public int getSalary()        { return salary; }

    @Override
    public String toString() {
        return name;
    }
}

The toString method returns only the name. That keeps the printed maps short and easy to read. Now the list itself:

List<Employee> employees = Arrays.asList(
        new Employee(1, "Shweta",    "IT",    "Pune",   90000),
        new Employee(2, "Reshma",    "HR",    "Mumbai", 60000),
        new Employee(3, "Poornima",  "IT",    "Mumbai", 85000),
        new Employee(4, "Shubhangi", "Sales", "Pune",   55000),
        new Employee(5, "Suraj",     "IT",    "Pune",   95000),
        new Employee(6, "Iqbal",     "Sales", "Mumbai", 65000),
        new Employee(7, "Amit",      "HR",    "Pune",   50000),
        new Employee(8, "Suchit",    "Sales", "Pune",   75000));

Keep this table handy. You can check every output below against it by hand.

DepartmentEmployeesTotal salary
ITShweta, Poornima, Suraj270000
HRReshma, Amit110000
SalesShubhangi, Iqbal, Suchit195000

4. Variant One: Classifier Only

The simplest overload takes just the classifier. Each group becomes a List.

4.1 The Method Signature

public static <T, K> Collector<T, ?, Map<K, List<T>>>
        groupingBy(Function<? super T, ? extends K> classifier)

It looks dense, so read it one piece at a time:

  • T is the element type, here Employee.
  • K is the key type, whatever the classifier returns. Here that is String.
  • Map<K, List<T>> is the result: each key points to a list of elements.
  • ? super T lets you pass a classifier written for a supertype, like Object.

4.2 Grouping Employees by Department

Map<String, List<Employee>> byDepartment = employees.stream()
        .collect(Collectors.groupingBy(Employee::getDepartment));

System.out.println(byDepartment);
// Output: {Sales=[Shubhangi, Iqbal, Suchit], HR=[Reshma, Amit], IT=[Shweta, Poornima, Suraj]}

Three departments, three keys. Inside each list, the employees keep the order they had in the source list. Shweta came before Poornima, so she stays first in IT.

That order inside a group is guaranteed for an ordered stream, like one made from a List. It holds even on a parallel stream. The order of the keys is another story.

4.3 Why the Keys Come Out in a Strange Order

Look at the output again. Sales, then HR, then IT. That is not alphabetical, and it is not the order the departments first appeared in. So where does it come from?

The default result is a HashMap in today’s JDK. A HashMap places each key in a bucket based on its hash code. Printing walks the buckets in order. With these three strings, “Sales” happens to land in an earlier bucket than “HR” and “IT”.

Never rely on that order. A different set of keys, or a bigger map, shuffles it. If you need sorted or first-seen order, ask for it. Section 7 shows how with one extra argument. Our article on HashMap in Java explains buckets in depth.

4.4 What Kind of Map You Get

Let us print the classes Java actually hands back:

System.out.println(byDepartment.getClass());
// Output: class java.util.HashMap

System.out.println(byDepartment.get("IT").getClass());
// Output: class java.util.ArrayList

So today you get a HashMap of ArrayLists. But the Javadoc makes no promises about the type, the mutability, or the thread safety of either one. A future JDK may change them.

What does that mean for you? Treat the map as a plain Map. If your code truly needs a HashMap, a TreeMap, or a mutable list, request it with the third overload. Do not cast.

5. Variant Two: Classifier Plus Downstream

A list per group is useful, but often you want something smaller. A count. A total. The best element. That is what the second overload is for.

5.1 The Method Signature

public static <T, K, A, D> Collector<T, ?, Map<K, D>>
        groupingBy(Function<? super T, ? extends K> classifier,
                   Collector<? super T, A, D> downstream)

Two new type letters show up:

  • A is the working container of the downstream collector. You never touch it.
  • D is what the downstream collector produces for each group. It becomes the map’s value type.

Compare the return types. Variant one gives Map<K, List<T>>. Variant two gives Map<K, D>. The downstream collector decides what D is.

5.2 What a Downstream Collector Does

Think of groupingBy as a sorting office. It reads the address on each letter and drops the letter into the right mailbag. The downstream collector is a small worker standing at each bag.

Each worker handles only its own bag. One worker might count letters. Another might weigh them. The sorting office does not care what the workers do. It just hands each letter to the right one.

In code terms, groupingBy creates a fresh downstream container for every new key. Each element goes to its key’s container. At the end, each container turns into that key’s value.

5.3 Counting Each Department

Counting is the classic first downstream collector:

Map<String, Long> headcount = employees.stream()
        .collect(Collectors.groupingBy(Employee::getDepartment, Collectors.counting()));

System.out.println(headcount);
// Output: {Sales=3, HR=2, IT=3}

Notice the value type is Long, not Integer. The counting() collector always returns a Long. Declare your map to match, or the code will not compile.

5.4 Variant One Is Secretly Variant Two

Here is a neat detail from the JDK source. The one-argument groupingBy just calls the two-argument one with toList() as the downstream. These two lines do the same job:

Collectors.groupingBy(Employee::getDepartment);
Collectors.groupingBy(Employee::getDepartment, Collectors.toList());

Likewise, the two-argument version calls the three-argument one with HashMap::new as the map factory. So there is really only one groupingBy. The shorter overloads just fill in sensible defaults for you.

6. A Cookbook of Downstream Collectors

Almost any collector can sit in the downstream slot. This section is a cookbook of the ones you will use most. Each recipe answers one real question about our employees.

6.1 Totals With summingInt

What does each department cost in salary?

Map<String, Integer> payroll = employees.stream()
        .collect(Collectors.groupingBy(Employee::getDepartment,
                 Collectors.summingInt(Employee::getSalary)));

System.out.println(payroll);
// Output: {Sales=195000, HR=110000, IT=270000}

Big totals can overflow an int without any warning. Our Summing Method in Collectors API article covers that trap and when to switch to summingLong.

6.2 Averages With averagingInt

What is the average salary in each department?

Map<String, Double> averagePay = employees.stream()
        .collect(Collectors.groupingBy(Employee::getDepartment,
                 Collectors.averagingInt(Employee::getSalary)));

System.out.println(averagePay);
// Output: {Sales=65000.0, HR=55000.0, IT=90000.0}

Even though salaries are ints, the average is always a Double. Dividing 195000 by 3 gives exactly 65000.0 here. With other numbers you would see decimals.

6.3 The Top Earner With maxBy

Who earns the most in each department?

Map<String, Optional<Employee>> topEarner = employees.stream()
        .collect(Collectors.groupingBy(Employee::getDepartment,
                 Collectors.maxBy(Comparator.comparingInt(Employee::getSalary))));

System.out.println(topEarner);
// Output: {Sales=Optional[Suchit], HR=Optional[Reshma], IT=Optional[Suraj]}

See the Optional wrapper around each name? The maxBy collector always returns an Optional, because it could run on an empty stream. Swap in minBy for the lowest earner. Both take a Comparator, which our Sorting Streams in Java 8 article explains.

6.4 Unwrapping the Optional With collectingAndThen

Inside groupingBy, a group only exists if at least one element landed in it. So those Optionals can never be empty. The wrapper is just noise. How do you remove it?

Map<String, String> topEarnerName = employees.stream()
        .collect(Collectors.groupingBy(Employee::getDepartment,
                 Collectors.collectingAndThen(
                         Collectors.maxBy(Comparator.comparingInt(Employee::getSalary)),
                         top -> top.get().getName())));

System.out.println(topEarnerName);
// Output: {Sales=Suchit, HR=Reshma, IT=Suraj}

The collectingAndThen adapter runs maxBy first. Then it applies one more function to the result. Here that function unwraps the Optional and pulls out the name.

6.5 Keeping One Field With mapping

Sometimes you do not want whole Employee objects in each group. You only want one field, like the city.

Map<String, List<String>> citiesByDepartment = employees.stream()
        .collect(Collectors.groupingBy(Employee::getDepartment,
                 Collectors.mapping(Employee::getCity, Collectors.toList())));

System.out.println(citiesByDepartment);
// Output: {Sales=[Pune, Mumbai, Pune], HR=[Mumbai, Pune], IT=[Pune, Mumbai, Pune]}

The mapping collector changes each element before passing it on. It takes two arguments: the function to apply, and the collector that receives the results. You cannot call stream().map() here, because the grouping has already started. mapping is the downstream way to do the same thing.

6.6 A Line of Names With joining

For a report, a single line of names often reads better than a list.

Map<String, String> namesByDepartment = employees.stream()
        .collect(Collectors.groupingBy(Employee::getDepartment,
                 Collectors.mapping(Employee::getName, Collectors.joining(" / "))));

System.out.println(namesByDepartment);
// Output: {Sales=Shubhangi / Iqbal / Suchit, HR=Reshma / Amit, IT=Shweta / Poornima / Suraj}

Why the extra mapping step? Because joining only accepts text, and our stream holds Employee objects. So mapping turns each employee into a name, and then joining glues the names together.

6.7 Unique Values With a Set

Look back at section 6.5. Pune appears twice for IT and twice for Sales. What if you only want each city once?

Map<String, Set<String>> uniqueCities = employees.stream()
        .collect(Collectors.groupingBy(Employee::getDepartment,
                 Collectors.mapping(Employee::getCity,
                         Collectors.toCollection(TreeSet::new))));

System.out.println(uniqueCities);
// Output: {Sales=[Mumbai, Pune], HR=[Mumbai, Pune], IT=[Mumbai, Pune]}

Plain toSet() also removes duplicates. But its order is not guaranteed. Here we used toCollection(TreeSet::new), so the cities come out sorted. The collect to List, Set, and Map article covers toCollection in detail.

6.8 Full Statistics With summarizingInt

Need the count, total, minimum, maximum, and average all at once? One downstream collector gets all five in a single pass.

Map<String, IntSummaryStatistics> stats = employees.stream()
        .collect(Collectors.groupingBy(Employee::getDepartment,
                 Collectors.summarizingInt(Employee::getSalary)));

IntSummaryStatistics it = stats.get("IT");
System.out.println(it.getMin() + " to " + it.getMax()); // Output: 85000 to 95000
System.out.println(it.getAverage());                    // Output: 90000.0

The statistics object has a few surprises of its own, especially with empty data. Java 8 Summarizing Methods walks through all of them.

6.9 Filtering Inside a Group

Which employees in each department earn 80000 or more? Java 9 added a downstream collector just for this:

// Java 9 and later
Map<String, List<String>> highEarners = employees.stream()
        .collect(Collectors.groupingBy(Employee::getDepartment,
                 Collectors.filtering(e -> e.getSalary() >= 80000,
                         Collectors.mapping(Employee::getName, Collectors.toList()))));

System.out.println(highEarners);
// Output: {Sales=[], HR=[], IT=[Shweta, Poornima, Suraj]}

Notice that HR and Sales still appear, with empty lists. The filtering collector runs after the key is chosen, so every department keeps its key. Section 13.3 shows why that matters, and what happens if you filter the stream first instead.

Java 9 also added flatMapping. It works like mapping, but each element can turn into many values, not just one.

6.10 The Cookbook Table

Question per groupDownstream collectorValue type
How many?counting()Long
What is the total?summingInt(…)Integer
What is the average?averagingInt(…)Double
Who is the biggest or smallest?maxBy(…) / minBy(…)Optional<T>
Just one field, pleasemapping(f, toList())List<R>
One line of textmapping(f, joining(“, “))String
No duplicatestoSet() or toCollection(TreeSet::new)Set<T>
All the numbers at oncesummarizingInt(…)IntSummaryStatistics
Only some elements (Java 9+)filtering(p, toList())List<T>
Tidy the final valuecollectingAndThen(c, f)Whatever f returns

7. Variant Three: Choosing the Map

Section 4.3 showed that the default key order is unpredictable. The third overload fixes that. It lets you choose the map yourself.

7.1 The Method Signature

public static <T, K, D, A, M extends Map<K, D>> Collector<T, ?, M>
        groupingBy(Function<? super T, ? extends K> classifier,
                   Supplier<M> mapFactory,
                   Collector<? super T, A, D> downstream)

The new piece is mapFactory, a Supplier that creates an empty map. Its type, M, must be some kind of Map<K, D>. The result type is M itself, so you get back exactly the map you asked for.

Note the argument order: classifier, then map factory, then downstream. The downstream comes last, even though you might expect it second.

7.2 Sorted Keys With TreeMap

Map<String, Long> sortedHeadcount = employees.stream()
        .collect(Collectors.groupingBy(Employee::getDepartment,
                 TreeMap::new,
                 Collectors.counting()));

System.out.println(sortedHeadcount);
// Output: {HR=2, IT=3, Sales=3}

A TreeMap keeps its keys sorted, so the departments now print in alphabetical order. Use this for any report a person will read. You can learn more in TreeMap in Java.

7.3 First-Seen Order With LinkedHashMap

Sometimes the source order carries meaning. Perhaps the list is already sorted by priority. A LinkedHashMap keeps keys in the order they first appeared.

Map<String, Long> firstSeenHeadcount = employees.stream()
        .collect(Collectors.groupingBy(Employee::getDepartment,
                 LinkedHashMap::new,
                 Collectors.counting()));

System.out.println(firstSeenHeadcount);
// Output: {IT=3, HR=2, Sales=3}

Shweta from IT comes first in the list, then Reshma from HR, then Shubhangi from Sales. The keys follow that exact order. LinkedHashMap in Java explains how it tracks insertion order.

Enum keys have a third good choice. Pass () -> new EnumMap<>(Level.class) as the factory. The keys then follow the order of the enum constants, and lookups are very fast.

8. Multi-Level Grouping

Here is where the downstream idea really pays off. What collector can sit in the downstream slot? Any collector. That includes another groupingBy.

8.1 Department, Then City

Say we want employees split by department, and then by city inside each department.

Map<String, Map<String, List<String>>> byDeptAndCity = employees.stream()
        .collect(Collectors.groupingBy(Employee::getDepartment,
                 Collectors.groupingBy(Employee::getCity,
                         Collectors.mapping(Employee::getName, Collectors.toList()))));

System.out.println(byDeptAndCity);
// Output: {Sales={Pune=[Shubhangi, Suchit], Mumbai=[Iqbal]}, HR={Pune=[Amit], Mumbai=[Reshma]}, IT={Pune=[Shweta, Suraj], Mumbai=[Poornima]}}

The outer groupingBy splits by department. For each department, the inner groupingBy splits again by city. And mapping keeps only the names at the very bottom.

8.2 Reading a Nested Map

To look up a value, you go down one level at a time:

List<String> itInPune = byDeptAndCity.get("IT").get("Pune");
System.out.println(itInPune); // Output: [Shweta, Suraj]

byDeptAndCity.forEach((dept, cities) ->
        cities.forEach((city, names) ->
                System.out.println(dept + " / " + city + " : " + names.size())));
// Output (one line per department and city pair):
// Sales / Pune : 2
// Sales / Mumbai : 1
// HR / Pune : 1
// HR / Mumbai : 1
// IT / Pune : 2
// IT / Mumbai : 1

Two levels are easy to follow. Three levels start to hurt. The type becomes a Map of Maps of Maps, and every lookup needs a null check at each level.

8.3 A Flat Alternative With a Combined Key

Do you really need the nesting? If you only ever look up both keys together, one flat map is simpler.

Map<String, Long> byPair = employees.stream()
        .collect(Collectors.groupingBy(e -> e.getDepartment() + "-" + e.getCity(),
                 TreeMap::new,
                 Collectors.counting()));

System.out.println(byPair);
// Output: {HR-Mumbai=1, HR-Pune=1, IT-Mumbai=1, IT-Pune=2, Sales-Mumbai=1, Sales-Pune=2}

The classifier builds one key out of two fields. The trade-off is clear. A nested map is better when you need “all cities for IT”. A flat key is better when you always know both parts.

In real projects, a small key class or a Java 16 record beats a glued-together string. It avoids clashes when a value itself contains the separator.

9. Grouping by a Computed Key

The classifier does not have to be a simple getter. It can compute anything. A common case is sorting numbers into bands.

static String band(Employee e) {
    if (e.getSalary() >= 90000) return "Senior";
    if (e.getSalary() >= 60000) return "Mid";
    return "Junior";
}

Map<String, List<Employee>> byBand = employees.stream()
        .collect(Collectors.groupingBy(GroupingDemo::band, TreeMap::new, Collectors.toList()));

System.out.println(byBand);
// Output: {Junior=[Shubhangi, Amit], Mid=[Reshma, Poornima, Iqbal, Suchit], Senior=[Shweta, Suraj]}

We moved the rule into a named method. That keeps the pipeline short and lets you test the rule on its own. A long lambda inside groupingBy is much harder to read.

What if your rule only has two outcomes, like “passed” and “failed”? Then partitioningBy is the better tool. It always returns both keys, even when one side is empty. See Partitioning in Java 8 for the details.

10. groupingByConcurrent for Parallel Streams

The second family of grouping methods is built for parallel streams. To see why it exists, we first need to know what plain groupingBy does on many threads.

10.1 How Plain groupingBy Runs in Parallel

A common myth says groupingBy uses locks in parallel. It does not. Each thread builds its own private HashMap from its slice of the data. No thread ever touches another thread’s map.

When the threads finish, the combiner merges their maps, two at a time. For every key, it merges the two groups with the downstream collector’s own combiner.

This design is safe, and it keeps the order inside each group. But the merge step costs time. With many keys and large groups, merging whole maps again and again can eat up the gain from going parallel.

10.2 What the Concurrent Version Does Differently

groupingByConcurrent takes the opposite approach. All threads share one ConcurrentHashMap. Each thread drops its elements straight into that shared map. There is no merge step at the end.

The price is order. Threads add elements whenever they get to them. So the order inside each group can change from run to run. That is why this collector reports itself as UNORDERED.

One more detail matters. If the downstream collector is not itself concurrent, like toList(), the JDK locks each group’s container while adding to it. Threads working on different keys never wait for each other. Threads hitting the same key take turns.

10.3 A Side-by-Side Example

ConcurrentMap<String, Long> concurrentHeadcount = employees.parallelStream()
        .collect(Collectors.groupingByConcurrent(Employee::getDepartment,
                 Collectors.counting()));

System.out.println(concurrentHeadcount);
// Output: {Sales=3, HR=2, IT=3}

System.out.println(concurrentHeadcount.getClass());
// Output: class java.util.concurrent.ConcurrentHashMap

ConcurrentMap<String, List<Employee>> concurrentGroups = employees.parallelStream()
        .collect(Collectors.groupingByConcurrent(Employee::getDepartment));

System.out.println(concurrentGroups.get("IT"));
// Output: the three IT employees, in an order that can change between runs

Counts never depend on order, so they are always right. The list of IT employees is different. It holds the right three people, but not always in source order.

Note the return type, too. The concurrent family returns a ConcurrentMap, which is safe to share across threads after the stream ends.

10.4 groupingBy Versus groupingByConcurrent

AspectgroupingBygroupingByConcurrent
Default resultHashMap (not guaranteed)ConcurrentHashMap
Parallel strategyOne map per thread, merged at the endOne shared map, no merge
Order inside a groupKept for ordered streamsNot kept
Map factory must returnAny MapA ConcurrentMap
Best forSequential streams, or when order mattersLarge parallel streams where order does not matter

The rule is simple. On a sequential stream, always use groupingBy. On a parallel stream, reach for groupingByConcurrent only when order does not matter and the data is large. Measure before you switch. Our article on Parallel Stream in Java 8 explains when parallel pays off at all.

11. Working With the Result Map

The map from groupingBy is often not the final answer. Which department has the biggest payroll? For that, you stream over the map’s entries.

Map<String, Integer> payroll = employees.stream()
        .collect(Collectors.groupingBy(Employee::getDepartment,
                 Collectors.summingInt(Employee::getSalary)));

payroll.entrySet().stream()
        .sorted(Map.Entry.<String, Integer>comparingByValue().reversed())
        .forEach(e -> System.out.println(e.getKey() + " -> " + e.getValue()));
// Output:
// IT -> 270000
// Sales -> 195000
// HR -> 110000

This two-step pattern is very common. First group and aggregate. Then stream the entries to sort, filter, or pick the top one.

Why the odd Map.Entry.<String, Integer> prefix? Calling reversed() breaks Java’s type inference, so we spell out the types by hand. Without it, the code does not compile.

12. groupingBy Versus Its Neighbours

Two other collectors also build maps. Picking the wrong one leads to clumsy code or runtime errors.

CollectorValues per keyKeys in the resultDuplicate keys
groupingByMany, gathered by a downstream collectorOnly keys that appearedExpected, that is the point
partitioningByMany, split by a true/false testAlways both true and falseExpected
toMapExactly oneOnly keys that appearedThrows IllegalStateException unless you pass a merge function

Use toMap when each key maps to one value, like an id to an employee. Use groupingBy when many elements share a key. The collect to List, Set, and Map article covers toMap and its duplicate-key trap.

13. Common Mistakes and Pitfalls

These are the grouping bugs that show up most often in real code.

13.1 A null Key

What happens if an employee has no department? The classifier returns null, and groupingBy refuses it:

// Throws: java.lang.NullPointerException: element cannot be mapped to a null key
Stream.of(new Employee(9, "Neha", null, "Pune", 40000))
        .collect(Collectors.groupingBy(Employee::getDepartment));

// Fix: give missing values a real key
Map<String, List<Employee>> safe = Stream.of(new Employee(9, "Neha", null, "Pune", 40000))
        .collect(Collectors.groupingBy(
                e -> e.getDepartment() == null ? "Unassigned" : e.getDepartment()));

System.out.println(safe); // Output: {Unassigned=[Neha]}

This surprises people, since a HashMap itself allows one null key. The collector checks for null on purpose. Decide what a missing key should mean, and map it to a real value.

13.2 Unboxing a Missing Group

groupingBy only creates keys it actually saw. Ask for a department with nobody in it, and you get null back.

long finance = headcount.get("Finance");            // NullPointerException while unboxing
long financeSafe = headcount.getOrDefault("Finance", 0L);

System.out.println(financeSafe); // Output: 0

The first line crashes because Java tries to unbox null into a primitive long. Use getOrDefault whenever a key might be missing.

13.3 Filtering Too Early

Compare this with the Java 9 filtering example from section 6.9:

Map<String, List<String>> filteredFirst = employees.stream()
        .filter(e -> e.getSalary() >= 80000)
        .collect(Collectors.groupingBy(Employee::getDepartment,
                 Collectors.mapping(Employee::getName, Collectors.toList())));

System.out.println(filteredFirst); // Output: {IT=[Shweta, Poornima, Suraj]}

HR and Sales have vanished. The stream filter removed their employees before grouping began, so their keys were never created.

Is that a bug? It depends on the question. For “list the high earners”, this is fine. For a report that must show every department, even with zero matches, it is wrong. There, use the filtering collector instead.

13.4 Trusting the Map Type

Casting the result to HashMap, or adding to its lists, works today. The Javadoc still gives no guarantee about the type or mutability. If your code depends on a specific type, say so with the map factory. For the lists, use toCollection(ArrayList::new) as the downstream.

13.5 Nesting When a Flat Key Would Do

Three levels of groupingBy give you a Map of Maps of Maps. Every reader has to untangle it. Before nesting, ask how the result will be used. If you always look up all the keys together, one combined key, as in section 8.3, is far simpler.

14. A Practical Walkthrough

Let us build one small program that uses the most important ideas from this article.

14.1 The Problem

An HR manager wants a department summary, sorted by department name. For each department, it should show:

  • How many employees it has
  • Its total and average salary
  • Who the top earner is
  • A line with everyone’s names

14.2 The Code

package com.javahandson.grouping;

import java.util.Arrays;
import java.util.Comparator;
import java.util.IntSummaryStatistics;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.stream.Collectors;

public class DepartmentReport {

    public static void main(String[] args) {
        List<Employee> employees = Arrays.asList(
                new Employee(1, "Shweta",    "IT",    "Pune",   90000),
                new Employee(2, "Reshma",    "HR",    "Mumbai", 60000),
                new Employee(3, "Poornima",  "IT",    "Mumbai", 85000),
                new Employee(4, "Shubhangi", "Sales", "Pune",   55000),
                new Employee(5, "Suraj",     "IT",    "Pune",   95000),
                new Employee(6, "Iqbal",     "Sales", "Mumbai", 65000),
                new Employee(7, "Amit",      "HR",    "Pune",   50000),
                new Employee(8, "Suchit",    "Sales", "Pune",   75000));

        // One pass for the numbers
        Map<String, IntSummaryStatistics> stats = employees.stream()
                .collect(Collectors.groupingBy(Employee::getDepartment, TreeMap::new,
                         Collectors.summarizingInt(Employee::getSalary)));

        // One pass for the top earner's name
        Map<String, String> topEarner = employees.stream()
                .collect(Collectors.groupingBy(Employee::getDepartment,
                         Collectors.collectingAndThen(
                                 Collectors.maxBy(Comparator.comparingInt(Employee::getSalary)),
                                 top -> top.get().getName())));

        // One pass for the team list
        Map<String, String> team = employees.stream()
                .collect(Collectors.groupingBy(Employee::getDepartment,
                         Collectors.mapping(Employee::getName, Collectors.joining(", "))));

        stats.forEach((dept, s) -> {
            System.out.println(dept + " (" + s.getCount() + " people)");
            System.out.println("  Total   : " + s.getSum());
            System.out.println("  Average : " + s.getAverage());
            System.out.println("  Top     : " + topEarner.get(dept));
            System.out.println("  Team    : " + team.get(dept));
        });
    }
}
/*
Output:
HR (2 people)
  Total   : 110000
  Average : 55000.0
  Top     : Reshma
  Team    : Reshma, Amit
IT (3 people)
  Total   : 270000
  Average : 90000.0
  Top     : Suraj
  Team    : Shweta, Poornima, Suraj
Sales (3 people)
  Total   : 195000
  Average : 65000.0
  Top     : Suchit
  Team    : Shubhangi, Iqbal, Suchit
*/

14.3 Reading the Report

Each part of the report maps to one idea from the article:

  • TreeMap::new on the stats map sorts the departments. We loop over that map, so the whole report comes out in A to Z order.
  • summarizingInt gives the count, total, and average in a single pass.
  • collectingAndThen with maxBy returns a plain name, with no Optional to unwrap later.
  • mapping with joining turns each team into one tidy line of text.

The two helper maps use the default HashMap. That is fine, because we only look them up by key and never loop over them. The order only matters for the map we loop over.

15. Interview Questions

Q: What is grouping in Java 8?

A: Grouping splits the elements of a stream into groups that share a key. You call Collectors.groupingBy with a classifier function. It returns a Map from each key to the elements that produced it, much like SQL’s GROUP BY.

Q: What are the three overloads of Collectors.groupingBy?

A: The first takes only a classifier and gives a Map of Lists. Next comes one that adds a downstream collector to decide each group’s value. The last also adds a map factory, so you choose the Map type, such as a TreeMap.

Q: What is a downstream collector in groupingBy?

A: It is a second collector that runs on the elements of each group. Examples are counting, summingInt, averagingInt, maxBy, and mapping. Its result becomes the value for that key in the final map.

Q: How do you count the elements in each group?

A: Pass Collectors.counting() as the downstream collector, as in groupingBy(Employee::getDepartment, counting()). The result is a Map with Long values, one count per key.

Q: Does groupingBy keep the keys in sorted order?

A: No. The default map is a HashMap today, so the key order depends on hash codes. For sorted keys, pass TreeMap::new as the map factory. For first-seen order, pass LinkedHashMap::new.

Q: What happens if the classifier returns null?

A: groupingBy throws a NullPointerException with the message “element cannot be mapped to a null key”. Map missing values to a real key, such as “Unassigned”, inside the classifier.

Q: Why does maxBy inside groupingBy return an Optional?

A: maxBy always returns an Optional, because in general it might see no elements. Inside groupingBy each group has at least one element, so the Optional is never empty. Wrap maxBy in collectingAndThen to unwrap it.

Q: How do you group by more than one field?

A: You have two options. Nest a second groupingBy as the downstream collector, which gives a Map of Maps. Or build one combined key in the classifier, such as a small key class or a record, which gives a flat Map.

Q: What is the difference between groupingBy and groupingByConcurrent?

A: On a parallel stream, groupingBy builds one map per thread and merges them at the end, keeping the order inside each group. groupingByConcurrent has all threads write into one shared ConcurrentHashMap. That skips the merge but loses the order.

Q: Does groupingBy use locks on a parallel stream?

A: No. Each thread fills its own private map, so no locking is needed. The combiner then merges the partial maps. It is groupingByConcurrent that may lock, and only per group, when its downstream collector is not concurrent.

Q: What is the difference between groupingBy and toMap?

A: groupingBy expects many elements per key and gathers them with a downstream collector. toMap expects exactly one value per key. It throws IllegalStateException on a duplicate key unless you pass a merge function.

Q: Why do some departments disappear when I filter before grouping?

A: groupingBy only creates keys for elements it sees. A stream filter removes elements before grouping, so their keys never appear. Use the Java 9 Collectors.filtering downstream collector if every key must stay in the result.

16. Conclusion

Let us wrap up what we covered.

  • Grouping in Java 8 splits a stream into labelled groups. A classifier function picks each element’s key.
  • groupingBy has three overloads: classifier only, plus a downstream collector, and plus a map factory.
  • Downstream collectors decide what each group becomes: a count, a total, an average, a top element, or a line of names.
  • The default map has no guaranteed order or type. Pass TreeMap::new or LinkedHashMap::new when order matters.
  • Nesting groupingBy gives multi-level maps. A combined key is often a simpler choice.
  • groupingByConcurrent shares one map across threads. It trades order for speed on large parallel streams.
  • Watch for null keys, missing groups, and filtering that removes whole groups.

Grouping is one of the most useful tools in the Stream API. Once the downstream idea clicks, a whole class of reporting code collapses into a few clear lines. A good next step is Partitioning in Java 8, its two-bucket cousin.

Further Reading

Leave a Comment