Java Stream Introduction

Java 8 introduced a robust and functional programming-oriented feature known as the Stream API. This API allows developers to process collections of data concisely and expressively. Streams provide a high-level abstraction that allows for parallel processing, making writing code that can take advantage of multi-core processors easier.

What is the Stream API?

A stream in Java represents a sequence of elements and supports various operations to perform computations on those elements. The Stream API allows developers to express complex data processing queries concisely without the need for low-level iteration. Streams are not stored in memory; rather, they are generated on demand from different sources such as collections, arrays, I/O channels, and even functions.

1. Stream Creation

There are several ways to create streams in Java, each with its advantages and use cases. Here are some of the most common methods:

  1. Creating a Stream from a Collection: The most common way to make a stream is to use the stream() method of the Collection interface. This method creates a sequential stream of the elements in the collection.
				
					import java.util.Arrays;
import java.util.List;
import java.util.stream.Stream;

public class StreamCreationExample {
    public static void main(String[] args) {
        List<String> list = Arrays.asList("Java", "Python", "C++", "JavaScript");

        // Creating a stream from a list
        Stream<String> streamFromList = list.stream();
        
        // Performing operations on the stream
        streamFromList
            .filter(s -> s.length() > 3)
            .forEach(System.out::println);
    }
}

				
			
In this examplewe start with a List of strings (list) and create the list as a stream using the stream() method. We use the filter operation to keep only the elements with a length greater than 3. Finally, we use forEach to print each element of the filtered stream.

2. Creating a Stream from an Array: You can also create a stream from an array using the Arrays.stream() method of the Stream class. This method takes an array of elements as its argument and creates a stream of those elements.

				
					import java.util.stream.Stream;

public class StreamCreationExample {
    public static void main(String[] args) {
        String[] array = {"Apple", "Banana", "Orange", "Mango"};

        // Creating a stream from an array
        Stream<String> streamFromArray = Arrays.stream(array);
        
        // Performing operations on the stream
        streamFromArray
            .filter(fruit -> fruit.length() > 5)
            .forEach(System.out::println);
    }
}

				
			

In this example: We start with an array of strings (array)and then we convert the array to a stream using the Arrays.stream() method.
We use the filter operation to keep only the elements with a length greater than 5. Finally, we use forEach to print each element of the filtered stream.

3. Create a Stream Using Stream.of(): You can also create a stream from an array using the of() method of the Stream class. This method takes an array of elements as its argument and creates a stream of those elements.

				
					// Create from array of string elements
String[] namesArray = {"Alice", "Bob", "Charlie"};
Stream<String> namesStream = Stream.of(namesArray);

// Create from series of elements
Stream<String> streamOfString = Stream.of("One", "Two", "Three", "Four");
streamOfString.filter(s -> s.length() > 3).forEach(System.out::println);

				
			

4. Creating an Empty Stream: You can create an empty stream using the empty() method of the Stream class. This method is useful when you need to perform operations on an empty collection.

				
					Stream<String> emptyStream = Stream.empty();

				
			

5. Creating a Stream Using Stream.Builder: You can also create a stream using the Stream.Builder class. This class allows you to build a stream incrementally by adding elements to it.

				
					Stream.Builder<String> builder = Stream.builder();
builder.accept("Alice");
builder.accept("Bob");
builder.accept("Charlie");
Stream<String> namesStream = builder.build();

				
			

6. Creating Streams of Primitive Types: Java also provides specialized stream classes for primitive types, such as IntStream, DoubleStream, and LongStream. These classes provide methods for creating streams of primitive values, as well as methods for performing operations on primitive values.

				
					IntStream intStream = IntStream.of(1, 2, 3, 4, 5);
DoubleStream doubleStream = DoubleStream.of(1.0, 2.0, 3.0, 4.0, 5.0);
LongStream longStream = LongStream.of(1L, 2L, 3L, 4L, 5L);

				
			

