Learn Java

Lesson 54Phase 6: Functional and modern Java

Collectors and stream recipes

Grouping, counting, joining and partitioning with collectors, flatMap for nested data, and patterns you will reuse.

By Updated 50 min

  • Collectors
  • groupingBy
  • counting
  • joining
  • partitioningBy
  • toMap

In the last lesson, most pipelines ended in a list, a count, a total or a single element. Plenty of questions want a different shape of answer. A teacher with a pile of exam results asks how many students took each subject, what the average score was in each one, who passed and who did not, and which three scores were the highest. Answers like these are maps and groups, and this lesson builds all of them with collect and the ready-made collectors in the Collectors class. Then it adds flatMap for lists inside lists, and sorted, distinct and skip for putting a stream in order and trimming it, and it ends with a set of recipes you will use again and again.

By the end of this lesson
  • End a pipeline with collect and a collector from Collectors: toList, toSet, joining and counting.
  • Group elements by a key with groupingBy, and choose what each group holds with a downstream collector such as counting, summingInt, averagingInt or mapping.
  • Split elements into a true group and a false group with partitioningBy.
  • Build a map with toMap, and give it a merge function when two elements share a key.
  • Flatten lists inside lists with flatMap, and order and trim a stream with sorted, distinct, limit and skip.
  • Combine the pieces into everyday recipes: counting words, the top N, joining with brackets, pass and fail.
From before
In Streams you built pipelines from a source, steps such as filter and map, and one terminal operation, and you met collect(Collectors.toList()): collect takes a collector, a recipe for gathering elements into a result. In HashMap and HashSet you counted words with merge and grouped fruit by first letter with computeIfAbsent, each with a loop. Comparable, Comparator and sorting gave you Comparator.comparingInt, reversed() and the TreeMap, which keeps its keys sorted. The exam results on this page are records, so Result::subject is a method reference to an accessor.

A collector is a recipe#

collect is a terminal operation, like toList() and count(). What it builds depends on the collector you hand it. Every collector follows the same three steps: make an empty container, add each element that reaches it, and finish the container into the result. Collectors.toList() makes an empty list, adds each element to it, and hands the list back.

Collectors, with an s, is a class full of static methods, and each one returns a ready-made collector. It lives in java.util.stream, next to Stream. Here are four of the simplest. Five people signed up for the chess club, and Ben signed up twice.

Collect.java
1import java.util.List;
2import java.util.Set;
3import java.util.stream.Collectors;
4
5public class Main {
6 public static void main(String[] args) {
7 List<String> signUps = List.of("Ada", "Ben", "Cleo", "Ben", "Eve");
8
9 List<String> asList = signUps.stream().collect(Collectors.toList());
10 Set<String> asSet = signUps.stream().collect(Collectors.toSet());
11 String asText = signUps.stream().collect(Collectors.joining(", "));
12 long howMany = signUps.stream().collect(Collectors.counting());
13
14 System.out.println(asList);
15 System.out.println(asSet);
16 System.out.println(asText);
17 System.out.println(howMany);
18 }
19}
Prints
›[Ada, Ben, Cleo, Ben, Eve]
›[Cleo, Eve, Ben, Ada]
›Ada, Ben, Cleo, Ben, Eve
›5

It prints four lines, one for each collector:

  • [Ada, Ben, Cleo, Ben, Eve]. toList() keeps every element in order, repeats included.
  • [Cleo, Eve, Ben, Ada]. toSet() drops the second Ben, as any set would. In practice it fills a HashSet, so the order comes from the names' hash codes. The documentation promises no order at all, so never rely on this one.
  • Ada, Ben, Cleo, Ben, Eve. joining(", ") glues the names into one string with a comma and a space between neighbours.
  • 5. counting() counts the elements, just like count().

Two of these look like repeats: counting() does the job of count(), and collect(Collectors.toList()) the job of toList(). Collectors earn their place in the next section, where one collector runs inside another, a job that count() and toList() cannot do.

Number streams need boxed() first
IntStream, LongStream and DoubleStream have no collect method that takes a collector. Call boxed() first, which turns each number into an Integer (or a Long or Double) object: IntStream.rangeClosed(1, 6).boxed().collect(...).

joining: one string with separators

joining comes in three sizes. With no arguments it glues the strings straight together. With one argument, it puts that separator between neighbours. With three, it also adds a prefix at the very start and a suffix at the very end, which is how you get brackets around a list.

Joining.java
1import java.util.List;
2import java.util.stream.Collectors;
3
4public class Main {
5 public static void main(String[] args) {
6 List<String> passed = List.of("Ada", "Cleo", "Eve");
7 List<String> nobody = List.of();
8
9 String plain = passed.stream().collect(Collectors.joining());
10 String commas = passed.stream().collect(Collectors.joining(", "));
11 String brackets = passed.stream().collect(Collectors.joining(", ", "[", "]"));
12 String empty = nobody.stream().collect(Collectors.joining(", ", "[", "]"));
13 System.out.println(plain);
14 System.out.println(commas);
15 System.out.println(brackets);
16 System.out.println(empty);
17
18 List<Integer> scores = List.of(91, 67, 82);
19 String working = scores.stream()
20 .map(String::valueOf)
21 .collect(Collectors.joining(" + "));
22 int total = scores.stream().mapToInt(Integer::intValue).sum();
23 System.out.println(working + " = " + total);
24 }
25}
Prints
›AdaCleoEve
›Ada, Cleo, Eve
›[Ada, Cleo, Eve]
›[]
›91 + 67 + 82 = 240

