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 BasithUpdated 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.
- End a pipeline with
collectand a collector fromCollectors:toList,toSet,joiningandcounting. - Group elements by a key with
groupingBy, and choose what each group holds with a downstream collector such ascounting,summingInt,averagingIntormapping. - Split elements into a
truegroup and afalsegroup withpartitioningBy. - 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 withsorted,distinct,limitandskip. - Combine the pieces into everyday recipes: counting words, the top N, joining with brackets, pass and fail.
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.
import java.util.List;import java.util.Set;import java.util.stream.Collectors; public class Main { public static void main(String[] args) { List<String> signUps = List.of("Ada", "Ben", "Cleo", "Ben", "Eve"); List<String> asList = signUps.stream().collect(Collectors.toList()); Set<String> asSet = signUps.stream().collect(Collectors.toSet()); String asText = signUps.stream().collect(Collectors.joining(", ")); long howMany = signUps.stream().collect(Collectors.counting()); System.out.println(asList); System.out.println(asSet); System.out.println(asText); System.out.println(howMany); }}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 aHashSet, 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 likecount().
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.
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.
import java.util.List;import java.util.stream.Collectors; public class Main { public static void main(String[] args) { List<String> passed = List.of("Ada", "Cleo", "Eve"); List<String> nobody = List.of(); String plain = passed.stream().collect(Collectors.joining()); String commas = passed.stream().collect(Collectors.joining(", ")); String brackets = passed.stream().collect(Collectors.joining(", ", "[", "]")); String empty = nobody.stream().collect(Collectors.joining(", ", "[", "]")); System.out.println(plain); System.out.println(commas); System.out.println(brackets); System.out.println(empty); List<Integer> scores = List.of(91, 67, 82); String working = scores.stream() .map(String::valueOf) .collect(Collectors.joining(" + ")); int total = scores.stream().mapToInt(Integer::intValue).sum(); System.out.println(working + " = " + total); }}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.
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.
import java.util.List;import java.util.Map;import java.util.stream.Collectors; public class Main { public static void main(String[] args) { List<String> fruits = List.of("apple", "banana", "avocado", "cherry", "blueberry", "apricot"); Map<Character, List<String>> index = fruits.stream() .collect(Collectors.groupingBy(fruit -> fruit.charAt(0))); System.out.println(index); System.out.println(index.get('b')); }}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.
import java.util.LinkedHashMap;import java.util.List;import java.util.Map;import java.util.TreeMap;import java.util.stream.Collectors; public class Main { public static void main(String[] args) { List<Result> results = List.of( new Result("Ada", "Maths", 91), new Result("Ben", "Music", 55), new Result("Cleo", "Art", 67), new Result("Ada", "Art", 78), new Result("Dan", "Music", 38), new Result("Eve", "Maths", 83), new Result("Ben", "Maths", 42)); Map<String, List<Result>> bySubject = results.stream() .collect(Collectors.groupingBy(Result::subject)); System.out.println(bySubject.get("Music")); System.out.println(bySubject.keySet()); Map<String, List<Result>> sorted = results.stream() .collect(Collectors.groupingBy(Result::subject, TreeMap::new, Collectors.toList())); System.out.println(sorted.keySet()); Map<String, List<Result>> arrival = results.stream() .collect(Collectors.groupingBy(Result::subject, LinkedHashMap::new, Collectors.toList())); System.out.println(arrival.keySet()); }} record Result(String student, String subject, int score) {}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().
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.
Map<String, List<Result>> bySubject = results.stream() .collect(Collectors.groupingBy( Result::subject, TreeMap::new, Collectors.toList()));Downstream collector
The stream, in list order
- AdaMaths 91, not sent yet
- BenMusic 55, not sent yet
- CleoArt 67, not sent yet
- AdaArt 78, not sent yet
- DanMusic 38, not sent yet
- EveMaths 83, not sent yet
- 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.
import java.util.List;import java.util.Map;import java.util.TreeMap;import java.util.stream.Collectors; public class Main { public static void main(String[] args) { List<Result> results = List.of( new Result("Ada", "Maths", 91), new Result("Ben", "Music", 55), new Result("Cleo", "Art", 67), new Result("Ada", "Art", 78), new Result("Dan", "Music", 38), new Result("Eve", "Maths", 83), new Result("Ben", "Maths", 42)); Map<String, Long> howMany = results.stream() .collect(Collectors.groupingBy(Result::subject, TreeMap::new, Collectors.counting())); Map<String, Integer> total = results.stream() .collect(Collectors.groupingBy(Result::subject, TreeMap::new, Collectors.summingInt(Result::score))); Map<String, Double> average = results.stream() .collect(Collectors.groupingBy(Result::subject, TreeMap::new, Collectors.averagingInt(Result::score))); Map<String, List<String>> names = results.stream() .collect(Collectors.groupingBy(Result::subject, TreeMap::new, Collectors.mapping(Result::student, Collectors.toList()))); System.out.println(howMany); System.out.println(total); System.out.println(average); System.out.println(names); }} record Result(String student, String subject, int score) {}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 collector | Each group becomes | The 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 Long | Map<String, Long> |
summingInt(Result::score) | the total score, as an Integer | Map<String, Integer> |
averagingInt(Result::score) | the average score, as a Double | Map<String, Double> |
mapping(Result::student, toList()) | a list of just the names | Map<String, List<String>> |
mapping(Result::student, joining(", ")) | the names, joined into one string | Map<String, String> |
maxBy(Comparator.comparingInt(Result::score)) | the best result, inside an Optional | Map<String, Optional<Result>> |
Key idea
groupingBydecides 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:
import java.util.List;import java.util.Map;import java.util.stream.Collectors; public class Main { public static void main(String[] args) { List<String> subjects = List.of("Maths", "Music", "Art", "Art", "Music", "Maths", "Maths"); Map<String, Integer> howMany = subjects.stream() .collect(Collectors.groupingBy(s -> s, Collectors.counting())); System.out.println(howMany); }}$ javac Main.javaMain.java:10: error: incompatible types: inference variable D has incompatible equality constraints Integer,Long .collect(Collectors.groupingBy(s -> s, Collectors.counting())); ^ where D,T,K,A are type-variables: D extends Object declared in method <T,K,A,D>groupingBy(Function<? super T,? extends K>,Collector<? super T,A,D>) T extends Object declared in method <T,K,A,D>groupingBy(Function<? super T,? extends K>,Collector<? super T,A,D>) K extends Object declared in method <T,K,A,D>groupingBy(Function<? super T,? extends K>,Collector<? super T,A,D>) A extends Object declared in method <T,K,A,D>groupingBy(Function<? super T,? extends K>,Collector<? super T,A,D>)1 errorErrors 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.
import java.util.Comparator;import java.util.List;import java.util.Map;import java.util.Optional;import java.util.TreeMap;import java.util.stream.Collectors; public class Main { public static void main(String[] args) { List<Result> results = List.of( new Result("Ada", "Maths", 91), new Result("Ben", "Music", 55), new Result("Cleo", "Art", 67), new Result("Ada", "Art", 78), new Result("Dan", "Music", 38), new Result("Eve", "Maths", 83), new Result("Ben", "Maths", 42)); Map<String, Optional<Result>> best = results.stream() .collect(Collectors.groupingBy(Result::subject, TreeMap::new, Collectors.maxBy(Comparator.comparingInt(Result::score)))); System.out.println(best.get("Art")); System.out.println(best.get("Music")); Optional<Result> top = results.stream() .max(Comparator.comparingInt(Result::score)); System.out.println(top); }} record Result(String student, String subject, int score) {}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]].
Optional safely, with methods such as orElse and orElseThrow.results.stream().collect(Collectors.groupingBy(Result::student, Collectors.summingInt(Result::score)))?{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.
import java.util.List;import java.util.Map;import java.util.stream.Collectors; public class Main { public static void main(String[] args) { List<Result> results = List.of( new Result("Ada", "Maths", 91), new Result("Ben", "Music", 55), new Result("Cleo", "Art", 67), new Result("Ada", "Art", 78), new Result("Dan", "Music", 38), new Result("Eve", "Maths", 83), new Result("Ben", "Maths", 42)); Map<Boolean, List<String>> passed = results.stream() .collect(Collectors.partitioningBy(r -> r.score() >= 50, Collectors.mapping(Result::student, Collectors.toList()))); System.out.println(passed); System.out.println("Passed: " + passed.get(true)); System.out.println("Failed: " + passed.get(false)); Map<Boolean, Long> counts = results.stream() .collect(Collectors.partitioningBy(r -> r.score() >= 50, Collectors.counting())); System.out.println(counts); }} record Result(String student, String subject, int score) {}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.
import java.util.List;import java.util.Map;import java.util.stream.Collectors; public class Main { public static void main(String[] args) { List<Integer> scores = List.of(72, 88, 95); Map<Boolean, List<Integer>> parts = scores.stream() .collect(Collectors.partitioningBy(s -> s >= 50)); Map<Boolean, List<Integer>> groups = scores.stream() .collect(Collectors.groupingBy(s -> s >= 50)); System.out.println(parts); System.out.println(groups); }}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.
import java.util.List;import java.util.Map;import java.util.stream.Collectors; public class Main { public static void main(String[] args) { List<Result> results = List.of( new Result("Ada", "Maths", 91), new Result("Ben", "Music", 55), new Result("Cleo", "Art", 67), new Result("Ada", "Art", 78), new Result("Dan", "Music", 38), new Result("Eve", "Maths", 83), new Result("Ben", "Maths", 42)); Map<String, Integer> scoreOf = results.stream() .collect(Collectors.toMap(Result::student, Result::score)); System.out.println(scoreOf); }} record Result(String student, String subject, int score) {}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.
import java.util.List;import java.util.Map;import java.util.TreeMap;import java.util.stream.Collectors; public class Main { public static void main(String[] args) { List<Result> results = List.of( new Result("Ada", "Maths", 91), new Result("Ben", "Music", 55), new Result("Cleo", "Art", 67), new Result("Ada", "Art", 78), new Result("Dan", "Music", 38), new Result("Eve", "Maths", 83), new Result("Ben", "Maths", 42)); Map<String, Integer> best = results.stream() .collect(Collectors.toMap(Result::student, Result::score, Integer::max)); System.out.println(best); Map<String, Integer> total = results.stream() .collect(Collectors.toMap(Result::student, Result::score, Integer::sum, TreeMap::new)); System.out.println(total); }} record Result(String student, String subject, int score) {}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.
IllegalStateException, unless you give a merge function.groupingBy(Result::subject)toMap(Result::student, Result::score, Integer::max)Key ideaUse
groupingBywhen a key collects many elements, andtoMapwhen each key has one value. If a key can turn up twice, givetoMapa merge function that says what to keep.
Collectors.toMap(Song::artist, Song::title)?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.
import java.util.List; public class Main { public static void main(String[] args) { List<Basket> baskets = List.of( new Basket("Ada", List.of("milk", "bread")), new Basket("Ben", List.of("eggs", "milk")), new Basket("Cleo", List.of("bread", "jam", "milk"))); List<List<String>> packed = baskets.stream() .map(Basket::items) .toList(); System.out.println(packed); List<String> tippedOut = baskets.stream() .flatMap(b -> b.items().stream()) .toList(); System.out.println(tippedOut); }} record Basket(String customer, List<String> items) {}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.
Stream<List<String>>Stream<String>Key idea
mapturns each element into one new element.flatMapturns 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.
import java.util.List;import java.util.Map;import java.util.TreeMap;import java.util.stream.Collectors; public class Main { public static void main(String[] args) { List<Basket> baskets = List.of( new Basket("Ada", List.of("milk", "bread")), new Basket("Ben", List.of("eggs", "milk")), new Basket("Cleo", List.of("bread", "jam", "milk")), new Basket("Dan", List.of())); long itemsSold = baskets.stream() .flatMap(b -> b.items().stream()) .count(); System.out.println(itemsSold + " items sold"); List<String> range = baskets.stream() .flatMap(b -> b.items().stream()) .distinct() .sorted() .toList(); System.out.println(range); Map<String, Long> sales = baskets.stream() .flatMap(b -> b.items().stream()) .collect(Collectors.groupingBy(item -> item, TreeMap::new, Collectors.counting())); System.out.println(sales); }} record Basket(String customer, List<String> items) {}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.
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.
import java.util.Comparator;import java.util.List; public class Main { public static void main(String[] args) { List<Result> results = List.of( new Result("Ada", "Maths", 91), new Result("Ben", "Music", 55), new Result("Cleo", "Art", 67), new Result("Ada", "Art", 78), new Result("Dan", "Music", 38), new Result("Eve", "Maths", 83), new Result("Ben", "Maths", 42)); List<String> topThree = results.stream() .sorted(Comparator.comparingInt(Result::score).reversed()) .limit(3) .map(r -> r.student() + " " + r.score()) .toList(); System.out.println(topThree); List<String> nextTwo = results.stream() .sorted(Comparator.comparingInt(Result::score).reversed()) .skip(3) .limit(2) .map(r -> r.student() + " " + r.score()) .toList(); System.out.println(nextTwo); List<String> subjects = results.stream() .map(Result::subject) .distinct() .toList(); System.out.println(subjects); List<String> sortedSubjects = results.stream() .map(Result::subject) .distinct() .sorted() .toList(); System.out.println(sortedSubjects); }} record Result(String student, String subject, int score) {}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 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.
import java.util.List; public class Main { public static void main(String[] args) { List<String> fruit = List.of("pear", "fig", "apple"); List<String> inOrder = fruit.stream() .peek(f -> System.out.println("into sorted: " + f)) .sorted() .peek(f -> System.out.println("out of sorted: " + f)) .toList(); System.out.println(inOrder); }}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 ideaMost steps pass each element on as soon as they are done with it.
sortedhas to see every element before it can pass on the first one.
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.
// Count each word, with the words in alphabetical orderMap<String, Long> counts = words.stream() .collect(Collectors.groupingBy(w -> w, TreeMap::new, Collectors.counting())); // Group people by a fieldMap<String, List<Person>> byCity = people.stream() .collect(Collectors.groupingBy(Person::city)); // How many in each group, and a total for each groupMap<String, Long> perCity = people.stream() .collect(Collectors.groupingBy(Person::city, Collectors.counting()));Map<String, Integer> pointsPerTeam = players.stream() .collect(Collectors.groupingBy(Player::team, Collectors.summingInt(Player::points))); // The top NList<Result> topThree = results.stream() .sorted(Comparator.comparingInt(Result::score).reversed()) .limit(3) .toList(); // The top N of a frequency mapList<Map.Entry<String, Long>> mostCommon = counts.entrySet().stream() .sorted(Map.Entry.comparingByValue(Comparator.reverseOrder())) .limit(3) .toList(); // Flatten lists inside listsList<String> allItems = baskets.stream() .flatMap(b -> b.items().stream()) .toList(); // Every different value, sortedList<String> subjects = results.stream() .map(Result::subject) .distinct() .sorted() .toList(); // Join with a separator and bracketsString line = names.stream() .collect(Collectors.joining(", ", "[", "]")); // Pass and failMap<Boolean, List<Result>> passFail = results.stream() .collect(Collectors.partitioningBy(r -> r.score() >= 50)); // A lookup map whose keys can repeatMap<String, Integer> best = results.stream() .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.
import java.util.Arrays;import java.util.Comparator;import java.util.List;import java.util.Map;import java.util.TreeMap;import java.util.stream.Collectors; public class Main { public static void main(String[] args) { List<String> lines = List.of( "The rain in Spain", "stays mainly in the plain", "in the rain"); Map<String, Long> counts = lines.stream() .flatMap(line -> Arrays.stream(line.split(" "))) .map(String::toLowerCase) .collect(Collectors.groupingBy(word -> word, TreeMap::new, Collectors.counting())); System.out.println(counts); List<String> topThree = counts.entrySet().stream() .sorted(Map.Entry.comparingByValue(Comparator.reverseOrder())) .limit(3) .map(e -> e.getKey() + " " + e.getValue()) .toList(); System.out.println(topThree); }}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, soflatMapturns three lines into one stream of twelve words.map(String::toLowerCase)comes before the counting, so thatTheandthecount as one word.counts.entrySet().stream()streams the map's entries, the key and value pairs from HashMap and HashSet. A map has nostream()of its own, but itsentrySet()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.
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.Collectors. can be left off. This page writes Collectors. in full, which is also what the in-browser runner expects.import static java.util.stream.Collectors.counting;import static java.util.stream.Collectors.groupingBy; Map<String, Long> howMany = results.stream() .collect(groupingBy(Result::subject, counting()));[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.
Collectors.groupingBy(String::length). What is the type of the map, and what does it print?partitioningBy. Why does the pipeline need boxed()?Map<String, Integer> with toMap instead of groupingBy, with the foods in alphabetical order.Laps: 62s | 58s | 60s. with one pipeline.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.
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.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.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?Further reading#
- The Collectors class in the Java SE 25 API documentation, with every collector and the rules for toMap and groupingBy
- The Stream interface in the Java SE 25 API documentation, for flatMap, sorted, distinct, limit and skip
- Using a collector as a terminal operation, the collectors tutorial on dev.java
- Adding intermediate operations on a stream, on dev.java, including flatMap, distinct and sorted
Spotted a mistake, or a part that did not make sense? Report it by email