7. Creating Infinite Streams:  You can create infinite streams using the iterate() and generate() methods of the Stream class. The iterate() method takes an initial value and a function as its arguments and creates an infinite stream of values generated by applying the function to the initial value and its subsequent results.

				
					Stream<Integer> infiniteStream = Stream.iterate(1, n -> n + 1);

				
			

The generate() method takes a supplier as its argument and creates an infinite stream of values generated by calling the supplier.

				
					Stream<String> infiniteStream = Stream.generate(() -> UUID.randomUUID().toString());

				
			

2. Stream Operations

Stream operations are crucial for processing data efficiently, particularly in functional programming. They are categorized into two main types: intermediate and terminal operations.

1. Intermediate Operations:

Intermediate operations are non-terminal operations that transform, filter, or modify stream elements without consumption. These operations are lazy, meaning they don’t execute immediately. Instead, they build a pipeline of operations that waits for a terminal operation to trigger execution. This deferred execution ensures that only necessary operations are performed, optimizing resource use and enhancing performance.

Examples of Intermediate Operations:

  • filter(): Selects elements that satisfy a specific condition.
  • map(): Transforms each element into another form or value.
  • sorted(): Arrange elements in a defined order.

2. Terminal Operations:

Terminal operations are eager and act as the final step in a stream pipeline. They consume the stream, process all elements, and return a concrete result, such as a value, collection, or side effect. Once a terminal operation is invoked, the stream is considered closed, and no further operations can be performed. These operations also trigger the execution of any intermediate operations chained before them.

Examples of Terminal Operations:

  • forEach(): Acts as each element.
  • collect(): Aggregates elements into a collection or another data structure.
  • count(): Returns the total number of elements in the stream.

3. Key Differences Between Intermediate and Terminal Operations:

  • Return Type: Intermediate operations return a new stream, while terminal operations return a non-stream value (such as a list, count, or side effect).
  • Chaining: Intermediate operations can be chained to form a pipeline, enabling complex transformations and filtering in a single flow. Terminal operations, however, mark the end of the pipeline and cannot be chained.
  • Execution: Intermediate operations are lazy and don’t execute until a terminal operation is reached. Terminal operations, conversely, are eager and implement immediately.

4. Laziness and Efficiency:

The lazy execution model of intermediate operations enhances the efficiency of stream processing. Since these operations only execute when a terminal operation is called, unnecessary computations are avoided. This is particularly advantageous when handling large datasets, as only the required elements are processed, minimizing resource consumption and improving performance.

By understanding and leveraging these stream operations, you can build flexible and efficient data processing pipelines that maximize available resources and achieve optimal performance.

3. Common Stream Operations

Some of the most common stream operations include:

     filter(): Filters the stream to include only elements that match a certain predicate.
     map(): Transforms each element of the stream using a mapping function.
     flatMap(): Transforms each element of the stream into a stream, and then flattens the resulting streams into a single stream.
     distinct(): Returns a stream consisting of the distinct elements of the original stream.
     sorted(): Returns a stream sorted according to a comparator.
     count(): Returns the number of elements in the stream.
     reduce(): Reduces the stream to a single value using a reducer function.
     collect(): Collects the elements of the stream into a collection.

4. Examples of Using the Stream API

Here are a few examples of how to use the Stream API to process collections of data:

1. Filtering

Example: Filter a list of numbers to only include even numbers:

				
					        List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
        List<Integer> evenNumbers = numbers.stream()
                .filter(number -> number % 2 == 0)
                .toList();
        System.out.println(evenNumbers); // [2, 4]
				
			
2. Map and Reduce

map() produces a new stream after applying a function to each element of the original stream. The new stream could be of different types.

reduce() method is a reduction operation and it takes a sequence of input elements and combines them into a single summary result by repeated application of a combining operation.

Example: Square each element in a list of numbers and then add them all together:

				
					        List<Integer> numbers= Arrays.asList(1, 2, 3, 4, 5);
        int sumOfSquares = numbers.stream()
                .map(number -> number * number)
                .reduce(0, Integer::sum);
        System.out.println(sumOfSquares); // 55
				
			