It prints AdaCleoEve, Ada, Cleo, Eve and [Ada, Cleo, Eve]. An empty stream still gets its prefix and suffix, so the fourth line is []. The last line is 91 + 67 + 82 = 240.

joining works only on text. The scores are a Stream<Integer>, so map(String::valueOf) turns each number into a String first. Leave that line out and the program does not compile.

Joining a list you already have
For a list that is already built, you do not need a stream: String.join(", ", passed) gives the same Ada, Cleo, Eve. Save joining for the end of a pipeline that does some other work first, such as the map above.

groupingBy: sorting elements into groups#

In HashMap and HashSetyou built an index of fruit by first letter. For each fruit, the loop asked the map for that letter's list with computeIfAbsent, which made an empty list the first time a letter turned up, and added the fruit to it. Collectors.groupingBy does that whole job.

FruitIndex.java
1import java.util.List;
2import java.util.Map;
3import java.util.stream.Collectors;
4
5public class Main {
6 public static void main(String[] args) {
7 List<String> fruits = List.of("apple", "banana", "avocado", "cherry", "blueberry", "apricot");
8
9 Map<Character, List<String>> index = fruits.stream()
10 .collect(Collectors.groupingBy(fruit -> fruit.charAt(0)));
11
12 System.out.println(index);
13 System.out.println(index.get('b'));
14 }
15}
Prints
›{a=[apple, avocado, apricot], b=[banana, blueberry], c=[cherry]}
›[banana, blueberry]

It prints {a=[apple, avocado, apricot], b=[banana, blueberry], c=[cherry]}, the same map the loop built, and then [banana, blueberry]. Step through it and watch the lambda run once for each fruit.

The lambda you give groupingBy is called the classifier. It takes one element and returns that element's key: the letter a for apple. For each element, groupingByworks out the key, looks up the list for that key (making a new, empty one if the key is new), and adds the element to it. That is your loop exactly. Java's own groupingBy calls computeIfAbsent once for every element.

The type of the result follows from those parts. The keys have the type the classifier returns, Character, and each value is a list of elements, so the map is a Map<Character, List<String>>.

Grouping records by a field

Most of the time you group objects by one of their fields. Here are seven exam results, each a record with a student's name, a subject and a score. The rest of the page uses them.

BySubject.java
1import java.util.LinkedHashMap;
2import java.util.List;
3import java.util.Map;
4import java.util.TreeMap;
5import java.util.stream.Collectors;
6
7public class Main {
8 public static void main(String[] args) {
9 List<Result> results = List.of(
10 new Result("Ada", "Maths", 91),
11 new Result("Ben", "Music", 55),
12 new Result("Cleo", "Art", 67),
13 new Result("Ada", "Art", 78),
14 new Result("Dan", "Music", 38),
15 new Result("Eve", "Maths", 83),
16 new Result("Ben", "Maths", 42));
17
18 Map<String, List<Result>> bySubject = results.stream()
19 .collect(Collectors.groupingBy(Result::subject));
20 System.out.println(bySubject.get("Music"));
21 System.out.println(bySubject.keySet());
22
23 Map<String, List<Result>> sorted = results.stream()
24 .collect(Collectors.groupingBy(Result::subject, TreeMap::new, Collectors.toList()));
25 System.out.println(sorted.keySet());
26
27 Map<String, List<Result>> arrival = results.stream()
28 .collect(Collectors.groupingBy(Result::subject, LinkedHashMap::new, Collectors.toList()));
29 System.out.println(arrival.keySet());
30 }
31}
32
33record Result(String student, String subject, int score) {}
Prints
›[Result[student=Ben, subject=Music, score=55], Result[student=Dan, subject=Music, score=38]]
›[Maths, Art, Music]
›[Art, Maths, Music]
›[Maths, Music, Art]

Result::subject is the classifier, so the results are grouped by subject, and bySubject.get("Music") gives both Music results: [Result[student=Ben, subject=Music, score=55], Result[student=Dan, subject=Music, score=38]].

The second line lists the keys: [Maths, Art, Music]. That is not the order in which the subjects first turn up (Maths, then Music, then Art), and it is not alphabetical either. The one-argument groupingBy puts its groups in a HashMap, so the order comes from the keys' hash codes, the scrambled order you met in HashMap and HashSet. Strictly, the documentation promises only some kind of Map; today it is a HashMap.

To choose the map yourself, use the three-argument form: the classifier, then something that makes the empty map, then what to collect in each group. TreeMap::new is a constructor reference, one of the four kinds of method reference from Streams. It gives a TreeMap, which keeps its keys sorted: [Art, Maths, Music]. LinkedHashMap::new keeps the keys in the order they first arrived: [Maths, Music, Art]. There is no two-argument form that takes a map, so the third argument has to be there, even when it is just Collectors.toList().

Ask for a TreeMap when people will read it
Whenever a grouped map gets printed or shown to a person, pass TreeMap::new. Sorted keys are much easier to scan than hash order, which is why the programs on the rest of this page use it.

Downstream collectors: what each group holds#

The last argument in groupingBy(Result::subject, TreeMap::new, Collectors.toList()) is itself a collector. groupingByruns it separately for each group, on that group's elements only, so it decides what each group ends up holding. A collector used like this is called a downstream collector. toList() is the one you get when you leave it out, and it keeps the elements themselves. Swap it for another collector and each group holds something else.

