Grouping in Java 8
-
Last Updated: May 7, 2024
-
By: javahandson
-
Series

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.
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.
Let us build the idea up slowly, starting away from code.
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.
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.
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.
The Collectors class offers six grouping methods. They come as two families of three:
| Overload | groupingBy | groupingByConcurrent |
|---|---|---|
| Classifier only | Map of Lists | ConcurrentMap of Lists |
| Classifier + downstream | Map of any result | ConcurrentMap of any result |
| Classifier + map factory + downstream | Any Map type you choose | Any 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.
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.
| Department | Employees | Total salary |
|---|---|---|
| IT | Shweta, Poornima, Suraj | 270000 |
| HR | Reshma, Amit | 110000 |
| Sales | Shubhangi, Iqbal, Suchit | 195000 |
The simplest overload takes just the classifier. Each group becomes a List.
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:
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.
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.
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.ArrayListSo 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.
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.
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:
Compare the return types. Variant one gives Map<K, List<T>>. Variant two gives Map<K, D>. The downstream collector decides what D is.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.0The statistics object has a few surprises of its own, especially with empty data. Java 8 Summarizing Methods walks through all of them.
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.
| Question per group | Downstream collector | Value 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, please | mapping(f, toList()) | List<R> |
| One line of text | mapping(f, joining(“, “)) | String |
| No duplicates | toSet() or toCollection(TreeSet::new) | Set<T> |
| All the numbers at once | summarizingInt(…) | IntSummaryStatistics |
| Only some elements (Java 9+) | filtering(p, toList()) | List<T> |
| Tidy the final value | collectingAndThen(c, f) | Whatever f returns |
Section 4.3 showed that the default key order is unpredictable. The third overload fixes that. It lets you choose the map yourself.
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.
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.
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.
Here is where the downstream idea really pays off. What collector can sit in the downstream slot? Any collector. That includes another groupingBy.
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.
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 : 1Two 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.
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.
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.
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.
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.
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.
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 runsCounts 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.
| Aspect | groupingBy | groupingByConcurrent |
|---|---|---|
| Default result | HashMap (not guaranteed) | ConcurrentHashMap |
| Parallel strategy | One map per thread, merged at the end | One shared map, no merge |
| Order inside a group | Kept for ordered streams | Not kept |
| Map factory must return | Any Map | A ConcurrentMap |
| Best for | Sequential streams, or when order matters | Large 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.
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 -> 110000This 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.
Two other collectors also build maps. Picking the wrong one leads to clumsy code or runtime errors.
| Collector | Values per key | Keys in the result | Duplicate keys |
|---|---|---|---|
| groupingBy | Many, gathered by a downstream collector | Only keys that appeared | Expected, that is the point |
| partitioningBy | Many, split by a true/false test | Always both true and false | Expected |
| toMap | Exactly one | Only keys that appeared | Throws 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.
These are the grouping bugs that show up most often in real code.
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.
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: 0The first line crashes because Java tries to unbox null into a primitive long. Use getOrDefault whenever a key might be missing.
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.
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.
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.
Let us build one small program that uses the most important ideas from this article.
An HR manager wants a department summary, sorted by department name. For each department, it should show:
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
*/Each part of the report maps to one idea from the article:
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
Let us wrap up what we covered.
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.