3. flatMap

flatMap is used to flatten a nested stream. Given a stream of collections, flatMap will return a stream of the individual elements of those collections. For example, the following code will flatten a stream of lists of strings into a stream of strings:

				
					        List<List<String>> listsOfStrings = Arrays.asList(Arrays.asList("a", "b"), Arrays.asList("c", "d"));
        List<String> strings = listsOfStrings.stream()
                .flatMap(Collection::stream)
                .toList();
        System.out.println(strings); // [a, b, c, d]
				
			
4. Sorting

sorted is used to sort a stream of elements. The stream is sorted according to the natural ordering of the elements, or according to a comparator function if one is provided. For example, the following code will sort a stream of strings alphabetically:

				
					        List<String> stringList = Arrays.asList("d", "c", "b", "a");
        List<String> sortedStrings = stringList.stream()
                .sorted()
                .toList();
        System.out.println(sortedStrings); // [a, b, c, d]
				
			
5. foreach

forEach is used to apply an action to each element of a stream. The action is applied to each element of the stream, but the results are not collected into a new stream. For example, the following code will print each element of a stream of numbers to the console:

				
					        List<Integer> numbersList = Arrays.asList(1, 2, 3, 4, 5);
        numbersList.forEach(System.out::println); // 1 2 3 4 5
				
			

5. Parallel Streams

A parallel stream processes its elements in parallel, utilizing multiple processor cores to improve the overall performance of stream operations. By dividing the stream into smaller chunks and executing them concurrently, parallel streams can significantly reduce the time it takes to process large datasets.

Enabling Parallel Processing: To convert a stream into a parallel stream, you can use the parallel() method. This method is available for all stream interfaces, including Stream, IntStream, LongStream, and DoubleStream. For example, to convert a List<String> to a parallel stream, you can use the following code:

				
					List<String> strings = Arrays.asList("apple", "banana", "orange");
Stream<String> parallelStream = strings.stream().parallel();

				
			
5.1. Benefits of Parallel Streams

Improved Performance: Parallel streams can significantly reduce the processing time for operations on large datasets. This is particularly beneficial for computationally intensive operations.

Efficient Resource Utilization: By utilizing multiple cores, parallel streams can efficiently utilize the processing power of modern multi-core processors.

5.2. Considerations for Using Parallel Streams

While parallel streams offer performance benefits, there are some considerations to keep in mind:

Order of Operations: Unlike sequential streams, where elements are processed in a specific order, parallel streams do not guarantee the order of element processing.

State Changes: Operations that modify the state of shared objects should be avoided in parallel streams, as this can lead to race conditions and unpredictable results.

Data Dependency: Parallel streams are most effective for operations that are independent of each other. If operations rely on intermediate results from previous operations, parallelization may not provide significant performance gains.

5.3. Examples of Using Parallel Streams

Here are a few examples of how to use parallel streams to improve the performance of stream operations:

  1. Filter and Count Words in Parallel:
				
					        List<String> words = Arrays.asList("apple", "banana", "orange", "grape", "strawberry");
        long countOfWordsStartingWithA = words.stream().parallel()
                .filter(word -> word.startsWith("a"))
                .count();
        System.out.println(countOfWordsStartingWithA); // 1
				
			
  1. Calculate the Sum of Squares in Parallel:
				
					        List<Integer> numberList = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
        int sumOfSquare = numberList.stream().parallel()
                .map(number -> number * number)
                .reduce(0, Integer::sum);
        System.out.println(sumOfSquare); // 385
				
			

Conclusion

The Stream API in Java provides a concise and expressive way to process data collections. Its functional programming features enable developers to write cleaner and more readable code. By leveraging streams, you can perform complex operations on data with greater ease and efficiency. As you explore the Stream API further, you’ll discover its versatility and power in simplifying data processing tasks in Java.