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.
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.
There are several ways to create streams in Java, each with its advantages and use cases. Here are some of the most common methods:
import java.util.Arrays;
import java.util.List;
import java.util.stream.Stream;
public class StreamCreationExample {
public static void main(String[] args) {
List list = Arrays.asList("Java", "Python", "C++", "JavaScript");
// Creating a stream from a list
Stream streamFromList = list.stream();
// Performing operations on the stream
streamFromList
.filter(s -> s.length() > 3)
.forEach(System.out::println);
}
}
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 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 namesStream = Stream.of(namesArray);
// Create from series of elements
Stream 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 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 builder = Stream.builder();
builder.accept("Alice");
builder.accept("Bob");
builder.accept("Charlie");
Stream 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 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 infiniteStream = Stream.generate(() -> UUID.randomUUID().toString());
Stream operations are crucial for processing data efficiently, particularly in functional programming. They are categorized into two main types: intermediate and terminal 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.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.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.
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.
Here are a few examples of how to use the Stream API to process collections of data:
Example: Filter a list of numbers to only include even numbers:
List numbers = Arrays.asList(1, 2, 3, 4, 5);
List evenNumbers = numbers.stream()
.filter(number -> number % 2 == 0)
.toList();
System.out.println(evenNumbers); // [2, 4]
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 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
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> listsOfStrings = Arrays.asList(Arrays.asList("a", "b"), Arrays.asList("c", "d"));
List strings = listsOfStrings.stream()
.flatMap(Collection::stream)
.toList();
System.out.println(strings); // [a, b, c, d]
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 stringList = Arrays.asList("d", "c", "b", "a");
List sortedStrings = stringList.stream()
.sorted()
.toList();
System.out.println(sortedStrings); // [a, b, c, d]
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 numbersList = Arrays.asList(1, 2, 3, 4, 5);
numbersList.forEach(System.out::println); // 1 2 3 4 5
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 strings = Arrays.asList("apple", "banana", "orange");
Stream parallelStream = strings.stream().parallel();
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.
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.
Here are a few examples of how to use parallel streams to improve the performance of stream operations:
List words = Arrays.asList("apple", "banana", "orange", "grape", "strawberry");
long countOfWordsStartingWithA = words.stream().parallel()
.filter(word -> word.startsWith("a"))
.count();
System.out.println(countOfWordsStartingWithA); // 1
List 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
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.