All 13 Ways to Consume a Stream in Java: One of Them Is Evil
Let’s discuss do’s and don’ts when it comes to consuming streams in Java.
Overview
As we already know, on a Java stream we can perform intermediary and terminal operations. Intermediate steps alter or transform the stream, such as filter, limit, and sort, with the return type also being a stream. It’s important to note that intermediate operations are lazily evaluated, meaning they won’t be executed without terminal operations.
On the other hand, terminal operations consume the stream and put an end to the flow. The result of these operations is something else entirely. In this article, we’ll explore all possible ways of consuming a stream, or in other words, all possible terminal operations.
It’s essential to have a solid understanding of these terminal operations, as they’re crucial in effectively utilizing Java streams. With this knowledge, you’ll be able to work with streams efficiently and effectively, making the most out of this powerful tool. So, let’s dive into the different ways of consuming a Java stream and explore their benefits and use cases.
1–3) Evaluate a Boolean Expression
Oftentimes, we need to iterate through a stream just to evaluate a boolean expression. If this is the case, the result of consuming the stream will be a boolean value.
In Java, we have three methods for evaluating an expression like this: anyMatch(), allMatch(), and noneMatch().
boolean atLeastOneMinor = students().anyMatch(s -> s.age() < 18);
boolean noMinors = students().noneMatch(s -> s.age() < 18);
boolean onlyMinors = students().allMatch(s -> s.age() < 18);4–5) Find An (Optional) Element
Perhaps some of the most commonly used stream methods are the ones for finding a particular element. For this purpose, we might use the intermediate operation filter, followed by findFirst() or findAny().
Note that both of these methods are going to wrap the element into the Optional<> monad, and, if the stream is empty, an empty Optional will be returned:
Optional<Student> firstStudent = students().findFirst(); Optional<Student> anyStudent = students().findAny();
6–9) Collect The Elements To Collection
Streams are all about processing the data, and they aren't a good option when it comes to storing data. For this case, if we want to keep the data as a local variable or a field, we need to collect it first.
Two quick ways of collecting the stream are directly calling the toList() or toArray() methods:
List<Student> listOfStudents = students().toList();
Student[] arrayOfStudents = students().toArray(Student[]::new);Though, if we need more flexibility when it comes to the types of Collection to collect into, we can use the collect() method and provide a collector. We can use out-of-the-box collectors defined as static methods of the java.util.stream.Collectors such as toList(), toUnmodifiableList(), toSet() ... etc.
Set<Student> studentsSet = students().collect(Collectors.toUnmodifiableSet());
Additionally, we can also use the collect method by passing a custom Collector or providing a set of lambda expressions that will be used to collect all the data:
LinkedHashSet<Student> studentsLinkedHashSet = students().collect(
LinkedHashSet::new,
LinkedHashSet::add,
LinkedHashSet::addAll
);10–11) Collect The Stream To a Map
Sometimes we need to group the items by a specific property. For this purpose, there are two extremely useful Collectors: we can either use Collectors.toMap() or Collectors.groupingBy().
Firstly, let’s consider we want to collect the students stream into Map where we use the lastName as the key.
import static java.util.stream.Collectors.toMap;
Map<String, Student> studentsByLastName = students().collect(toMap(
Student::lastName,
s -> s
));
// instead of s -> s, we can also use Function.identity()
Map<String, Student> studentsByLastName = students().collect(toMap(
Student::lastName,
Function.identity()
));As a side note here: we are assuming we won’t have duplicates. However, if we know the stream might have multiple students with the same lastName and we want to either merge the elements or decide which element to keep, we’ll need to define a mergeFunction as the 3rd argument. Let’s use a merge function that will keep the oldest student, in case of duplicates:
Map<String, Student> olderSudentsByLastName = students().collect(toMap(
Student::lastName,
Function.identity(),
(s1, s2) -> s1.age() > s2.age() ? s1 : s2
));On the other hand, if we know we have duplicates, but we don’t want to merge or discard any of the elements and rather collect the values into a collection, we should use the groupBy collector:
import static java.util.stream.Collectors.toMap;
Map<String, List<Student>> allStudentsByLastName = students()
.collect(groupingBy(Student::lastName));Furthermore, we can decide which type of collection to store the values. To customize it, we have to pass yet another collector, as the second argument of groupinBy:
Map<String, Set<Student>> allStudentsByLastName = students()
.collect(groupingBy(
Student::firstName,
Collectors.toUnmodifiableSet()
));12) Reduce The Stream to a Single (Optional) Element
Finally, we can reduce the whole stream down to a single element using the reduce method. For example, we can find the oldest student from the stream by comparing the age() property of the students:
Optional<Student> oldestStudent = students.stream()
.reduce((s1, s2) -> s1.age() > s2.age() ? s1 : s2);Similarly, the reduction will return an Optional, since there is always the possibility that the stream was empty, to begin with.
Oftentimes, we’ll use the intermediate operation map before reducing the stream. This will allow us, for instance, to reduce the stream to a single integer which represents the age of the youngest student, or reduce it to a String with all the names of the students separated commas:
Optional<Integer> youngestStudentAge = students.stream()
.map(Student::age)
.reduce((age1, age2) -> age1 < age2 ? age1 : age2);
Optional<String> allStudentNames = students.stream()
.map(Student::firstName)
.reduce((name1, name2) -> name1 + "," + name2);
// very similar to:
String allStudentNames2 = students.stream()
.map(Student::firstName)
.collect(Collectors.joining(","));13) Providing a Consumer
I know, I know, there’s an elephant in the room: Let’s finally talk about forEach. This method allows us to pass a lambda expression that will consume each element of the stream and do something with it.
For example, we can use it to print something to the console, send an HTTP request, or call a function from a different object.
students().forEach(s -> sendDeleteRequest(s.id())); students().forEach(s -> System.out.println(s));
I’d like to challenge you to find the differences between the terminal methods we’ve discussed so far and forEach. It’s something that all the other methods and techniques have in common, and forEach doesn’t.
To keep the answer a surprise, I've added an adorable cat image to give you some time to ponder before revealing the answer.
The answer is that forEach is the only one forcing you to introduce side effects. Since it’s not returning anything, it means it needs to do something, to change the state of the application somehow.
Adding forEach as the final operation of your functional pipeline will make it harder to test. In short, whenever we add forEach as our final step, our function will no longer be a pure function.
In other words, if we want to go in this direction of functional programming, we should stick to lazy evaluation, pure functions, and avoid this terminal method. In fact, I have an article about this, if you want to dive deeper into the topic, I encourage you to read through it:
Conclusion
In this article, we’ve discussed all the various ways of consuming a stream in Java. We’ve divided the terminal operations by the type they return and we’ve seen that the main return types are:
- Boolean (anyMatch, allMatch, noneMatch)
- Optional<T> (findFirst, findAny, reduce)
- Collection<T> (toList, toArray, Collectors.to___, custom collector)
- Map<Key, T> or Map<Key, List<T>> (Collectors.toMap, Collectors.groupingBy)
- Void (forEach 😈)
Thank You!
Thanks for reading the article and please let me know what you think! Any feedback is welcome.
If you want to read more about clean code, design, unit testing, object-oriented programming, functional programming, and many others, make sure to check out my other articles. Do you like the content? Consider following or subscribing to the email list.
Finally, if you consider becoming a Medium member and supporting my blog, here’s my referral.
Happy Coding!






