Java 8 Streams API: Writing Clean, Efficient, and Composable Code

PV Prasanth
5 min readAug 27, 2024

--

What is the Java Streams API?

A stream is a sequence of elements that can be processed sequentially or in parallel. The Java Streams API provides a way to process these sequences of elements in a functional style. It can perform a wide variety of operations, such as filtering, mapping, and reducing data, without the need for boilerplate code such as loops and conditionals.

Basic Components of Streams:

Streams are built around three main types of operations:

Source: This is the origin of the data. Streams are typically created from collections like List, Set, or arrays.

Intermediate Operations: These operations return a new stream and are used to transform the data. Examples include filter(), map(), and sorted()

Terminal Operations: These operations produce a result or a side effect, such as forEach(), collect(), or reduce(). Once a terminal operation is executed, the stream can no longer be used.

Creating a Stream:

Streams are created from collections or arrays using the stream() method. For example:

List<String> names = Arrays.asList("John", "Jane", "Jack", "Jill");
Stream<String> nameStream = names.stream();

Example 1: Filtering Data Using Streams

The filter() operation allows you to remove elements from the stream based on a given predicate.

Let’s filter a list of integers to get only the even numbers:

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

List<Integer> evenNumbers = numbers.stream()
.filter(num -> num % 2 == 0) // Keep only even numbers
.collect(Collectors.toList()); // Collect the results into a List

System.out.println(evenNumbers); // Output: [2, 4, 6, 8, 10]

Here, the filter() operation checks each element in the stream and retains only those that match the predicate (num % 2 == 0).

Example 2: Transforming Data with map()

The map() operation allows you to apply a function to each element of the stream and produce a new stream with transformed elements.

Let’s transform a list of strings to uppercase:

List<String> names = Arrays.asList("john", "jane", "jack", "jill");

List<String> upperCaseNames = names.stream()
.map(String::toUpperCase) // Convert each name to uppercase
.collect(Collectors.toList()); // Collect the results into a List

System.out.println(upperCaseNames); // Output: [JOHN, JANE, JACK, JILL]

Example 3: Reducing Data with reduce()

The reduce() operation allows you to reduce the elements of the stream into a single result by repeatedly applying a binary operator.

Let’s calculate the sum of a list of integers:

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);

int sum = numbers.stream()
.reduce(0, Integer::sum); // Sum the elements, with 0 as the initial value

System.out.println(sum); // Output: 15

In this example, reduce() takes two arguments: an initial value (0) and a binary operator (Integer::sum) that adds each element to the accumulated result.

Example 4: Collecting Data with collect()

he collect() operation is a terminal operation that transforms the elements of the stream into a different form, such as a List, Set, or Map.

Let’s collect a list of strings into a single concatenated string, separated by commas:

List<String> names = Arrays.asList("John", "Jane", "Jack", "Jill");

String result = names.stream()
.collect(Collectors.joining(", ")); // Join the strings with ", " as a separator

System.out.println(result); // Output: John, Jane, Jack, Jill

The Streams API allows for:

Declarative Code:

Declarative programming focuses on what you want to achieve, rather than how to achieve it. This is in contrast to imperative programming, where you explicitly define each step.

Example: Filtering even numbers from a list.

Imperative Approach (How):

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6);
List<Integer> evenNumbers = new ArrayList<>();

for (Integer number : numbers) {
if (number % 2 == 0) {
evenNumbers.add(number);
}
}

System.out.println(evenNumbers); // Output: [2, 4, 6]

Here, you specify exactly how the program should execute the logic step-by-step: create a new list, loop through the original list, check if each element is even, and add it to the new list.

Declarative Approach (What):

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6);
List<Integer> evenNumbers = numbers.stream()
.filter(n -> n % 2 == 0)
.collect(Collectors.toList());

System.out.println(evenNumbers); // Output: [2, 4, 6]

In this declarative approach, you specify what you want: filter the even numbers and collect them into a list. You don’t explicitly tell Java how to iterate or perform these operations under the hood.

Efficient Data Processing:

The Streams API is optimized for efficient data processing through lazy evaluation and parallel processing.

Lazy Evaluation

Lazy evaluation means that intermediate operations (like filter() or map()) are not executed until a terminal operation (like forEach() or collect()) is invoked. This can lead to performance optimizations because the stream only processes elements when necessary.

Example: Demonstrating lazy evaluation

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6);

numbers.stream()
.filter(n -> {
System.out.println("Filtering: " + n);
return n % 2 == 0;
})
.map(n -> {
System.out.println("Mapping: " + n);
return n * 2;
})
.collect(Collectors.toList());

Output:

Filtering: 1
Filtering: 2
Mapping: 2
Filtering: 3
Filtering: 4
Mapping: 4
Filtering: 5
Filtering: 6
Mapping: 6

Notice that the stream processes each element through filter() and map() step-by-step, not all at once. The filtering and mapping occur only when needed, based on the terminal operation collect().

Parallel Processing:

Parallel processing allows the stream to split the data into chunks and process them concurrently on multiple CPU cores. This is particularly useful for large datasets.

Example: Summing numbers in parallel

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

int sum = numbers.parallelStream()
.filter(n -> n % 2 == 0) // Process in parallel
.reduce(0, Integer::sum);

System.out.println(sum); // Output: 30

Here, parallelStream() processes the elements concurrently, utilizing multiple CPU cores for potentially faster execution, especially for larger datasets.

Key Takeaways:

  • Streams are sequences of elements that support functional-style operations.
  • You can perform filtering, mapping, and reduction operations with ease.
  • Intermediate operations (like filter(), map()) are lazy and don’t run until a terminal operation (like collect(), forEach()) is invoked.
  • Parallel streams can help improve performance for large datasets.

Conclusion

The Java Streams API is a powerful and flexible tool for writing cleaner and more efficient code. By embracing the functional and declarative style of streams, you can reduce boilerplate code, make your code more readable, and improve performance, especially when dealing with large data sets.

By mastering streams, you will be able to write Java code that is not only concise but also expressive, which will ultimately help you become a more effective and productive Java developer.

Thank You for Reading! 🙏

I appreciate you taking the time to dive into this article. I hope you found the information helpful and gained valuable insights into working with Java. If you enjoyed the read and found it useful, please share it with your friends who might benefit from it as well! 🌟

If you’d like to stay updated with more articles like this, don’t forget to follow and subscribe. And if you enjoyed what you read, a clap would be greatly appreciated! Your support helps me create more content for you. 👍

Thank you once again, and happy coding! 🚀

--

--