Skip to main content

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:

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:

  1. Intermediate operations - These return another stream, allowing operations to be chained.
  2. 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:

java
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:

java
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:

java
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:

java
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:

java
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:

java
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:

java
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:

java
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

java
@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

java
@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

  1. 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)
  2. Use specialized streams for primitive types to avoid boxing/unboxing overhead.

    java
    IntStream.range(1, 100)                  // Better than Stream.iterate(1, i -> i + 1).limit(99)
    .filter(n -> n % 2 == 0)
    .sum();
  3. 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
  4. Don't overuse streams for simple operations where traditional loops would be clearer.

  5. 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

  1. User Data Processing: Create a stream pipeline that filters a list of users by age, transforms them to DTOs, and sorts them by name.

  2. File Analysis: Read a text file, use streams to count occurrences of each word, and find the top 5 most common words.

  3. Transaction Summary: Process a list of financial transactions, grouping them by category, and calculate the total amount for each category.

  4. API Integration: Use streams to process and transform data coming from an external API to match your application's domain model.

  5. Performance Comparison: Compare the performance of sequential vs. parallel streams for a computationally intensive task.

Additional Resources



If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)