Try it in the lab. Each press of Next step sends one result into groupingBy. Result::subject picks its group, the first result of a subject makes that group, and the downstream collector you chose adds the result in its own way. Switch the downstream collector at any point, even halfway through, and compare what the same groups hold.

Into groups7 exam results
Map<String, List<Result>> bySubject = results.stream()
.collect(Collectors.groupingBy(
Result::subject,
TreeMap::new,
Collectors.toList()));

Downstream collector

The stream, in list order

  1. AdaMaths 91, not sent yet
  2. BenMusic 55, not sent yet
  3. CleoArt 67, not sent yet
  4. AdaArt 78, not sent yet
  5. DanMusic 38, not sent yet
  6. EveMaths 83, not sent yet
  7. BenMaths 42, not sent yet

The map, one group per key

No groups yet. A group appears when its first result arrives.

    Nothing has run yet, and the map is empty. Press Next step to send the first result into groupingBy.

    System.out.println(bySubject)

    Nothing to print yet: collect hands back the map only after the last result.

    Here are four downstream collectors in one program, each giving a different map.

    PerSubject.java
    1import java.util.List;
    2import java.util.Map;
    3import java.util.TreeMap;
    4import java.util.stream.Collectors;
    5
    6public class Main {
    7 public static void main(String[] args) {
    8 List<Result> results = List.of(
    9 new Result("Ada", "Maths", 91),
    10 new Result("Ben", "Music", 55),
    11 new Result("Cleo", "Art", 67),
    12 new Result("Ada", "Art", 78),
    13 new Result("Dan", "Music", 38),
    14 new Result("Eve", "Maths", 83),
    15 new Result("Ben", "Maths", 42));
    16
    17 Map<String, Long> howMany = results.stream()
    18 .collect(Collectors.groupingBy(Result::subject, TreeMap::new,
    19 Collectors.counting()));
    20 Map<String, Integer> total = results.stream()
    21 .collect(Collectors.groupingBy(Result::subject, TreeMap::new,
    22 Collectors.summingInt(Result::score)));
    23 Map<String, Double> average = results.stream()
    24 .collect(Collectors.groupingBy(Result::subject, TreeMap::new,
    25 Collectors.averagingInt(Result::score)));
    26 Map<String, List<String>> names = results.stream()
    27 .collect(Collectors.groupingBy(Result::subject, TreeMap::new,
    28 Collectors.mapping(Result::student, Collectors.toList())));
    29
    30 System.out.println(howMany);
    31 System.out.println(total);
    32 System.out.println(average);
    33 System.out.println(names);
    34 }
    35}
    36
    37record Result(String student, String subject, int score) {}
    Prints
    ›{Art=2, Maths=3, Music=2}
    ›{Art=145, Maths=216, Music=93}
    ›{Art=72.5, Maths=72.0, Music=46.5}
    ›{Art=[Cleo, Ada], Maths=[Ada, Eve, Ben], Music=[Ben, Dan]}

    It prints the number of results in each subject, {Art=2, Maths=3, Music=2}, then the totals, {Art=145, Maths=216, Music=93}, the averages, {Art=72.5, Maths=72.0, Music=46.5}, and the names, {Art=[Cleo, Ada], Maths=[Ada, Eve, Ben], Music=[Ben, Dan]}. Inside each group, the elements keep the order they had in the list.

    Downstream collectorEach group becomesThe map
    toList()a list of the results (also what you get with no downstream at all)Map<String, List<Result>>
    counting()how many results, as a LongMap<String, Long>
    summingInt(Result::score)the total score, as an IntegerMap<String, Integer>
    averagingInt(Result::score)the average score, as a DoubleMap<String, Double>
    mapping(Result::student, toList())a list of just the namesMap<String, List<String>>
    mapping(Result::student, joining(", "))the names, joined into one stringMap<String, String>
    maxBy(Comparator.comparingInt(Result::score))the best result, inside an OptionalMap<String, Optional<Result>>
    Every row groups by Result::subject. summingLong, summingDouble, averagingLong and averagingDouble do the same jobs for long and double values.
    Key idea

    groupingBy decides which group each element joins. The downstream collector decides what each group holds: the elements, a count, a total, or anything else a collector can make.

    mapping changes the elements on their way in

    mapping takes two arguments: a function and another collector. It runs the function on each element of the group, the way map does in a pipeline, and hands the results to the collector. So mapping(Result::student, Collectors.toList())turns each result into its student's name and collects the names into a list. Give it Collectors.joining(", ") as the inner collector instead, and each subject gets a single string such as Cleo, Ada.

    counting() gives a Long

    counting() hands back its count as a Long, so the map it fills is a Map<String, Long>. Declare the map with Integer values and the program does not compile:

    Main.java
    read along
    1import java.util.List;
    2import java.util.Map;
    3import java.util.stream.Collectors;
    4
    5public class Main {
    6 public static void main(String[] args) {
    7 List<String> subjects = List.of("Maths", "Music", "Art", "Art", "Music", "Maths", "Maths");
    8
    9 Map<String, Integer> howMany = subjects.stream()
    10 .collect(Collectors.groupingBy(s -> s, Collectors.counting()));
    11 System.out.println(howMany);
    12 }
    13}
    Terminal
    read along
    1$ javac Main.java
    2Main.java:10: error: incompatible types: inference variable D has incompatible equality constraints Integer,Long
    3 .collect(Collectors.groupingBy(s -> s, Collectors.counting()));
    4 ^
    5 where D,T,K,A are type-variables:
    6 D extends Object declared in method <T,K,A,D>groupingBy(Function<? super T,? extends K>,Collector<? super T,A,D>)
    7 T extends Object declared in method <T,K,A,D>groupingBy(Function<? super T,? extends K>,Collector<? super T,A,D>)
    8 K extends Object declared in method <T,K,A,D>groupingBy(Function<? super T,? extends K>,Collector<? super T,A,D>)
    9 A extends Object declared in method <T,K,A,D>groupingBy(Function<? super T,? extends K>,Collector<? super T,A,D>)
    101 error

    Errors about collectors are long, but only the first line matters here. Dis the type of each group's value, and the compiler is being asked to make it Integer (your declaration) and Long (what counting() makes) at the same time. The where lines underneath only say what the letters D, T, K and A stand for in the declaration of groupingBy. Change Integer to Long and the program prints {Maths=3, Art=2, Music=2}.

    The best in each group: maxBy

    maxBy keeps the largest element according to a comparator, the same kind of comparator you built in Comparable, Comparator and sorting. minBy keeps the smallest.

    BestPerSubject.java
    1import java.util.Comparator;
    2import java.util.List;
    3import java.util.Map;
    4import java.util.Optional;
    5import java.util.TreeMap;
    6import java.util.stream.Collectors;
    7
    8public class Main {
    9 public static void main(String[] args) {
    10 List<Result> results = List.of(
    11 new Result("Ada", "Maths", 91),
    12 new Result("Ben", "Music", 55),
    13 new Result("Cleo", "Art", 67),
    14 new Result("Ada", "Art", 78),
    15 new Result("Dan", "Music", 38),
    16 new Result("Eve", "Maths", 83),
    17 new Result("Ben", "Maths", 42));
    18
    19 Map<String, Optional<Result>> best = results.stream()
    20 .collect(Collectors.groupingBy(Result::subject, TreeMap::new,
    21 Collectors.maxBy(Comparator.comparingInt(Result::score))));
    22 System.out.println(best.get("Art"));
    23 System.out.println(best.get("Music"));
    24
    25 Optional<Result> top = results.stream()
    26 .max(Comparator.comparingInt(Result::score));
    27 System.out.println(top);
    28 }
    29}
    30
    31record Result(String student, String subject, int score) {}
    Prints
    ›Optional[Result[student=Ada, subject=Art, score=78]]
    ›Optional[Result[student=Ben, subject=Music, score=55]]
    ›Optional[Result[student=Ada, subject=Maths, score=91]]

    The best Art result is Ada's 78 and the best Music result is Ben's 55, and each one comes wrapped: Optional[Result[student=Ada, subject=Art, score=78]]. maxBy works on any stream, and an empty one has no largest element, so it always answers with an Optional, the box you met with findFirst(). Inside groupingBy every group has at least one element, so these boxes are never empty. The last line shows the same idea on a whole stream: max(comparator) is a terminal operation, and it returns Optional[Result[student=Ada, subject=Maths, score=91]].

    Taking the value out of the box
    Optional: a value that might be missing shows how to take the value out of an Optional safely, with methods such as orElse and orElseThrow.
    Quick check
    What is the type of results.stream().collect(Collectors.groupingBy(Result::student, Collectors.summingInt(Result::score)))?
    ExerciseTakings.java
    A corner shop recorded six sales. Fill in the two maps so that the program prints the money taken in each category and the items sold in each category, both with the categories in alphabetical order: {bakery=8, dairy=6, fruit=7} and {bakery=[bread, cake], dairy=[milk, cheese], fruit=[apples, pears]}.

    partitioningBy: true or false#

    Pass and fail is grouping with only two groups, and a score of 50 or more is a pass. partitioningBy takes a test, the same kind of lambda filter takes, and splits the elements by its answer. The result is a Map<Boolean, List<Result>>: the elements that passed the test under true, the rest under false. Like groupingBy, it takes a downstream collector as an optional second argument.

    PassFail.java
    1import java.util.List;
    2import java.util.Map;
    3import java.util.stream.Collectors;
    4
    5public class Main {
    6 public static void main(String[] args) {
    7 List<Result> results = List.of(
    8 new Result("Ada", "Maths", 91),
    9 new Result("Ben", "Music", 55),
    10 new Result("Cleo", "Art", 67),
    11 new Result("Ada", "Art", 78),
    12 new Result("Dan", "Music", 38),
    13 new Result("Eve", "Maths", 83),
    14 new Result("Ben", "Maths", 42));
    15
    16 Map<Boolean, List<String>> passed = results.stream()
    17 .collect(Collectors.partitioningBy(r -> r.score() >= 50,
    18 Collectors.mapping(Result::student, Collectors.toList())));
    19 System.out.println(passed);
    20 System.out.println("Passed: " + passed.get(true));
    21 System.out.println("Failed: " + passed.get(false));
    22
    23 Map<Boolean, Long> counts = results.stream()
    24 .collect(Collectors.partitioningBy(r -> r.score() >= 50, Collectors.counting()));
    25 System.out.println(counts);
    26 }
    27}
    28
    29record Result(String student, String subject, int score) {}
    Prints
    ›{false=[Dan, Ben], true=[Ada, Ben, Cleo, Ada, Eve]}
    ›Passed: [Ada, Ben, Cleo, Ada, Eve]
    ›Failed: [Dan, Ben]
    ›{false=2, true=5}

    It prints {false=[Dan, Ben], true=[Ada, Ben, Cleo, Ada, Eve]}, then Passed: [Ada, Ben, Cleo, Ada, Eve] and Failed: [Dan, Ben], then the two counts, {false=2, true=5}. filter would have thrown the failing results away; partitioningBy keeps both sides, and false always prints first.

    You could also write groupingBy(r -> r.score() >= 50), since a classifier may return a Boolean too. The two are not quite the same. Predict what this program prints when every score passes.

    Predict the outputAllPassed.java
    1import java.util.List;
    2import java.util.Map;
    3import java.util.stream.Collectors;
    4
    5public class Main {
    6 public static void main(String[] args) {
    7 List<Integer> scores = List.of(72, 88, 95);
    8
    9 Map<Boolean, List<Integer>> parts = scores.stream()
    10 .collect(Collectors.partitioningBy(s -> s >= 50));
    11 Map<Boolean, List<Integer>> groups = scores.stream()
    12 .collect(Collectors.groupingBy(s -> s >= 50));
    13
    14 System.out.println(parts);
    15 System.out.println(groups);
    16 }
    17}

    All three scores pass. What do the two maps print?

    It is the second pair. partitioningBy always has both keys, so with nobody failing, false holds an empty list. groupingBy only makes a key when an element turns up for it, so its map has no false key at all, and groups.get(false) would give null. When you want exactly two groups, true and false, use partitioningBy.

    toMap: one value for each key#

    groupingByexpects many elements for each key. Sometimes each key should get just one value: each student's score, each product's price. Collectors.toMap takes two functions, one that makes the key from an element and one that makes the value. Here it tries to map each student to their score. Run it and read the error.

    ScoreOf.java
    1import java.util.List;
    2import java.util.Map;
    3import java.util.stream.Collectors;
    4
    5public class Main {
    6 public static void main(String[] args) {
    7 List<Result> results = List.of(
    8 new Result("Ada", "Maths", 91),
    9 new Result("Ben", "Music", 55),
    10 new Result("Cleo", "Art", 67),
    11 new Result("Ada", "Art", 78),
    12 new Result("Dan", "Music", 38),
    13 new Result("Eve", "Maths", 83),
    14 new Result("Ben", "Maths", 42));
    15
    16 Map<String, Integer> scoreOf = results.stream()
    17 .collect(Collectors.toMap(Result::student, Result::score));
    18 System.out.println(scoreOf);
    19 }
    20}
    21
    22record Result(String student, String subject, int score) {}
    Prints
    !Exception in thread "main" java.lang.IllegalStateException: Duplicate key Ada (attempted merging values 91 and 78)
    ! at Main.main(Main.java:16)
    Stops on line 16

    IllegalStateException: Duplicate key Ada (attempted merging values 91 and 78)

    A method was called at a moment when the object could not handle it.

    Nothing prints. The program stops with java.lang.IllegalStateException: Duplicate key Ada (attempted merging values 91 and 78). Ada took two exams, so two results make the key Ada, and a map holds one value per key. toMap refuses to guess which one you want. The message names the key and both values: 91 from Maths, already in the map, and 78 from Art, the fourth result, which is where the program stops.

    The fix is a third argument, the merge function. When a key is already in the map, toMap calls it with the value already there and the new one, and stores whatever it returns. Integer::max keeps the higher score, and Integer::sum adds the two together. A fourth argument chooses the kind of map, as it does for groupingBy.

    Merge.java
    1import java.util.List;
    2import java.util.Map;
    3import java.util.TreeMap;
    4import java.util.stream.Collectors;
    5
    6public class Main {
    7 public static void main(String[] args) {
    8 List<Result> results = List.of(
    9 new Result("Ada", "Maths", 91),
    10 new Result("Ben", "Music", 55),
    11 new Result("Cleo", "Art", 67),
    12 new Result("Ada", "Art", 78),
    13 new Result("Dan", "Music", 38),
    14 new Result("Eve", "Maths", 83),
    15 new Result("Ben", "Maths", 42));
    16
    17 Map<String, Integer> best = results.stream()
    18 .collect(Collectors.toMap(Result::student, Result::score, Integer::max));
    19 System.out.println(best);
    20
    21 Map<String, Integer> total = results.stream()
    22 .collect(Collectors.toMap(Result::student, Result::score, Integer::sum, TreeMap::new));
    23 System.out.println(total);
    24 }
    25}
    26
    27record Result(String student, String subject, int score) {}
    Prints
    ›{Dan=38, Cleo=67, Eve=83, Ben=55, Ada=91}
    ›{Ada=169, Ben=97, Cleo=67, Dan=38, Eve=83}

    The first line is each student's best score, in hash order: {Dan=38, Cleo=67, Eve=83, Ben=55, Ada=91}. The second is each student's total, in a TreeMap: {Ada=169, Ben=97, Cleo=67, Dan=38, Eve=83}. Ada's 169 is 91 plus 78.

    A merge function is not new to you. In HashMap and HashSet you counted words with counts.merge(word, 1, Integer::sum). toMap with a merge function calls that same merge method once for every element, passing your function along.

    groupingBy
    toMap
    Many elements for one key is normal.
    Each key is expected to get one value.
    Each value is a group: a list, unless a downstream collector makes it something else.
    Each value comes from your value function.
    A repeated key just joins its group.
    A repeated key throws an IllegalStateException, unless you give a merge function.
    groupingBy(Result::subject)
    toMap(Result::student, Result::score, Integer::max)
    Key idea

    Use groupingBy when a key collects many elements, and toMap when each key has one value. If a key can turn up twice, give toMap a merge function that says what to keep.

    Quick check
    A playlist has two songs by the same artist. What happens when you collect it with Collectors.toMap(Song::artist, Song::title)?
    ExerciseBestRound.java
    Three friends played five rounds of a game. The program stops with a duplicate key exception. Fix the first map so that it keeps each player's best score, then fill in the second map with each player's total. Both maps should list the players in alphabetical order, so give toMap a fourth argument too. The output should be {Lia=160, Mo=120, Sam=140} and {Lia=250, Mo=195, Sam=140}.

    flatMap: lists inside lists#

    Some data comes in layers. A shop keeps a list of baskets, and every basket holds its own list of items. To ask anything about the items, such as how many were sold, you need one stream of items, not a stream of baskets.

    map cannot get you there. It turns each basket into one new element, its list of items, so you end up with a stream of lists. flatMap takes a function that turns each element into a stream, and pours the contents of all those streams into one stream, in order.

    Baskets.java
    1import java.util.List;
    2
    3public class Main {
    4 public static void main(String[] args) {
    5 List<Basket> baskets = List.of(
    6 new Basket("Ada", List.of("milk", "bread")),
    7 new Basket("Ben", List.of("eggs", "milk")),
    8 new Basket("Cleo", List.of("bread", "jam", "milk")));
    9
    10 List<List<String>> packed = baskets.stream()
    11 .map(Basket::items)
    12 .toList();
    13 System.out.println(packed);
    14
    15 List<String> tippedOut = baskets.stream()
    16 .flatMap(b -> b.items().stream())
    17 .toList();
    18 System.out.println(tippedOut);
    19 }
    20}
    21
    22record Basket(String customer, List<String> items) {}
    Prints
    ›[[milk, bread], [eggs, milk], [bread, jam, milk]]
    ›[milk, bread, eggs, milk, bread, jam, milk]

    The first line, [[milk, bread], [eggs, milk], [bread, jam, milk]], is a list of three lists: map turned each basket into one element. The second, [milk, bread, eggs, milk, bread, jam, milk], is one flat list of seven items. Picture three shopping bags. map puts each bag on the counter still packed; flatMap tips each one out.

    Notice the extra .stream() inside the lambda. The function you give flatMapmust return a stream, so it asks each basket's list for one. For an array, use Arrays.stream, as the word count further down does.

    map
    flatMap
    The function returns one value for each element.
    The function returns a stream for each element.
    One element in, exactly one element out.
    One element in, any number out, zero included.
    On the baskets: a Stream<List<String>>
    On the baskets: a Stream<String>
    Key idea

    map turns each element into one new element. flatMap turns each element into a stream of elements and joins all those streams into one.

    Once the items are in a single stream, everything else on this page works on them. A basket that holds nothing adds nothing: Dan's empty basket below gives an empty stream, which flattens to no items at all.

    BasketSales.java
    1import java.util.List;
    2import java.util.Map;
    3import java.util.TreeMap;
    4import java.util.stream.Collectors;
    5
    6public class Main {
    7 public static void main(String[] args) {
    8 List<Basket> baskets = List.of(
    9 new Basket("Ada", List.of("milk", "bread")),
    10 new Basket("Ben", List.of("eggs", "milk")),
    11 new Basket("Cleo", List.of("bread", "jam", "milk")),
    12 new Basket("Dan", List.of()));
    13
    14 long itemsSold = baskets.stream()
    15 .flatMap(b -> b.items().stream())
    16 .count();
    17 System.out.println(itemsSold + " items sold");
    18
    19 List<String> range = baskets.stream()
    20 .flatMap(b -> b.items().stream())
    21 .distinct()
    22 .sorted()
    23 .toList();
    24 System.out.println(range);
    25
    26 Map<String, Long> sales = baskets.stream()
    27 .flatMap(b -> b.items().stream())
    28 .collect(Collectors.groupingBy(item -> item, TreeMap::new, Collectors.counting()));
    29 System.out.println(sales);
    30 }
    31}
    32
    33record Basket(String customer, List<String> items) {}
    Prints
    ›7 items sold
    ›[bread, eggs, jam, milk]
    ›{bread=2, eggs=1, jam=1, milk=3}

    It prints 7 items sold, then every different item in alphabetical order, [bread, eggs, jam, milk], and then how many of each item sold, {bread=2, eggs=1, jam=1, milk=3}. In the last pipeline the classifier item -> item hands each item back unchanged, so every item is its own key. The library has a ready-made function that does the same, Function.identity(), and you will meet it in other people's code.

    Quick check
    rows is List.of(List.of(1, 2), List.of(3)). Which pipeline gives the list [1, 2, 3]?

    Ordering and trimming: sorted, distinct, limit and skip#

    Four intermediate operations change which elements come out of a stream, or in what order:

    • sorted() puts the elements in their natural order, numbers from small to large and strings alphabetically. sorted(comparator) uses any order a comparator describes.
    • distinct() drops every element that equals one already let through.
    • limit(n), from Streams, keeps the first n elements.
    • skip(n) drops the first n elements and keeps the rest.
    TopScores.java
    1import java.util.Comparator;
    2import java.util.List;
    3
    4public class Main {
    5 public static void main(String[] args) {
    6 List<Result> results = List.of(
    7 new Result("Ada", "Maths", 91),
    8 new Result("Ben", "Music", 55),
    9 new Result("Cleo", "Art", 67),
    10 new Result("Ada", "Art", 78),
    11 new Result("Dan", "Music", 38),
    12 new Result("Eve", "Maths", 83),
    13 new Result("Ben", "Maths", 42));
    14
    15 List<String> topThree = results.stream()
    16 .sorted(Comparator.comparingInt(Result::score).reversed())
    17 .limit(3)
    18 .map(r -> r.student() + " " + r.score())
    19 .toList();
    20 System.out.println(topThree);
    21
    22 List<String> nextTwo = results.stream()
    23 .sorted(Comparator.comparingInt(Result::score).reversed())
    24 .skip(3)
    25 .limit(2)
    26 .map(r -> r.student() + " " + r.score())
    27 .toList();
    28 System.out.println(nextTwo);
    29
    30 List<String> subjects = results.stream()
    31 .map(Result::subject)
    32 .distinct()
    33 .toList();
    34 System.out.println(subjects);
    35
    36 List<String> sortedSubjects = results.stream()
    37 .map(Result::subject)
    38 .distinct()
    39 .sorted()
    40 .toList();
    41 System.out.println(sortedSubjects);
    42 }
    43}
    44
    45record Result(String student, String subject, int score) {}
    Prints
    ›[Ada 91, Eve 83, Ada 78]
    ›[Cleo 67, Ben 55]
    ›[Maths, Music, Art]
    ›[Art, Maths, Music]

    The first line is the top three, [Ada 91, Eve 83, Ada 78]: sort from the highest score to the lowest, keep three, then turn each one into text. Comparator.comparingInt(Result::score) puts the lowest score first, and reversed() flips it. The second line skips those three and keeps the next two, [Cleo 67, Ben 55], which are fourth and fifth place. A website that shows ten results to a page gets page three the same way, with skip(20).limit(10).

    The last two lines show what distinct does and what it does not do. It keeps the first of each repeated subject, in the order they arrived: [Maths, Music, Art]. It does not sort; add sorted() after it and you get [Art, Maths, Music].

    distinct and sorted rely on your class
    distinct decides which elements are the same with equals and hashCode, the pair from equals, hashCode and toString. Strings, numbers and records already have both, but a class of your own needs them, or every object counts as different. sorted() with no comparator needs elements that are Comparable. On a record that is not, it throws a ClassCastException when the terminal operation runs, so give it a comparator instead.

    sorted waits for every element

    In Streams you saw each element travel the whole pipeline before the next one set off. sorted cannot work like that, because it cannot know which element comes first until it has seen them all. This program prints from just before sorted and from just after it. Predict the order of the lines.

    Predict the outputIntoSorted.java
    1import java.util.List;
    2
    3public class Main {
    4 public static void main(String[] args) {
    5 List<String> fruit = List.of("pear", "fig", "apple");
    6
    7 List<String> inOrder = fruit.stream()
    8 .peek(f -> System.out.println("into sorted: " + f))
    9 .sorted()
    10 .peek(f -> System.out.println("out of sorted: " + f))
    11 .toList();
    12
    13 System.out.println(inOrder);
    14 }
    15}

    In what order do the lines come out?

    It is the second one. All three fruits go into sorted before any of them comes out. It holds on to every element, sorts them, and only then sends them on, smallest first. An operation that has to remember the elements it has already seen is called stateful. distinct is stateful too, since it remembers what it has let through, but it can pass each new element on straight away.

    Key idea

    Most steps pass each element on as soon as they are done with it. sorted has to see every element before it can pass on the first one.

    Never sort a stream without an end
    Because sorted waits for the last element, it never finishes on a stream that has no last element, such as Stream.iterate(1, n -> n * 2). It keeps collecting elements until the program runs out of memory. Put limit before sorted on such a stream, so that there is an end to wait for.

    Recipes you will reuse#

    A lot of the stream code you will read and write is one of a few recipes, built from the pieces on this page. Here they are on one card. They use the records from this page, plus a Person with a city and a Player with a team and points.

    Recipes
    read along
    1// Count each word, with the words in alphabetical order
    2Map<String, Long> counts = words.stream()
    3 .collect(Collectors.groupingBy(w -> w, TreeMap::new, Collectors.counting()));
    4
    5// Group people by a field
    6Map<String, List<Person>> byCity = people.stream()
    7 .collect(Collectors.groupingBy(Person::city));
    8
    9// How many in each group, and a total for each group
    10Map<String, Long> perCity = people.stream()
    11 .collect(Collectors.groupingBy(Person::city, Collectors.counting()));
    12Map<String, Integer> pointsPerTeam = players.stream()
    13 .collect(Collectors.groupingBy(Player::team, Collectors.summingInt(Player::points)));
    14
    15// The top N
    16List<Result> topThree = results.stream()
    17 .sorted(Comparator.comparingInt(Result::score).reversed())
    18 .limit(3)
    19 .toList();
    20
    21// The top N of a frequency map
    22List<Map.Entry<String, Long>> mostCommon = counts.entrySet().stream()
    23 .sorted(Map.Entry.comparingByValue(Comparator.reverseOrder()))
    24 .limit(3)
    25 .toList();
    26
    27// Flatten lists inside lists
    28List<String> allItems = baskets.stream()
    29 .flatMap(b -> b.items().stream())
    30 .toList();
    31
    32// Every different value, sorted
    33List<String> subjects = results.stream()
    34 .map(Result::subject)
    35 .distinct()
    36 .sorted()
    37 .toList();
    38
    39// Join with a separator and brackets
    40String line = names.stream()
    41 .collect(Collectors.joining(", ", "[", "]"));
    42
    43// Pass and fail
    44Map<Boolean, List<Result>> passFail = results.stream()
    45 .collect(Collectors.partitioningBy(r -> r.score() >= 50));
    46
    47// A lookup map whose keys can repeat
    48Map<String, Integer> best = results.stream()
    49 .collect(Collectors.toMap(Result::student, Result::score, Integer::max));

    Counting words, and the most common ones

    The biggest recipe puts several of these together: count the words in some text, then find the most common ones. It takes two pipelines. The first splits each line into words with flatMap, makes every word lowercase, and counts the words into a TreeMap. The second streams the map's entries, sorts them by count from highest to lowest, and keeps three.

    WordCount.java
    1import java.util.Arrays;
    2import java.util.Comparator;
    3import java.util.List;
    4import java.util.Map;
    5import java.util.TreeMap;
    6import java.util.stream.Collectors;
    7
    8public class Main {
    9 public static void main(String[] args) {
    10 List<String> lines = List.of(
    11 "The rain in Spain",
    12 "stays mainly in the plain",
    13 "in the rain");
    14
    15 Map<String, Long> counts = lines.stream()
    16 .flatMap(line -> Arrays.stream(line.split(" ")))
    17 .map(String::toLowerCase)
    18 .collect(Collectors.groupingBy(word -> word, TreeMap::new, Collectors.counting()));
    19 System.out.println(counts);
    20
    21 List<String> topThree = counts.entrySet().stream()
    22 .sorted(Map.Entry.comparingByValue(Comparator.reverseOrder()))
    23 .limit(3)
    24 .map(e -> e.getKey() + " " + e.getValue())
    25 .toList();
    26 System.out.println(topThree);
    27 }
    28}
    Prints
    ›{in=3, mainly=1, plain=1, rain=2, spain=1, stays=1, the=3}
    ›[in 3, the 3, rain 2]

    It prints {in=3, mainly=1, plain=1, rain=2, spain=1, stays=1, the=3}, then [in 3, the 3, rain 2]. Here is what each new piece does:

    • Arrays.stream(line.split(" ")) cuts a line into its words and makes a stream of them, so flatMap turns three lines into one stream of twelve words.
    • map(String::toLowerCase) comes before the counting, so that The and the count as one word.
    • counts.entrySet().stream()streams the map's entries, the key and value pairs from HashMap and HashSet. A map has no stream() of its own, but its entrySet() does.
    • Map.Entry.comparingByValue(Comparator.reverseOrder()) is a comparator that compares two entries by their values, highest first.

    in and the both appear three times. They come out in alphabetical order because the entries arrive from the TreeMap that way, and sorted keeps tied elements in the order they arrived.

    Two short pipelines beat one long one
    WordCount.java could be written as a single pipeline, but the map in the middle has a name, counts, that tells the reader what the first half produced. When a pipeline needs a comment to be understood, split it at a good name. And when a job reads more clearly as a loop, write the loop; the end of Streams covers when that is.
    Shorter names with a static import
    Code that uses many collectors often starts with static imports, the kind you met in testing with JUnit, so that Collectors. can be left off. This page writes Collectors. in full, which is also what the in-browser runner expects.
    With static imports
    read along
    1import static java.util.stream.Collectors.counting;
    2import static java.util.stream.Collectors.groupingBy;
    3
    4Map<String, Long> howMany = results.stream()
    5 .collect(groupingBy(Result::subject, counting()));
    Put the lines in orderColours.java
    A paint shop writes each order as colour names separated by spaces. Put the lines in order so that the program prints every colour ordered, once each, in alphabetical order and in brackets: [blue, green, red].

    Tap a line, then tap where it should go. Or use the arrows.

    Try it

    Answer each one in your head or on paper first, then open the answer.

    1
    Group these words by their length with Collectors.groupingBy(String::length). What is the type of the map, and what does it print?
    2
    Split the numbers 1 to 6 into even and odd with partitioningBy. Why does the pipeline need boxed()?
    3
    Count the party votes into a Map<String, Integer> with toMap instead of groupingBy, with the foods in alphabetical order.
    4
    Predict both lines before you run this. Why are they different?
    5
    Turn the lap times into the line Laps: 62s | 58s | 60s. with one pipeline.
    6
    A library has a list of Book records with an isbn, an author and a number of pages. Which collector would you reach for to get (a) the number of books by each author, (b) a lookup from ISBN to book, (c) the books under 200 pages and the rest, (d) every author's name once, in one line separated by commas?

    Practice

    Work through these on your own. Each one builds on this page.

    1
    Write a record Person(String name, String city, int age) and a list of eight people in three cities. Print the people grouped by city in a TreeMap, the number of people in each city, and the average age in each city. Then print the city with the most people.
    2
    Copy a paragraph from a book you like into a list of lines. Print its five most common words with their counts, ignoring capital letters and punctuation.
    3
    Give a Book record a List<String> of genres, and make a list of six books where some share genres. With flatMap, print every genre once in alphabetical order, then the number of books in each genre.
    4
    Go back to the exercises in HashMap and HashSet: the food votes counted with merge, and the fruit grouped by length with computeIfAbsent. Rewrite both with collectors. Which version, the loop or the stream, would you rather read six months from now, and why?
    5
    Using the seven exam results, find the student with the highest total across all their subjects, and print the name with the total.

    Further reading#

    Spotted a mistake, or a part that did not make sense? Report it by email