Lesson 53Phase 6: Functional and modern Java
Streams: processing collections step by step
Describing what to do with a collection instead of how: filter, map, reduce and friends, and why streams are lazy.
By BasithUpdated 55 min
- Stream
- filter
- map
- reduce
- forEach
- collect
Five students have just taken a test, and you want the names of the ones who passed. You already know how to write that as a loop: make an empty list, walk the students, check each score, add each passing name. A stream lets you write the same job as a list of steps: take the students, keep the ones who passed, turn each one into a name, collect the names. You say what should happen to the elements, and Java does the walking. This lesson builds those steps one at a time, and then shows the order in which Java runs them, which is the part most people guess wrong at first.
- Build a pipeline from a source, intermediate operations such as
filterandmap, and one terminal operation such astoList,forEachorcount. - Start a stream from a collection, from a few values with
Stream.of, from an array withArrays.stream, and from a range of numbers withIntStream.rangeandrangeClosed. - Explain laziness: nothing runs before the terminal operation, each element goes through every step before the next one starts, and
limitandfindFirststop the work early. - Combine elements into one value with
reduce, and usemapToIntto get thesum,averageandmaxof anIntStream. - Write the four kinds of method reference, such as
String::toUpperCaseandSystem.out::println, in place of lambdas. - Say why a stream can be used only once, and choose between a stream and a plain loop.
s -> s.score() >= 50is the body of a functional interface's one method, written short, and you met the method reference Math::max. Comparable, Comparator and sorting added Song::artist. The lists come from the Collections Framework, which also gave you a first look at a stream: orders.stream().filter(order -> order.equals("tea")).count(). The students on this page are records with a name and a score, and the difference between int and Integer from wrapper classes comes back later on this page.From a loop to a stream#
Each student is a record with a name and a score, and a score of 50 or more is a pass. Here is the job written as a loop.
import java.util.ArrayList;import java.util.List; public class Main { public static void main(String[] args) { List<Student> students = List.of( new Student("Ada", 91), new Student("Ben", 42), new Student("Cleo", 67), new Student("Dan", 38), new Student("Eve", 82)); List<String> passed = new ArrayList<>(); for (Student s : students) { if (s.score() >= 50) { passed.add(s.name()); } } System.out.println(passed); }} record Student(String name, int score) {}It prints [Ada, Cleo, Eve]. The loop works, but most of its lines are about the mechanics: an empty list to fill, a loop variable, an if, a call to add. You have to read all of them to find the goal.
Here is the same job as a stream. Press Run, then step through it.
import java.util.List; public class Main { public static void main(String[] args) { List<Student> students = List.of( new Student("Ada", 91), new Student("Ben", 42), new Student("Cleo", 67), new Student("Dan", 38), new Student("Eve", 82)); List<String> passed = students.stream() .filter(s -> s.score() >= 50) .map(s -> s.name()) .toList(); System.out.println(passed); }} record Student(String name, int score) {}It prints [Ada, Cleo, Eve] as well. Read the four lines from students.stream() down as instructions: take the students, keep each one whose score is at least 50, turn each one into its name, and put the results in a list. There is no empty list to set up and no loop to write.
A stream is a sequence of elements that you process with a chain of steps. students.stream() gives you a stream of the students in the list. Each step is a method that hands a new stream to the next step, which is why the calls hang off each other with dots, one step per line.
While you step through, read the narration. It names every lambda call and the student it was given. Watch the order of those calls: it is the subject of the laziness section further down.
[s.name for s in students if s.score >= 50], and in JavaScript students.filter(s => s.score >= 50).map(s => s.name). Both run straight away, and the JavaScript version builds one new array for filter and another for map. A Java stream behaves more like a Python generator expression, which hands its elements along one at a time and does nothing until something asks for them.Source, steps and a result#
Every stream program has the same three parts, and together they make a pipeline:
- A source supplies the elements. Above, it is
students.stream(). - Zero or more intermediate operations, such as
filterandmap. Each one adds a step and gives back a new stream, so you can chain as many as you need. - Exactly one terminal operation at the end, such as
toList. It produces the result, which is no longer a stream, and it finishes the pipeline.
| Line | Part | What it does |
|---|---|---|
students.stream() | source | Hands the students over, one at a time. |
.filter(s -> s.score() >= 50) | intermediate | Lets a student through only when the lambda answers true. |
.map(s -> s.name()) | intermediate | Turns each student that arrives into a name. |
.toList() | terminal | Gathers whatever arrives into a new list and returns it. |
filter keeps, map changes
filter takes a lambda that answers true or false about one element. An element that gets true carries on, unchanged. An element that gets false is dropped. s -> s.score() >= 50 answers true for Ada, Cleo and Eve.
map takes a lambda that turns one element into a new value, and passes the new value on in its place. The new value can have a different type: s -> s.name() turns a Student into a String, so after map the stream carries strings. Here is the type of the stream after each step.
List<String> passed = students.stream() // Stream<Student> .filter(s -> s.score() >= 50) // still Stream<Student> .map(s -> s.name()) // now Stream<String> .toList(); // List<String>The type in the angle brackets is what the next step receives. A lambda after map gets a name, so calling s.score() there would not compile: a String has no score() method.
The interfaces behind the lambdas
A lambda only means something once Java knows which functional interface it fills in. Each stream method declares one, taken from the package java.util.function, so you never have to write these interfaces yourself.
| Method | Takes a | Whose one method is | A lambda for it |
|---|---|---|---|
filter | Predicate<T> | boolean test(T) | s -> s.score() >= 50 |
map | Function<T, R> | R apply(T) | s -> s.name() |
forEach | Consumer<T> | void accept(T) | name -> System.out.println(name) |
reduce | BinaryOperator<T> | T apply(T, T) | (a, b) -> a + b |
mapToInt | ToIntFunction<T> | int applyAsInt(T) | s -> s.score() |
Terminal operations: toList, forEach and count
The terminal operation decides what you get back. Three are worth knowing first:
toList()gathers the elements that reach it into a newList, in order.forEach(action)runs the action on each element that reaches it, and returns nothing. It plays the part of a for-each loop's body.count()returns how many elements reach it, as along.
import java.util.List; public class Main { public static void main(String[] args) { List<Student> students = List.of( new Student("Ada", 91), new Student("Ben", 42), new Student("Cleo", 67), new Student("Dan", 38), new Student("Eve", 82)); students.stream() .filter(s -> s.score() >= 50) .forEach(s -> System.out.println(s.name() + " passed with " + s.score())); long notPassed = students.stream() .filter(s -> s.score() < 50) .count(); System.out.println(notPassed + " did not pass"); }} record Student(String name, int score) {}It prints Ada passed with 91, Cleo passed with 67 and Eve passed with 82, then 2 did not pass. Notice that the second pipeline starts again from students.stream(). A terminal operation uses its stream up, so a second job needs a second stream; the section on using a stream once shows what happens otherwise.
toList() and collect(Collectors.toList())
toList() arrived in Java 16. A lot of code, old and new, ends a pipeline with collect(Collectors.toList()) instead. collect is a terminal operation that takes a collector, a recipe for gathering elements into a result, and Collectors.toList() is the recipe for a list. Both give you the same elements. The difference shows up when you try to change the list.
import java.util.List;import java.util.stream.Collectors; public class Main { public static void main(String[] args) { List<String> cart = List.of("pen", "notebook", "ink", "stapler"); List<String> viaCollect = cart.stream() .filter(item -> item.length() > 3) .collect(Collectors.toList()); viaCollect.add("glue"); System.out.println(viaCollect); List<String> viaToList = cart.stream() .filter(item -> item.length() > 3) .toList(); System.out.println(viaToList); viaToList.add("glue"); }}UnsupportedOperationException
This collection cannot be changed. List.of and similar methods make read-only lists.
The first list prints [notebook, stapler, glue], because adding to it worked. The second prints [notebook, stapler], and then add throws an UnsupportedOperationException. The list from toList() cannot be changed, just like a list made with List.of.
toList() when you only read the result, which is most of the time. The documentation for Collectors.toList() makes no promise about the kind of list it returns. Today it is an ArrayList you can change, but if your code needs to add to the list, say so in the code: new ArrayList<>(stream.toList()) copies the result into a list that you know you can change.Collectors has recipes for much more than lists: grouping elements by a key, counting each group, splitting the elements into those that pass a test and those that fail it, and joining strings with commas. They are the subject of the next lesson, Collectors and stream recipes.The source stays as it was
A stream never changes the collection it came from. filter leaves elements out of the stream; it does not remove them from the list. removeIf, from the Collections Framework, is the method that does.
import java.util.ArrayList;import java.util.List; public class Main { public static void main(String[] args) { List<String> fruit = new ArrayList<>(List.of("apple", "kiwi", "banana", "fig")); List<String> shortNames = fruit.stream() .filter(f -> f.length() <= 4) .toList(); System.out.println(shortNames); System.out.println(fruit); fruit.removeIf(f -> f.length() <= 4); System.out.println(fruit); }}It prints [kiwi, fig], then the untouched [apple, kiwi, banana, fig]. Only removeIf changes the list itself, which leaves [apple, banana].
Key ideaA pipeline is a source, any number of intermediate operations, and one terminal operation. Each intermediate operation adds a step and gives back a stream; the terminal operation produces the result.
songs.stream().filter(s -> s.length() > 4).map(s -> s.toUpperCase()).count(), which call is the terminal operation?[Night Drive, Echo]. Change the two lambdas; leave the rest as it is.Where streams come from#
stream() works on every collection: lists, sets, queues, and the keySet(), values() and entrySet() of a map. When your elements are not in a collection, there are other ways to start a stream.
| Source | Gives you | Example |
|---|---|---|
collection.stream() | a stream of the collection's elements, in its order | students.stream() |
Stream.of(a, b, c) | a stream of the values you list | Stream.of("red", "green", "blue") |
Arrays.stream(array) | a stream of the array's elements, and an IntStream for an int[] | Arrays.stream(temps) |
IntStream.range(a, b) | the whole numbers from a up to b, leaving b out | IntStream.range(1, 5) gives 1, 2, 3, 4 |
IntStream.rangeClosed(a, b) | the whole numbers from a to b, with b included | IntStream.rangeClosed(1, 5) gives 1 to 5 |
import java.util.Arrays;import java.util.List;import java.util.stream.IntStream;import java.util.stream.Stream; public class Main { public static void main(String[] args) { List<String> playlist = List.of("Intro", "Echo", "Outro"); System.out.println(playlist.stream().map(t -> t.toLowerCase()).toList()); Stream<String> colors = Stream.of("red", "green", "blue"); System.out.println(colors.filter(c -> c.length() > 3).toList()); String[] days = {"Mon", "Tue", "Wed", "Thu"}; System.out.println(Arrays.stream(days).count()); int[] temps = {18, 21, 19, 24}; System.out.println(Arrays.stream(temps).sum()); System.out.println(IntStream.range(1, 5).sum()); System.out.println(IntStream.rangeClosed(1, 5).sum()); }}The six lines print [intro, echo, outro], [green, blue], 4, 82, 10 and 15. Arrays.stream on an int[] gives an IntStream, and so do the two range methods. An IntStream is a stream of plain int values with number methods such as sum(), and it gets a section of its own further down.
The last two lines show the difference between the ranges. range(1, 5) adds 1 + 2 + 3 + 4, and rangeClosed(1, 5) adds the 5 as well. IntStream.range(0, n) visits the same numbers as the loop for (int i = 0; i < n; i++).
Files.lines in Path and Files gives a Stream<String> of a file's lines, and Matcher.results() in regular expressions gives a stream of every match. Everything on this page works on them too.IntStream.range(1, 5).sum() return?Streams are lazy#
Calling filter or map does not filter or map anything yet. Before reading on, predict what this program prints. Its map step prints a line for every name it changes, and nothing comes after map.
import java.util.List; public class Main { public static void main(String[] args) { List<String> names = List.of("ada", "ben", "cleo"); names.stream() .map(name -> { System.out.println("map " + name); return name.toUpperCase(); }); System.out.println("done"); }}Which output does this program produce?
It prints only done. The pipeline has no terminal operation, so it never runs, and neither does the lambda inside map. An intermediate operation only adds a step to a plan. This is what it means for streams to be lazy: the work waits until a terminal operation needs a result. The next program shows the moment the work starts.
import java.util.List;import java.util.stream.Stream; public class Main { public static void main(String[] args) { List<String> names = List.of("ada", "ben", "cleo"); Stream<String> shouting = names.stream() .map(name -> { System.out.println("map " + name); return name.toUpperCase(); }); System.out.println("The pipeline is built."); List<String> result = shouting.toList(); System.out.println(result); }}The first line printed is The pipeline is built., even though map comes earlier in the code. map ada, map ben and map cleo appear only when toList() runs, and then [ADA, BEN, CLEO].
Think of plumbing. The source is a water tank, each intermediate operation is a length of pipe that does something to the water passing through it, and the terminal operation is the tap. Fitting the pipes together moves no water; only opening the tap does.
One element at a time
The next question is the order of the work. This pipeline prints from inside filter and from inside map. Pick the output you expect.
import java.util.List; public class Main { public static void main(String[] args) { List<String> words = List.of("sun", "moon", "sky", "star"); List<String> result = words.stream() .filter(w -> { System.out.println("filter " + w); return w.length() == 3; }) .map(w -> { System.out.println("map " + w); return w.toUpperCase(); }) .toList(); System.out.println(result); }}In what order do the filter and map lines come out?
It is the second one. Java takes sun from the list and runs filter on it. sun passes, so it goes straight on through map and into the list as SUN, all before Java even looks at moon. moon fails the filter, so map never runs for it. Then sky makes the whole trip, and star is dropped.
Key ideaA stream does not run
filterover the whole list and thenmapover what is left. It takes one element, sends it through every step, and only then takes the next. An element that a step drops never reaches the steps after it.
Because elements pass along one at a time, a stream needs no list between steps such as filter and map. A version with two loops, one that filtered everything into a new list and one that mapped that list into another, would build an extra list along the way.
Watch it happen
The lab runs a pipeline over six test scores: keep the scores of 60 or more, add 5 bonus points to each, and collect the results. Press Next step and watch the grid. Each column is one score and each row is one step. The grid fills one column at a time, top to bottom: one element's whole trip before the next one starts. Then press Add .limit(2) and run it again.
List<Integer> result = scores.stream() .filter(s -> s >= 60) .map(s -> s + 5) .toList();| list | 72 | 41 | 95 | 58 | 88 | 60 |
|---|---|---|---|---|---|---|
| filter | ||||||
| map | ||||||
| toList |
✓ kept by filter, × dropped by filter.
- filter ran
- 0 times
- map ran
- 0 times
- toList() holds
- []
Nothing has run yet: filter and map wait for the terminal operation. Press Next step to run toList(), which starts the stream.
Here is the same pipeline with limit(2), as a program you can step through. Each lambda prints before it does its work, so the output is the order of the calls.
import java.util.List; public class Main { public static void main(String[] args) { List<Integer> scores = List.of(72, 41, 95, 58, 88, 60); List<Integer> result = scores.stream() .filter(s -> { System.out.println("filter " + s); return s >= 60; }) .map(s -> { System.out.println("map " + s); return s + 5; }) .limit(2) .toList(); System.out.println(result); }}It prints filter 72, map 72, filter 41, filter 95, map 95 and then [77, 100]. Open it in the playground, delete the .limit(2) line and run it again: the output grows to ten lines and ends with [77, 100, 93, 65].
Stopping early: limit and findFirst
limit(n) is an intermediate operation that lets the first n elements through and then wants no more. As soon as the second score has passed it, the stream stops taking scores from the list. 58, 88 and 60 are never looked at, so filter runs 3 times instead of 6, and map twice instead of 4 times.
An operation that can finish without visiting every element is called short-circuiting. Some terminal operations work this way too. findFirst() takes the first element that reaches it and stops right there.
import java.util.List;import java.util.Optional; public class Main { public static void main(String[] args) { List<String> songs = List.of("Intro", "Blue Sky", "Echo", "Blue Moon"); Optional<String> firstBlue = songs.stream() .filter(song -> { System.out.println("checking " + song); return song.startsWith("Blue"); }) .findFirst(); System.out.println(firstBlue); System.out.println(firstBlue.orElse("no blue song")); }}It prints checking Intro and checking Blue Sky, and the checking stops there: Echo and Blue Moon are never looked at. The last two lines are Optional[Blue Sky] and Blue Sky.
findFirst() returns an Optional, a small box that holds the answer, or is empty when no element got through. That is why printing it shows Optional[Blue Sky]. orElse("no blue song") takes the value out of the box, or gives you the text you passed when the box is empty.
Optional instead of null, and Optional: a value that might be missing covers the type properly. On this page, orElse is all you need.Three more terminal operations stop as soon as they know their answer, and each returns a boolean:
anyMatch(test)returnstrueas soon as one element passes the test.allMatch(test)returnsfalseas soon as one element fails it.noneMatch(test)returnsfalseas soon as one element passes it.
peek: watching elements go by
Printing from inside filter and map works, but it clutters the lambdas. peek is an intermediate operation made for watching: it runs an action on each element that passes, and hands the element on unchanged. The documentation describes it as mainly a debugging aid, for seeing which elements reach a given point in a pipeline.
import java.util.List; public class Main { public static void main(String[] args) { List<String> words = List.of("sun", "moon", "sky", "star"); List<String> result = words.stream() .peek(w -> System.out.println("from the list: " + w)) .filter(w -> w.length() == 3) .peek(w -> System.out.println("passed filter: " + w)) .map(w -> w.toUpperCase()) .toList(); System.out.println(result); }}It shows the same journeys as before. from the list: sun is followed straight away by passed filter: sun, while moon appears once and goes no further. The last line is [SUN, SKY].
peek, such as adding to a list or changing a field. A stream may skip it for elements it does not need, and the box below shows a case where it skips peek for every element.Combining the elements into one value#
count() boils a whole stream down to one number. reduce is the general tool for that. You give it a starting value and a lambda that combines the result so far with the next element, and it returns the final result. Here it adds up four prices. Step through it and read the narration for each call.
import java.util.List; public class Main { public static void main(String[] args) { List<Integer> prices = List.of(4, 12, 7, 3); int total = prices.stream() .reduce(0, (sum, price) -> sum + price); System.out.println("Total: " + total); }}It prints Total: 26. The lambda (sum, price) -> sum + price runs once for each price, and whatever it returns becomes sum for the next call.
| Call | sum | price | Returns |
|---|---|---|---|
| 1 | 0 | 4 | 4 |
| 2 | 4 | 12 | 16 |
| 3 | 16 | 7 | 23 |
| 4 | 23 | 3 | 26 |
The starting value is called the identity, because it must change nothing when it is combined with an element: 0 for adding, 1 for multiplying, the empty string for joining text. Leave it out, and reduce starts from the first element instead. An empty stream has no first element, though, so that form returns an Optional, the same box findFirst() uses.
import java.util.List; public class Main { public static void main(String[] args) { List<Integer> prices = List.of(4, 12, 7, 3); List<Integer> noPrices = List.of(); System.out.println(prices.stream().reduce(1, (a, b) -> a * b)); System.out.println(prices.stream().reduce((a, b) -> a + b)); System.out.println(noPrices.stream().reduce((a, b) -> a + b)); System.out.println(noPrices.stream().reduce(0, (a, b) -> a + b)); }}It prints 1008, the four prices multiplied together, then Optional[26]. For the empty list, the form without a starting value prints Optional.empty, and the form with one prints 0: the sum of no prices is 0.
Number streams: mapToInt, sum and average
Adding numbers up is so common that you might expect a sum() method on every stream. There is none. A Stream can hold any type of element, and adding up students or strings makes no sense. Turning the students into their scores with map does not help either:
import java.util.List; public class Main { public static void main(String[] args) { List<Student> students = List.of( new Student("Ada", 91), new Student("Ben", 42)); int total = students.stream() .map(s -> s.score()) .sum(); System.out.println(total); }} record Student(String name, int score) {}$ javac Main.javaMain.java:11: error: cannot find symbol .sum(); ^ symbol: method sum() location: interface Stream<Integer>1 errormap boxes each int score into an Integer, so the result is a Stream<Integer>, and the compiler says that Stream<Integer> has no method sum(). The fix is mapToInt. It turns each element into a plain int and gives you an IntStream, which has the number methods.
import java.util.List; public class Main { public static void main(String[] args) { List<Student> students = List.of( new Student("Ada", 91), new Student("Ben", 42), new Student("Cleo", 67), new Student("Dan", 38), new Student("Eve", 82)); long passed = students.stream().filter(s -> s.score() >= 50).count(); int total = students.stream().mapToInt(s -> s.score()).sum(); System.out.println(passed + " passed, " + total + " points in all"); System.out.println(students.stream().mapToInt(s -> s.score()).average()); System.out.println(students.stream().mapToInt(s -> s.score()).max()); }} record Student(String name, int score) {}It prints 3 passed, 320 points in all, then OptionalDouble[64.0] and OptionalInt[91]. The average and the maximum come in boxes for the same reason as before: a stream with no numbers in it has no average and no maximum. The sum of no numbers is 0, so sum() needs no box.
| IntStream method | Returns | For an empty stream |
|---|---|---|
sum() | int | 0 |
count() | long | 0 |
average() | OptionalDouble | OptionalDouble.empty |
max() and min() | OptionalInt | OptionalInt.empty |
To get a plain number out of an OptionalDouble, call orElse with the number you want when the stream is empty.
import java.util.List; public class Main { public static void main(String[] args) { List<Student> students = List.of( new Student("Ada", 91), new Student("Ben", 42), new Student("Cleo", 67), new Student("Dan", 38), new Student("Eve", 82)); double passAverage = students.stream() .filter(s -> s.score() >= 50) .mapToInt(s -> s.score()) .average() .orElse(0); System.out.println("Average of passes: " + passAverage); double topAverage = students.stream() .filter(s -> s.score() >= 95) .mapToInt(s -> s.score()) .average() .orElse(0); System.out.println("Average of scores over 95: " + topAverage); }} record Student(String name, int score) {}It prints Average of passes: 80.0, the average of 91, 67 and 82. Nobody scored 95 or more, so the second stream is empty and orElse(0) gives 0.0.
IntStream.sum() adds in int arithmetic, so a total past 2147483647 wraps around to a negative number without any error, the overflow from wrapper classes. When totals can grow that large, use mapToLong, whose sum() returns a long.students.stream().map(s -> s.score()).sum() fail to compile?Sale total: 50.Tap a line, then tap where it should go. Or use the arrows.
Method references#
Many lambdas in a pipeline do nothing except pass their argument to one method: s -> s.name(), name -> System.out.println(name). A method reference names that method with two colons instead, and Java builds the lambda for you. You have met two kinds already. Math::max names a static method, and Song::artist in Comparable, Comparator and sorting names a method to call on whichever song it is given. There are four kinds in all.
| Kind | Method reference | The lambda it stands for |
|---|---|---|
| A static method | Integer::parseInt | text -> Integer.parseInt(text) |
| A method of one particular object | System.out::println | x -> System.out.println(x) |
| A method of the element itself | String::toUpperCase | s -> s.toUpperCase() |
| A constructor | Song::new | title -> new Song(title) |
import java.util.List; public class Main { public static void main(String[] args) { List<String> typed = List.of("12", "7", "30"); List<Integer> numbers = typed.stream() .map(Integer::parseInt) .toList(); System.out.println(numbers); List<String> names = List.of("ada", "ben", "cleo"); names.stream() .map(String::toUpperCase) .forEach(System.out::println); List<Song> songs = List.of("Intro", "Echo").stream() .map(Song::new) .toList(); System.out.println(songs); }} record Song(String title) {}It prints [12, 7, 30], then ADA, BEN and CLEO on separate lines, then [Song[title=Intro], Song[title=Echo]]. Here is what each reference does with an element:
Integer::parseIntnames a static method, so each element is passed to it as the argument:Integer.parseInt("12").System.out::printlnnames a method of one object that already exists,System.out. Each element becomes the argument:System.out.println("ADA").String::toUpperCasehas a class name before the colons, not an object, and an instance method after them. So the element itself becomes the object the method runs on:"ada".toUpperCase().Song::newnames the constructor of theSongrecord, so each title becomesnew Song("Intro").
The accessors of a record work like String::toUpperCase. Here are the students again, with a method reference wherever a lambda only passed its argument on.
import java.util.List; public class Main { public static void main(String[] args) { List<Student> students = List.of( new Student("Ada", 91), new Student("Ben", 42), new Student("Cleo", 67), new Student("Dan", 38), new Student("Eve", 82)); students.stream() .filter(s -> s.score() >= 50) .map(Student::name) .forEach(System.out::println); int total = students.stream() .mapToInt(Student::score) .sum(); System.out.println("Total: " + total); }} record Student(String name, int score) {}It prints Ada, Cleo and Eve on separate lines, then Total: 320. Student::name stands for s -> s.name(), and Student::score for s -> s.score().
s -> s.score() >= 50 also compares the score with 50, so it stays a lambda. And when a lambda reads more clearly to you, keep the lambda: both do the same job.word -> word.length()?mapToInt with the method reference Song::seconds to work out the total length of the playlist and the average song length. The program should print Total: 643 seconds and Average: 160.75 seconds.A stream can be used once#
A stream is a one-way trip over its source. Once a terminal operation has run, the stream is used up, and asking it for anything more throws an exception. Run this and read the error.
import java.util.List;import java.util.stream.Stream; public class Main { public static void main(String[] args) { List<String> songs = List.of("Intro", "Echo", "Outro"); Stream<String> stream = songs.stream(); stream.forEach(song -> System.out.println(song)); long count = stream.count(); System.out.println(count + " songs"); }}IllegalStateException: stream has already been operated upon or closed
A method was called at a moment when the object could not handle it.
The three songs print, and then stream.count() throws an IllegalStateException with the message stream has already been operated upon or closed. The forEach on the line before used the stream up.
The fix is to ask the list for a new stream for each job. A list can be streamed as often as you like, and each call to songs.stream() starts a new trip.
songs.stream().forEach(song -> System.out.println(song));long count = songs.stream().count();The rule covers intermediate operations too. Calling filter on a stream uses that stream, so a later map on the same stream variable throws the same exception. Writing each pipeline as one chain, from the source to the terminal operation, avoids the problem completely, which is why you rarely see a stream kept in a variable.
Key ideaEach stream runs one pipeline. To go over the same elements again, start a new stream from the source.
Stream or loop?#
Streams do not replace loops. Some jobs read better as a pipeline and some read better as a loop. One limit decides many cases: a lambda cannot change a local variable of the method around it. Here is an attempt to add up bank account moves with forEach:
import java.util.List; public class Main { public static void main(String[] args) { List<Integer> moves = List.of(50, -20, -40, 30); int balance = 0; moves.stream().forEach(move -> balance += move); System.out.println(balance); }}$ javac Main.javaMain.java:8: error: local variables referenced from a lambda expression must be final or effectively final moves.stream().forEach(move -> balance += move); ^1 errorA local variable used inside a lambda must be effectively final: given a value once and never changed after that. balance changes, so the compiler refuses. For the final balance alone, the stream answer is moves.stream().mapToInt(m -> m).sum(). But some jobs carry a value from each element to the next, and then a loop is the clearer tool. This one prints the balance after every move and stops at the first overdraft.
import java.util.List; public class Main { public static void main(String[] args) { List<Integer> moves = List.of(50, -20, -40, 30); int balance = 0; for (int move : moves) { balance += move; System.out.println("after " + move + ": " + balance); if (balance < 0) { System.out.println("Overdrawn, stopping here."); break; } } }}It prints after 50: 50, after -20: 30 and after -40: -10, then Overdrawn, stopping here. Each step needs the balance left by the step before, prints a line, and may end the walk. A loop says all of that directly.
IOException, which a stream's lambdas cannot pass on.limit or findFirst to stop the work early.students.stream().filter(s -> s.score() >= 50).forEach(s -> names.add(s.name())), with names an empty ArrayList made beforehand. It compiles, because the variable names never changes; only the list it points at does. But it is a loop written the long way round, and the documentation of the stream package discourages lambdas with side effects like this one. Let the terminal operation build the list: .map(Student::name).toList().Speed is rarely the reason to choose. A stream is not faster than a loop just because it is a stream, and for the lists in most programs the difference is too small to notice. Write the version that a reader will understand first.
Key ideaUse a stream when the job reads as keep, change and combine. Use a loop when each step depends on what the earlier steps did.
Try it
Answer each one in your head or on paper first, then open the answer.
songs.stream().filter(s -> s.length() > 4).map(String::toUpperCase).toList(), where songs is a List<String>, name the source, the intermediate operations and the terminal operation. What type does each step give?peek run?IntStream.rangeClosed. What is the total?s -> s.trim(), n -> System.out.println(n), (a, b) -> Integer.max(a, b), name -> new Student(name, 0).IllegalStateException. Fix it so that it prints the three songs and then 3 songs.Practice
Work through these on your own. Each one builds on this page.
"the quick brown fox jumps over the lazy dog" into words with split(" "). With Arrays.stream on the array, print how many words there are, how many have more than 3 letters, and how many letters there are in total.anyMatch and allMatch. Then predict what each one returns for an empty list of students, and check.7 x 1 = 7 to 7 x 10 = 70, one line each, using IntStream.rangeClosed and forEach. Then write it as a for loop, and decide which version you would rather read..peek(s -> System.out.println("from the list " + s)) straight after stream(). Predict the output with and without limit(2), then run both. Then move limit(2) to just after stream(), so it comes before filter, and predict again.Further reading#
- The java.util.stream package summary in the Java SE 25 API documentation, on pipelines, laziness and short-circuiting
- The Stream interface in the Java SE 25 API documentation, with every operation and the notes on peek and count
- Processing data in memory using the Stream API, the start of the streams tutorial on dev.java
- Writing lambda expressions as method references, on dev.java
Spotted a mistake, or a part that did not make sense? Report it by email