Unleashing the Power of Streams in Java: Key Concepts and Code Examples
Introduction
The introduction of the Stream API in Java 8 revolutionized the way developers work with collections. Streams provide a powerful, expressive, and declarative way to process sequences of elements, enabling operations like filtering, mapping, and reducing data with minimal boilerplate code. This article will explore the key concepts behind streams in Java, the differences between various stream operations, and provide practical code examples to help you harness their full potential.
2. Understanding Streams
Streams are not data structures; they do not store elements. Instead, they operate on a source (such as a collection, an array, or I/O channels) to perform aggregate operations. Streams support functional-style operations on sequences of elements and are designed to be both lazy and efficient.
Sequential vs. Parallel Streams: Streams can be processed in sequence or in parallel. Sequential streams process elements one at a time, while parallel streams divide the work across multiple threads.
Intermediate vs. Terminal Operations: Streams are processed through a pipeline of operations. Intermediate operations (e.g.,
filter
,map
,sorted
) transform the stream and are lazy—they are not executed until a terminal operation (e.g.,collect
,forEach
,reduce
) is invoked.Immutability and Statelessness: Stream operations do not modify the underlying data structures; they produce new streams or values as output. Streams are also stateless, meaning each element is processed independently.
Example of Stream Operations
Creating a Stream
Streams can be created from various sources such as collections, arrays, or even from functions. Here’s how to create a stream from a list:
import java.util.Arrays;
import java.util.List;
import java.util.stream.Stream;
public class StreamCreationExample {
public static void main(String[] args) {
// Creating a stream from a list
List<String> names = Arrays.asList("Alice", "Bob", "Charlie", "David", "Edward");
Stream<String> nameStream = names.stream();
// Creating a stream from an array
String[] nameArray = {"Alice", "Bob", "Charlie"};
Stream<String> arrayStream = Arrays.stream(nameArray);
// Creating an infinite stream using Stream.iterate()
Stream<Integer> infiniteStream = Stream.iterate(0, n -> n + 2);
// Print first 5 even numbers
infiniteStream.limit(5).forEach(System.out::println);
}
}
Filtering and Mapping
The filter
and map
operations are two of the most commonly used intermediate stream operations. filter
is used to remove elements that don't match a given predicate, while map
transforms each element in the stream.
Code Example:
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
public class StreamFilterMapExample {
public static void main(String[] args) {
List<String> names = Arrays.asList("Alice", "Bob", "Charlie", "David", "Edward");
// Filter names that start with 'A' and convert them to uppercase
List<String> filteredNames = names.stream()
.filter(name -> name.startsWith("A"))
.map(String::toUpperCase)
.collect(Collectors.toList());
System.out.println(filteredNames); // Output: [ALICE]
}
}
Reducing and Collecting
The reduce
operation combines the elements of a stream into a single result, using an associative accumulation function. collect
is a terminal operation that transforms the elements of a stream into a different form, such as a collection or a string.
Code Example:
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
public class StreamReduceExample {
public static void main(String[] args) {
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
// Sum all numbers in the list using reduce
Optional<Integer> sum = numbers.stream()
.reduce(Integer::sum);
sum.ifPresent(System.out::println); // Output: 15
}
}
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public class StreamCollectExample {
public static void main(String[] args) {
List<String> names = Arrays.asList("Alice", "Bob", "Charlie", "David", "Edward");
// Group names by their length using collect
Map<Integer, List<String>> namesByLength = names.stream()
.collect(Collectors.groupingBy(String::length));
System.out.println(namesByLength);
// Output: {3=[Bob], 5=[Alice, David], 7=[Charlie], 6=[Edward]}
}
}
Parallel Streams
Parallel streams allow you to process elements concurrently, which can improve performance for large datasets. However, parallelism comes with overhead and should be used when appropriate.
Code Example:
import java.util.Arrays;
import java.util.List;
public class ParallelStreamExample {
public static void main(String[] args) {
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
// Calculate the sum of squares using a parallel stream
int sumOfSquares = numbers.parallelStream()
.map(n -> n * n)
.reduce(0, Integer::sum);
System.out.println(sumOfSquares); // Output: 385
}
}
Conclusion
Streams in Java offer a powerful and expressive way to work with sequences of data, allowing you to perform complex operations with concise and readable code. Understanding the key concepts of streams—such as sequential vs. parallel streams, intermediate vs. terminal operations, and immutability—enables you to write efficient, maintainable Java code. By mastering streams, you can unlock new levels of productivity and performance in your Java applications.
Subscribe to my newsletter
Read articles from Muhire Josué directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Muhire Josué
Muhire Josué
I am a backend developer, interested in writing about backend engineering, DevOps and tooling.