Java Streams
Introduction
Java Streams, introduced in Java 8, provide a powerful and expressive way to process collections of data. They allow you to perform complex data processing operations like filtering, mapping, reducing, and sorting with concise, readable code. Streams are particularly useful in Spring applications for efficiently processing data from repositories, APIs, or other sources.
Unlike collections that store elements, streams are pipelines that convey elements from a source through a sequence of operations. They don't modify the original data source and enable a functional programming style in Java.
Stream Basics
Creating Streams
There are several ways to create streams in Java:
// From a collection
List<String> names = Arrays.asList("John", "Sarah", "Mark", "Tanya");
Stream<String> streamFromCollection = names.stream();
// From an array
String[] array = {"Java", "Python", "C++"};
Stream<String> streamFromArray = Arrays.stream(array);
// Using Stream.of() method
Stream<Integer> streamOfNumbers = Stream.of(1, 2, 3, 4, 5);
// Generating infinite streams (with limit)
Stream<Integer> infiniteStream = Stream.iterate(0, n -> n + 2).limit(10); // Even numbers
Stream Operations
Stream operations are divided into two categories:
- Intermediate operations - These return another stream, allowing operations to be chained.
- Terminal operations - These produce a result or a side effect and end the stream.
Common Stream Operations
Filtering Elements
The filter()
method allows you to select elements based on a predicate:
List<String> names = Arrays.asList("John", "Sarah", "Mark", "Tanya", "Steve");
// Filter names that start with 'S'
List<String> sNames = names.stream()
.filter(name -> name.startsWith("S"))
.collect(Collectors.toList());
System.out.println(sNames); // Output: [Sarah, Steve]
Transforming Elements
The map()
method transforms each element using the provided function:
List<String> names = Arrays.asList("John", "Sarah", "Mark");
// Convert all names to uppercase
List<String> uppercaseNames = names.stream()
.map(String::toUpperCase)
.collect(Collectors.toList());
System.out.println(uppercaseNames); // Output: [JOHN, SARAH, MARK]
Sorting Elements
The sorted()
method orders elements based on natural ordering or a custom comparator:
List<String> names = Arrays.asList("John", "Sarah", "Mark", "Tanya");
// Sort names alphabetically
List<String> sortedNames = names.stream()
.sorted()
.collect(Collectors.toList());
System.out.println(sortedNames); // Output: [John, Mark, Sarah, Tanya]
// Sort by length
List<String> sortedByLength = names.stream()
.sorted((s1, s2) -> s1.length() - s2.length())
.collect(Collectors.toList());
System.out.println(sortedByLength); // Output: [John, Mark, Sarah, Tanya]
Finding and Matching
Streams offer several methods to check if elements match certain criteria:
List<String> names = Arrays.asList("John", "Sarah", "Mark", "Tanya");
// Check if ANY name starts with 'J'
boolean anyStartsWithJ = names.stream().anyMatch(name -> name.startsWith("J"));
System.out.println("Any name starts with J: " + anyStartsWithJ); // Output: true
// Check if ALL names have length > 3
boolean allLengthGreaterThan3 = names.stream().allMatch(name -> name.length() > 3);
System.out.println("All names length > 3: " + allLengthGreaterThan3); // Output: false
// Check if NO name has length > 10
boolean noNameLongerThan10 = names.stream().noneMatch(name -> name.length() > 10);
System.out.println("No name longer than 10: " + noNameLongerThan10); // Output: true
Reducing Streams
The reduce()
method combines elements to produce a single result:
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
// Sum all numbers
int sum = numbers.stream()
.reduce(0, (a, b) -> a + b);
System.out.println("Sum: " + sum); // Output: 15
// Alternatively, using Integer::sum
int sum2 = numbers.stream().reduce(0, Integer::sum);
System.out.println("Sum: " + sum2); // Output: 15
// Multiply all numbers
int product = numbers.stream()
.reduce(1, (a, b) -> a * b);
System.out.println("Product: " + product); // Output: 120
Advanced Stream Operations
Collecting Results
The collect()
method is a versatile terminal operation that accumulates elements into collections or other data structures:
List<String> names = Arrays.asList("John", "Sarah", "Mark", "Tanya", "Steve");
// Collect to a List
List<String> nameList = names.stream()
.filter(name -> name.length() > 4)
.collect(Collectors.toList());
System.out.println("Names longer than 4 characters: " + nameList);
// Output: [Sarah, Tanya, Steve]
// Collect to a Set
Set<String> nameSet = names.stream()
.map(String::toUpperCase)
.collect(Collectors.toSet());
System.out.println("Names as a set: " + nameSet);
// Output: [JOHN, SARAH, MARK, TANYA, STEVE] (order not guaranteed)
// Collect to a String
String joinedNames = names.stream()
.collect(Collectors.joining(", "));
System.out.println("Joined names: " + joinedNames);
// Output: John, Sarah, Mark, Tanya, Steve
Grouping and Partitioning
Collectors provide powerful ways to group or partition stream elements:
List<Person> people = Arrays.asList(
new Person("John", 28),
new Person("Sarah", 22),
new Person("Mark", 35),
new Person("Tanya", 28),
new Person("Steve", 22)
);
// Group people by age
Map<Integer, List<Person>> peopleByAge = people.stream()
.collect(Collectors.groupingBy(Person::getAge));
System.out.println("People grouped by age: " + peopleByAge);
// Output: {22=[Person{name=Sarah, age=22}, Person{name=Steve, age=22}],
// 28=[Person{name=John, age=28}, Person{name=Tanya, age=28}],
// 35=[Person{name=Mark, age=35}]}
// Partition people by age > 25
Map<Boolean, List<Person>> partitionedByAge = people.stream()
.collect(Collectors.partitioningBy(person -> person.getAge() > 25));
System.out.println("People older than 25: " + partitionedByAge.get(true));
System.out.println("People 25 or younger: " + partitionedByAge.get(false));
Parallel Streams
Java Streams can process data in parallel to improve performance on multi-core systems:
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
// Sequential stream
long startSequential = System.currentTimeMillis();
long sequentialSum = numbers.stream()
.map(n -> performComplexCalculation(n))
.reduce(0L, Long::sum);
System.out.println("Sequential time: " + (System.currentTimeMillis() - startSequential) + "ms");
System.out.println("Sequential sum: " + sequentialSum);
// Parallel stream
long startParallel = System.currentTimeMillis();
long parallelSum = numbers.parallelStream()
.map(n -> performComplexCalculation(n))
.reduce(0L, Long::sum);
System.out.println("Parallel time: " + (System.currentTimeMillis() - startParallel) + "ms");
System.out.println("Parallel sum: " + parallelSum);
// Helper method that simulates a complex calculation
private static long performComplexCalculation(int n) {
// Simulate a time-consuming operation
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
return n * n;
}
Practical Examples for Spring Applications
Filtering and Processing Repository Data
@Service
public class UserService {
private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public List<UserDTO> getActiveAdminUsers() {
return userRepository.findAll().stream()
.filter(user -> user.isActive() && user.getRoles().contains("ADMIN"))
.map(this::convertToDTO)
.collect(Collectors.toList());
}
public Map<String, List<User>> getUsersByDepartment() {
return userRepository.findAll().stream()
.collect(Collectors.groupingBy(User::getDepartment));
}
public double getAverageAgeOfUsers() {
return userRepository.findAll().stream()
.mapToInt(User::getAge)
.average()
.orElse(0);
}
private UserDTO convertToDTO(User user) {
return new UserDTO(user.getId(), user.getName(), user.getEmail());
}
}
Processing API Responses
@Service
public class ProductService {
private final RestTemplate restTemplate;
public ProductService(RestTemplate restTemplate) {
this.restTemplate = restTemplate;
}
public List<Product> getDiscountedProducts(double minDiscountPercentage) {
ResponseEntity<Product[]> response =
restTemplate.getForEntity("https://api.example.com/products", Product[].class);
if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
return Arrays.stream(response.getBody())
.filter(p -> p.getDiscountPercentage() >= minDiscountPercentage)
.sorted(Comparator.comparing(Product::getPrice))
.collect(Collectors.toList());
}
return Collections.emptyList();
}
public Map<String, List<Product>> getProductsByCategory() {
ResponseEntity<Product[]> response =
restTemplate.getForEntity("https://api.example.com/products", Product[].class);
if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
return Arrays.stream(response.getBody())
.collect(Collectors.groupingBy(Product::getCategory));
}
return Collections.emptyMap();
}
}
Stream Best Practices
-
Prefer method references over lambda expressions when possible for better readability.
java// Less readable
names.stream().map(name -> name.toUpperCase())
// More readable
names.stream().map(String::toUpperCase) -
Use specialized streams for primitive types to avoid boxing/unboxing overhead.
javaIntStream.range(1, 100) // Better than Stream.iterate(1, i -> i + 1).limit(99)
.filter(n -> n % 2 == 0)
.sum(); -
Be cautious with parallel streams:
- Use them only for CPU-intensive operations with large datasets
- Ensure your operations are stateless and associative
- Be aware of the threading overhead
-
Don't overuse streams for simple operations where traditional loops would be clearer.
-
Avoid side-effects in stream operations to maintain their functional nature.
Summary
Java Streams provide a powerful, declarative approach to processing collections of data. They enable you to write more concise and expressive code by supporting a functional programming style. Key benefits include:
- Concise syntax for data manipulation operations
- Built-in support for filtering, mapping, reducing and other common operations
- Ability to chain operations for complex data processing
- Support for parallel execution to improve performance
In Spring applications, streams are particularly useful for processing data from repositories, handling API responses, and transforming data between different representations.
Exercise Ideas
-
User Data Processing: Create a stream pipeline that filters a list of users by age, transforms them to DTOs, and sorts them by name.
-
File Analysis: Read a text file, use streams to count occurrences of each word, and find the top 5 most common words.
-
Transaction Summary: Process a list of financial transactions, grouping them by category, and calculate the total amount for each category.
-
API Integration: Use streams to process and transform data coming from an external API to match your application's domain model.
-
Performance Comparison: Compare the performance of sequential vs. parallel streams for a computationally intensive task.
Additional Resources
- Java Documentation on Stream API
- Oracle's Java Tutorial on Streams
- Baeldung's Guide to Java 8 Streams
- Book: "Java 8 in Action" by Raoul-Gabriel Urma, Mario Fusco, and Alan Mycroft
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)