Java Streams API: A Practical Tutorial

The Java Streams API, introduced in Java 8, is a powerful addition to the Java programming language. It provides a high - level and declarative way to process collections of data. Streams allow developers to perform complex operations on data sources such as lists, sets, and arrays in a concise and efficient manner. By using the Streams API, you can write more readable and maintainable code, especially when dealing with data filtering, mapping, and reduction operations. This tutorial will guide you through the fundamental concepts, usage methods, common practices, and best practices of the Java Streams API.

Table of Contents

  1. Fundamental Concepts
    • What are Streams?
    • Stream Sources
    • Intermediate and Terminal Operations
  2. Usage Methods
    • Creating Streams
    • Intermediate Operations
    • Terminal Operations
  3. Common Practices
    • Filtering Data
    • Mapping Data
    • Reducing Data
    • Sorting Data
  4. Best Practices
    • Lazy Evaluation
    • Parallel Streams
    • Avoiding Side - Effects
  5. Conclusion
  6. References

Fundamental Concepts

What are Streams?

A stream in Java is a sequence of elements from a source that supports various aggregate operations. It is not a data structure itself but rather a way to process data from existing data structures like collections or arrays. Streams allow you to perform operations on the elements in a declarative way, similar to SQL queries on a database.

Stream Sources

Streams can be created from different sources, including:

  • Collections: You can create a stream from any Collection implementation such as List, Set, etc.
  • Arrays: Arrays can also be used as a source to create a stream.
  • Files: You can read the lines of a file as a stream.

Intermediate and Terminal Operations

  • Intermediate Operations: These operations are lazy and return a new stream. They are used to transform or filter the elements of the stream. Examples include filter(), map(), and sorted().
  • Terminal Operations: These operations are eager and produce a result or a side - effect. Once a terminal operation is called on a stream, the stream is consumed and cannot be used again. Examples include forEach(), collect(), and reduce().

Usage Methods

Creating Streams

Here are some common ways to create streams:

From a Collection

import java.util.ArrayList;
import java.util.List;
import java.util.stream.Stream;

public class StreamCreationFromCollection {
    public static void main(String[] args) {
        List<String> list = new ArrayList<>();
        list.add("apple");
        list.add("banana");
        list.add("cherry");

        Stream<String> stream = list.stream();
    }
}

From an Array

import java.util.stream.Stream;

public class StreamCreationFromArray {
    public static void main(String[] args) {
        String[] array = {"apple", "banana", "cherry"};
        Stream<String> stream = Stream.of(array);
    }
}

Intermediate Operations

Filtering

The filter() method is used to select elements that match a given predicate.

import java.util.Arrays;
import java.util.List;
import java.util.stream.Stream;

public class FilterExample {
    public static void main(String[] args) {
        List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6);
        Stream<Integer> filteredStream = numbers.stream().filter(n -> n % 2 == 0);
        filteredStream.forEach(System.out::println);
    }
}

Mapping

The map() method is used to transform each element of the stream using a given function.

import java.util.Arrays;
import java.util.List;

public class MapExample {
    public static void main(String[] args) {
        List<Integer> numbers = Arrays.asList(1, 2, 3);
        numbers.stream().map(n -> n * 2).forEach(System.out::println);
    }
}

Terminal Operations

Collecting

The collect() method is used to accumulate the elements of the stream into a collection or other data structures.

import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

public class CollectExample {
    public static void main(String[] args) {
        List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
        List<Integer> evenNumbers = numbers.stream()
                .filter(n -> n % 2 == 0)
                .collect(Collectors.toList());
        System.out.println(evenNumbers);
    }
}

Reducing

The reduce() method is used to combine the elements of the stream into a single value.

import java.util.Arrays;
import java.util.List;
import java.util.Optional;

public class ReduceExample {
    public static void main(String[] args) {
        List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
        Optional<Integer> sum = numbers.stream().reduce((a, b) -> a + b);
        sum.ifPresent(System.out::println);
    }
}

Common Practices

Filtering Data

Filtering is a common operation when working with streams. You can use the filter() method to select elements based on a certain condition. For example, to filter a list of employees based on their age:

import java.util.ArrayList;
import java.util.List;

class Employee {
    private int age;

    public Employee(int age) {
        this.age = age;
    }

    public int getAge() {
        return age;
    }
}

public class EmployeeFiltering {
    public static void main(String[] args) {
        List<Employee> employees = new ArrayList<>();
        employees.add(new Employee(25));
        employees.add(new Employee(30));
        employees.add(new Employee(40));

        List<Employee> seniorEmployees = employees.stream()
                .filter(e -> e.getAge() > 30)
                .collect(java.util.stream.Collectors.toList());
        System.out.println(seniorEmployees.size());
    }
}

Mapping Data

Mapping is useful when you need to transform the elements of a stream. For example, to convert a list of strings to a list of their lengths:

import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

public class StringLengthMapping {
    public static void main(String[] args) {
        List<String> words = Arrays.asList("apple", "banana", "cherry");
        List<Integer> lengths = words.stream()
                .map(String::length)
                .collect(Collectors.toList());
        System.out.println(lengths);
    }
}

Reducing Data

Reducing is used to combine the elements of a stream into a single value. For example, to find the sum of all elements in a list:

import java.util.Arrays;
import java.util.List;
import java.util.Optional;

public class SumReduction {
    public static void main(String[] args) {
        List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
        Optional<Integer> sum = numbers.stream().reduce((a, b) -> a + b);
        sum.ifPresent(System.out::println);
    }
}

Sorting Data

The sorted() method is used to sort the elements of a stream.

import java.util.Arrays;
import java.util.List;

public class SortingExample {
    public static void main(String[] args) {
        List<Integer> numbers = Arrays.asList(5, 3, 1, 4, 2);
        numbers.stream().sorted().forEach(System.out::println);
    }
}

Best Practices

Lazy Evaluation

Intermediate operations in streams are lazily evaluated. This means that they are not executed until a terminal operation is called. This can lead to significant performance improvements, especially when dealing with large data sets. For example, if you have a long stream and you apply multiple intermediate operations, the operations are combined and executed only when a terminal operation is invoked.

Parallel Streams

Java Streams API supports parallel processing through parallel streams. You can convert a sequential stream to a parallel stream using the parallel() method. Parallel streams can significantly speed up the processing of large data sets by distributing the work across multiple threads. However, you need to be careful when using parallel streams as they have some overhead and may not always result in better performance, especially for small data sets.

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);
        int sum = numbers.parallelStream().mapToInt(Integer::intValue).sum();
        System.out.println(sum);
    }
}

Avoiding Side - Effects

It is recommended to avoid side - effects in stream operations. Side - effects can make the code harder to understand and debug. For example, modifying a shared variable inside a forEach() method is a side - effect and should be avoided. Instead, use pure functions that do not modify external state.

Conclusion

The Java Streams API is a powerful tool for processing collections of data in a declarative and efficient way. By understanding the fundamental concepts, usage methods, common practices, and best practices, you can write more concise, readable, and maintainable code. Whether you are working with small or large data sets, the Streams API provides a flexible and scalable solution for data processing.

References