Java Made Easy: Stream API in Java

The Stream API is a powerful tool that enables you to process collections of data functionally and concisely.
Throughout this topic, we will explore the various features and functionalities of the Stream API, including filtering, mapping, and reducing.

Image by StockSnap from Pixabay

Understanding the basics of Streams

Streams are a key concept in the Stream API.
Streams represent a sequence of elements that can be processed in parallel or sequentially. They are wrappers around a data source, allowing us to operate with that data source and making bulk processing convenient and fast.

By using Streams, you can perform various operations on collections of data without the need for explicit loops or mutation of the original data. Streams should not be confused with Java I/O streams (ex: FileInputStream etc); these have very little to do with each other.

A stream does not store data and, in that sense, is not a data structure. It also never modifies the underlying data source.

Core Components of a Stream

To get started with streams, you first need to understand the core components.
A stream consists of:

  • a source, which can be any Java Collection or an array,
  • intermediate operations, which are optional and can be used to transform or filter the stream,
  • a terminal operation that is responsible for producing a result or a side-effect.
Common Operations with Stream API

The Stream API provides a rich set of operations that allow you to manipulate and process data efficiently. Some of the common operations include filtering, mapping, and reducing. Let’s take a closer look at each of these operations.

Filter: This operation allows you to select elements from a stream, based on a specific condition.
For example, if you have a stream of integers and you want to select only the even numbers, you can use the ‘filter’ operation with a lambda expression to achieve this.

Map: This operation is used to transform each element in a stream into another element. This is useful when you want to convert the data to a different type or perform some calculations on the elements.
For instance, consider you have a stream of strings representing numbers. If you want to convert them to integers, you can use the ‘map’ operation along with the ‘Integer.parseInt’ method.

Reduce: This operation allows you to combine the elements of a stream into a single result. This is useful when you want to calculate the sum, product, or any other aggregation of the elements.
For example, if you have a stream of integers and you want to calculate the sum of all the elements, you can use the ‘reduce’ operation with the ‘Integer::sum’ method reference.

The intermediate and terminal operations

In the Stream API, operations are categorized into two types: intermediate and terminal operations.

Intermediate operations are operations that can be chained together to form a pipeline of operations. They do not produce a result immediately but instead, return a new stream that can be further processed. Examples of intermediate operations include ‘filter’, ‘map’, and ‘distinct’.

Terminal operations are operations that produce a result or a side-effect. They are the final operation in a stream pipeline and cannot be followed by any other operation. Examples of terminal operations include ‘forEach’, ‘collect’, and ‘reduce’.

It is important to note that intermediate operations are lazy, which means they are only executed when a terminal operation is invoked. This lazy evaluation allows for better performance and optimization in stream processing. However, it also means that you must be careful when working with side effects, as they may not be executed without terminal operation.

Example 1: Use Stream API to filter data

One of the most common use cases of Stream API is filtering and sorting data. With the ‘filter’ operation, you can easily select elements from a stream based on certain criteria. For example, if you have a list of objects and you want to filter out the objects that meet a specific condition, you can use the ‘filter’ operation along with a lambda expression.

Sorting data is also straightforward with the Stream API. You can use the ‘sorted’ operation to sort the elements of a stream in natural order or provide a custom comparator for sorting. This is useful when you want to sort a list of objects based on a specific property or attribute.

By combining these operations, you can efficiently filter and sort data in a concise and readable manner. This makes it easier to work with large datasets and perform complex data manipulation tasks.

Problem: Write a Java program to count the number of strings in a list that start with a specific letter using streams.
In the solution, we call the filter() method to filter out only strings that start with the specified letter. Then, we use the count() method to get the count of those filtered strings. Finally, the original list and the count of strings starting with the specific letter are printed on the console.

import java.util.Arrays;
import java.util.List;

public class StreamExampleFilter {
  public static void main(String[] args) {
    List<String> colors = Arrays.asList("Red", "Green", "Blue", "Pink", "Brown");
    System.out.println("Original list of strings (colors): " + colors);
    char startingLetter = 'B';
    long ctr = colors.stream()
      .filter(s -> s.startsWith(String.valueOf(startingLetter)))
      .count();
    System.out.println("Number of colors starting with '" +   startingLetter + "': " + ctr);
    char startingLetter1 = 'Y';
    ctr = colors.stream()
      .filter(s -> s.startsWith(String.valueOf(startingLetter1)))
      .count();
    System.out.println("Number of colors starting with '" + startingLetter1 + "': " + ctr);
  }
}

In Console:

Original list of strings (colors): [Red, Green, Blue, Pink, Brown]
Number of colors starting with 'B': 2
Number of colors starting with 'Y': 0

In the above exercise, we have a list of strings (“Red”, “Green”, “Blue”, “Pink”, “Brown”). We want to count the number of strings that start with a specific letter, which is defined by the startingLetter variable (in this case, ‘a’).

Example 2: Use Stream API to sort data

Problem: Write a Java program to sort a list of strings alphabetically, ascending and descending using streams.

Using streams, we call the sorted() method without passing any comparator to sort the strings in ascending order based on their natural order. We collect the sorted strings into a new list using the toList() method.

import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Collectors;

public class StringExampleSort {
 public static void main(String[] args) {
  List < String > colors = Arrays.asList("Red", "Green", "Blue", "Pink", "Brown");
  System.out.println("Original List of strings(colors): " + colors);
  List<String> ascendingOrder = colors.stream()
   .sorted()
   .collect(Collectors.toList());
  List<String> descendingOrder = colors.stream()
   .sorted(Comparator.reverseOrder())
   .collect(Collectors.toList());

  System.out.println("Sorted in Ascending Order: " + ascendingOrder);
  System.out.println("Sorted in Descending Order: " + descendingOrder);
 }
}

In Console

Original List of strings(colors): [Red, Green, Blue, Pink, Brown]
Sorted in Ascending Order: [Blue, Brown, Green, Pink, Red]
Sorted in Descending Order: [Red, Pink, Green, Brown, Blue]

To sort the strings in descending order, we pass Comparator.reverseOrder() as the comparator to the sorted() method. This reverses the natural order and sorts the strings in descending order.

Problem: Write a Java program to find the second smallest and largest elements in a list of integers using streams.

We create a list of integers (numbers) and use streams to find the second smallest element by applying distinct, sorting the elements in ascending order, skipping the first element, and locating the first element. Also, we find the second largest element by sorting the elements in descending order.

import java.util.Arrays;
import java.util.List;

public class SecondLargestSmallest {
 public static void main(String[] args) {
  List<Integer> nums = Arrays.asList(1, 17, 54, 14, 33, 45, -11);
  System.out.println("List of numbers: " + nums);
  Integer secondSmallest = nums.stream()
   .distinct()
   .sorted()
   .skip(1)
   .findFirst()
   .orElse(null);
  Integer secondLargest = nums.stream()
   .distinct()
   .sorted((a, b) -> Integer.compare(b, a))
   .skip(1)
   .findFirst()
   .orElse(null);

  System.out.println("Second smallest element: " + secondSmallest);
  System.out.println("Second largest element: " + secondLargest);
 }
}

In Console

List of numbers: [1, 17, 54, 14, 33, 45, -11]
Second smallest element: 1
Second largest element: 45

Notice, that the orElse method handles the case when the list is empty or there are no second smallest/largest elements.

Stream API and parallel processing

One of the major advantages of the Stream API is the support for parallel processing. With parallel streams, you can use multi-core processors and perform operations on large datasets in parallel. In this way, you will improve the performance of the application.

To create a parallel stream, you simply need to call the ‘parallel’ method on a stream. This will enable parallel execution of operations, splitting the data into multiple chunks and processing them concurrently. However, it is important to note that not all operations are suitable for parallel execution, especially operations that have side effects or rely on the order of elements.

When working with parallel streams, it is also important to consider thread safety and synchronization. Since multiple threads may be accessing and modifying the data concurrently, you need to ensure that your code is thread-safe to avoid race conditions and other concurrency issues.

Best practices for using Stream API

To make the most out of the Stream API, here are some best practices to keep in mind:

Use lazy evaluation: Take advantage of the lazy evaluation feature of streams to optimize performance and reduce unnecessary computations. Only perform operations that are necessary to produce the desired result.

Avoid side effects: Side effects, such as modifying the external state or printing to the console, can make your code harder to reason about and debug. Try to minimize or eliminate side effects when using streams.

Use method references and lambda expressions: Method references and lambda expressions can make your code more concise and readable. Take advantage of these features to express your intentions clearly.

Reuse streams: In some cases, it may be more efficient to reuse a stream instead of creating a new one. Reusing streams can help reduce the overhead of creating new objects and improve performance.

Consider performance implications: While the Stream API provides a convenient and expressive way to process data, it is important to consider the performance implications of your code. Be mindful of the data size, complexity of operations, and the need for parallel processing.

By following these best practices, you can write clean, efficient, and maintainable code using the Stream API.

Advanced Concepts in Stream API

The Stream API offers a wide range of advanced concepts and features that can further enhance your coding experience. Some of these advanced concepts include:

Collectors: Collectors are used to accumulate elements from a stream into a collection or a single value. They provide a convenient way to perform complex aggregations and transformations on the elements of a stream. Examples of collectors include ‘toList’, ‘toSet’, ‘toMap’, and ‘joining’.

Optional: Optional is a container object that may or may not contain a non-null value. It is used to avoid null pointer exceptions and provide a clearer and safer way of handling potentially absent values. Optional can be used with the Stream API to handle empty streams or find the first or any element that matches a specific condition.

Parallel processing: As mentioned earlier, the Stream API supports parallel processing, allowing you to take advantage of multi-core processors. However, parallel processing introduces additional considerations and challenges like thread safety and synchronization. Understanding these concepts is crucial when working with parallel streams.

These advanced concepts provide powerful tools for handling complex scenarios and enable you to write more efficient and expressive code using the Stream API.

Resources for learning and mastering Stream API

If you want to dive deeper into the Stream API and become a true master, there are plenty of resources available to help you on your journey. Here are some recommended resources:
📑 Official Java documentation: The official Java documentation provides detailed information about the Stream API, including examples and best practices. It is a great starting point for understanding the concepts and features of the Stream API.
🎙 Online tutorials and courses: Many online tutorials and courses cover the Stream API in depth. Websites like Udemy, Coursera, and Pluralsight offer comprehensive courses that can help you master the Stream API.
📚 Books: Several books are available that focus specifically on the Stream API in Java. Some recommended titles include “Java 8 in Action” by Raoul-Gabriel Urma, Mario Fusco, and Alan Mycroft, and “Functional Programming in Java” by Venkat Subramaniam.
👥 Community forums and discussion groups: Engaging with the Java community can be a great way to learn and exchange ideas about the Stream API. Websites like Stack Overflow and Reddit have dedicated sections for Java programming where you can ask questions and get answers from experienced developers.

By exploring these resources and actively practicing your skills, you can become proficient in using the Stream API and unlock its full potential.

Conclusions

The Stream API is a powerful tool that allows you to process collections of data functionally and concisely. By leveraging the features and functionalities of the Stream API, you can write clean, efficient, and readable code that takes your Java programming skills to new heights.
Whether you are a beginner or a seasoned Java developer, the Stream API offers a wealth of possibilities. So, don’t hesitate to dive into the world of functional programming with the Stream API and embark on your journey from a beginner to a pro in Java.