6.2 Stream API

The Stream API, introduced in Java 8 and enhanced in subsequent versions, provides a functional approach to processing sequences of elements. Streams enable declarative, composable operations on data while abstracting away iteration details.

What is a Stream?

A stream is not a data structure—it's a pipeline of operations on a data source. Key characteristics:

  1. No storage: Streams don't store elements
  2. Functional: Operations produce results without modifying the source
  3. Lazy: Intermediate operations are evaluated only when needed
  4. Possibly unbounded: Can represent infinite sequences
  5. Consumable: Can be used only once
List<String> names = List.of("Alice", "Bob", "Charlie", "David");

// Create a stream and process it
List<String> shortNames = names.stream()
    .filter(name -> name.length() <= 5)
    .map(String::toUpperCase)
    .toList();

System.out.println(shortNames); // [ALICE, BOB, DAVID]

Creating Streams

From Collections:

List<String> list = List.of("a", "b", "c");
Stream<String> stream = list.stream();

Set<Integer> set = Set.of(1, 2, 3);
Stream<Integer> setStream = set.stream();

From Arrays:

String[] array = {"x", "y", "z"};
Stream<String> arrayStream = Arrays.stream(array);
Stream<String> partialStream = Arrays.stream(array, 1, 3); // [y, z]

From Values:

Stream<String> streamOf = Stream.of("one", "two", "three");
Stream<String> empty = Stream.empty();

From Generators:

// Infinite stream using iterate
Stream<Integer> evens = Stream.iterate(0, n -> n + 2);
// Must limit: evens.limit(5).toList() -> [0, 2, 4, 6, 8]

// Infinite stream using generate
Stream<Double> randoms = Stream.generate(Math::random);
// randoms.limit(3).toList()

// Bounded iterate (Java 9+)
Stream<Integer> bounded = Stream.iterate(0, n -> n < 10, n -> n + 1);
// [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

From Files:

try (Stream<String> lines = Files.lines(Path.of("data.txt"))) {
    lines.filter(line -> !line.isBlank())
         .forEach(System.out::println);
}

From Ranges:

IntStream.range(0, 5).forEach(System.out::println); // 0, 1, 2, 3, 4
IntStream.rangeClosed(1, 5).forEach(System.out::println); // 1, 2, 3, 4, 5

Intermediate Operations

Intermediate operations return a new stream and are lazy—they're not executed until a terminal operation is invoked.

Filter: Remove elements that don't match a predicate

List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6, 7, 8);

List<Integer> evens = numbers.stream()
    .filter(n -> n % 2 == 0)
    .toList(); // [2, 4, 6, 8]

Map: Transform each element

List<String> names = List.of("alice", "bob", "charlie");

List<String> upper = names.stream()
    .map(String::toUpperCase)
    .toList(); // [ALICE, BOB, CHARLIE]

List<Integer> lengths = names.stream()
    .map(String::length)
    .toList(); // [5, 3, 7]

FlatMap: Transform and flatten

List<List<String>> nested = List.of(
    List.of("a", "b"),
    List.of("c", "d"),
    List.of("e")
);

List<String> flattened = nested.stream()
    .flatMap(List::stream)
    .toList(); // [a, b, c, d, e]

// Practical example: get all tags from articles
record Article(String title, List<String> tags) {}

List<Article> articles = List.of(
    new Article("Java Streams", List.of("java", "streams")),
    new Article("Spring Boot", List.of("java", "spring"))
);

List<String> allTags = articles.stream()
    .flatMap(article -> article.tags().stream())
    .distinct()
    .toList(); // [java, streams, spring]

MapMulti: Conditional flattening (Java 16+)

// More efficient than flatMap for conditional expansion
List<Integer> expanded = List.of(1, 2, 3, 4).stream()
    .<Integer>mapMulti((n, consumer) -> {
        consumer.accept(n);
        if (n % 2 == 0) {
            consumer.accept(n * 10);
        }
    })
    .toList(); // [1, 2, 20, 3, 4, 40]

Distinct: Remove duplicates

List<Integer> numbers = List.of(1, 2, 2, 3, 3, 3, 4);
List<Integer> unique = numbers.stream()
    .distinct()
    .toList(); // [1, 2, 3, 4]

Sorted: Order elements

List<String> names = List.of("Charlie", "Alice", "Bob");

// Natural order
List<String> sorted = names.stream()
    .sorted()
    .toList(); // [Alice, Bob, Charlie]

// Custom comparator
List<String> byLength = names.stream()
    .sorted(Comparator.comparingInt(String::length))
    .toList(); // [Bob, Alice, Charlie]

// Reverse order
List<String> reversed = names.stream()
    .sorted(Comparator.reverseOrder())
    .toList(); // [Charlie, Bob, Alice]

Peek: Perform action without changing elements (mainly for debugging)

List<String> result = names.stream()
    .peek(name -> System.out.println("Original: " + name))
    .map(String::toUpperCase)
    .peek(name -> System.out.println("Uppercase: " + name))
    .toList();

Limit and Skip: Control stream size

List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6, 7, 8);

List<Integer> first3 = numbers.stream()
    .limit(3)
    .toList(); // [1, 2, 3]

List<Integer> skip3 = numbers.stream()
    .skip(3)
    .toList(); // [4, 5, 6, 7, 8]

// Pagination
int page = 2;
int pageSize = 3;
List<Integer> pageData = numbers.stream()
    .skip((long) (page - 1) * pageSize)
    .limit(pageSize)
    .toList(); // [4, 5, 6]

TakeWhile and DropWhile: Conditional limit/skip (Java 9+)

List<Integer> numbers = List.of(1, 2, 3, 4, 5, 1, 2);

// Take elements while condition is true
List<Integer> taken = numbers.stream()
    .takeWhile(n -> n < 4)
    .toList(); // [1, 2, 3]

// Drop elements while condition is true
List<Integer> dropped = numbers.stream()
    .dropWhile(n -> n < 4)
    .toList(); // [4, 5, 1, 2]

Terminal Operations

Terminal operations trigger stream processing and produce a result.

forEach: Process each element

List<String> names = List.of("Alice", "Bob");
names.stream().forEach(System.out::println);

// ForEachOrdered: maintains order for parallel streams
names.parallelStream().forEachOrdered(System.out::println);

toList: Collect to immutable list (Java 16+)

List<String> result = stream.toList();
// Equivalent to: stream.collect(Collectors.toUnmodifiableList())

toArray: Collect to array

String[] array = stream.toArray(String[]::new);
Object[] objects = stream.toArray();

reduce: Combine elements

List<Integer> numbers = List.of(1, 2, 3, 4, 5);

// Sum
int sum = numbers.stream()
    .reduce(0, Integer::sum); // 15

// Product
int product = numbers.stream()
    .reduce(1, (a, b) -> a * b); // 120

// Max
Optional<Integer> max = numbers.stream()
    .reduce(Integer::max); // Optional[5]

// String concatenation
String concatenated = List.of("a", "b", "c").stream()
    .reduce("", (a, b) -> a + b); // "abc"

collect: Accumulate into collections or custom results

List<String> list = stream.collect(Collectors.toList());
Set<String> set = stream.collect(Collectors.toSet());
String joined = stream.collect(Collectors.joining(", "));

count: Count elements

long count = stream.filter(n -> n > 5).count();

anyMatch, allMatch, noneMatch: Test predicates

List<Integer> numbers = List.of(1, 2, 3, 4, 5);

boolean hasEven = numbers.stream().anyMatch(n -> n % 2 == 0); // true
boolean allPositive = numbers.stream().allMatch(n -> n > 0); // true
boolean noneNegative = numbers.stream().noneMatch(n -> n < 0); // true

findFirst, findAny: Get elements

Optional<String> first = names.stream().findFirst();
Optional<String> any = names.stream().findAny();

min, max: Find extremes

List<Integer> numbers = List.of(3, 1, 4, 1, 5, 9);

Optional<Integer> min = numbers.stream().min(Integer::compareTo); // 1
Optional<Integer> max = numbers.stream().max(Integer::compareTo); // 9

// With custom comparator
record Person(String name, int age) {}
List<Person> people = List.of(
    new Person("Alice", 30),
    new Person("Bob", 25)
);

Optional<Person> youngest = people.stream()
    .min(Comparator.comparingInt(Person::age)); // Bob

Real-World Example: Order Processing

import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.*;
import java.util.stream.Collectors;

record Order(
    String id,
    String customerId,
    LocalDate date,
    List<OrderItem> items,
    String status
) {
    BigDecimal total() {
        return items.stream()
            .map(OrderItem::lineTotal)
            .reduce(BigDecimal.ZERO, BigDecimal::add);
    }
}

record OrderItem(String productId, int quantity, BigDecimal price) {
    BigDecimal lineTotal() {
        return price.multiply(BigDecimal.valueOf(quantity));
    }
}

class OrderAnalytics {
    private final List<Order> orders;

    public OrderAnalytics(List<Order> orders) {
        this.orders = orders;
    }

    // Find high-value orders (>= $1000)
    public List<Order> getHighValueOrders() {
        return orders.stream()
            .filter(order -> order.total()
                .compareTo(BigDecimal.valueOf(1000)) >= 0)
            .toList();
    }

    // Get orders by date range
    public List<Order> getOrdersByDateRange(LocalDate start, LocalDate end) {
        return orders.stream()
            .filter(order -> !order.date().isBefore(start))
            .filter(order -> !order.date().isAfter(end))
            .sorted(Comparator.comparing(Order::date))
            .toList();
    }

    // Calculate total revenue
    public BigDecimal getTotalRevenue() {
        return orders.stream()
            .filter(order -> order.status().equals("completed"))
            .map(Order::total)
            .reduce(BigDecimal.ZERO, BigDecimal::add);
    }

    // Get top customers by order count
    public List<Map.Entry<String, Long>> getTopCustomers(int limit) {
        return orders.stream()
            .collect(Collectors.groupingBy(
                Order::customerId,
                Collectors.counting()
            ))
            .entrySet().stream()
            .sorted(Map.Entry.<String, Long>comparingByValue().reversed())
            .limit(limit)
            .toList();
    }

    // Get most popular products
    public List<Map.Entry<String, Integer>> getMostPopularProducts(int limit) {
        return orders.stream()
            .flatMap(order -> order.items().stream())
            .collect(Collectors.groupingBy(
                OrderItem::productId,
                Collectors.summingInt(OrderItem::quantity)
            ))
            .entrySet().stream()
            .sorted(Map.Entry.<String, Integer>comparingByValue().reversed())
            .limit(limit)
            .toList();
    }

    // Calculate average order value
    public BigDecimal getAverageOrderValue() {
        return orders.stream()
            .map(Order::total)
            .reduce(BigDecimal.ZERO, BigDecimal::add)
            .divide(
                BigDecimal.valueOf(orders.size()),
                2,
                BigDecimal.ROUND_HALF_UP
            );
    }
}

// Usage example
void demonstrateOrderAnalytics() {
    List<Order> orders = List.of(
        new Order(
            "ORD001",
            "CUST001",
            LocalDate.of(2025, 1, 1),
            List.of(
                new OrderItem("PROD001", 2, BigDecimal.valueOf(50)),
                new OrderItem("PROD002", 1, BigDecimal.valueOf(100))
            ),
            "completed"
        ),
        new Order(
            "ORD002",
            "CUST001",
            LocalDate.of(2025, 1, 2),
            List.of(
                new OrderItem("PROD003", 5, BigDecimal.valueOf(200))
            ),
            "completed"
        )
    );

    var analytics = new OrderAnalytics(orders);

    // Find high-value orders
    List<Order> highValue = analytics.getHighValueOrders();
    System.out.println("High value orders: " + highValue.size());

    // Get total revenue
    BigDecimal revenue = analytics.getTotalRevenue();
    System.out.println("Total revenue: $" + revenue);

    // Top customers
    List<Map.Entry<String, Long>> topCustomers = analytics.getTopCustomers(5);
    System.out.println("Top customers: " + topCustomers);
}

Parallel Streams

Parallel streams can improve performance for CPU-intensive operations on large datasets:

List<Integer> numbers = IntStream.rangeClosed(1, 1_000_000)
    .boxed()
    .toList();

// Sequential
long sum = numbers.stream()
    .mapToLong(Integer::longValue)
    .sum();

// Parallel - automatically uses ForkJoinPool
long parallelSum = numbers.parallelStream()
    .mapToLong(Integer::longValue)
    .sum();

When to use parallel streams:

  • Large datasets (thousands+ elements)
  • CPU-intensive operations
  • No shared mutable state
  • Operations are independent

When to avoid:

  • Small datasets (overhead not worth it)
  • I/O operations (database, file, network)
  • Operations with side effects
  • Order-dependent operations

Stream Performance Tips

  1. Use primitive streams for boxed types

    // Slower - boxing overhead
    int sum = list.stream()
        .mapToInt(Integer::intValue)
        .sum();
    
    // Faster - no boxing
    IntStream.range(0, 1000).sum();
    
  2. Short-circuit operations early

    // Put filter before expensive operations
    list.stream()
        .filter(x -> x.isActive()) // cheap filter first
        .map(x -> expensiveTransform(x)) // expensive operation
        .toList();
    
  3. Avoid creating unnecessary intermediate collections

    // Don't do this
    List<String> temp = list.stream().filter(...).toList();
    List<String> result = temp.stream().map(...).toList();
    
    // Do this instead
    List<String> result = list.stream()
        .filter(...)
        .map(...)
        .toList();
    
  4. Use anyMatch/noneMatch instead of filter().count()

    // Slower
    boolean hasActive = list.stream()
        .filter(User::isActive)
        .count() > 0;
    
    // Faster (short-circuits)
    boolean hasActive = list.stream()
        .anyMatch(User::isActive);
    

Common Stream Patterns

Grouping and partitioning:

Map<Boolean, List<Integer>> partitioned = numbers.stream()
    .collect(Collectors.partitioningBy(n -> n % 2 == 0));
// {true=[2, 4, 6], false=[1, 3, 5]}

Map<String, List<User>> byRole = users.stream()
    .collect(Collectors.groupingBy(User::role));

Finding statistics:

IntSummaryStatistics stats = numbers.stream()
    .mapToInt(Integer::intValue)
    .summaryStatistics();

System.out.println("Count: " + stats.getCount());
System.out.println("Sum: " + stats.getSum());
System.out.println("Min: " + stats.getMin());
System.out.println("Max: " + stats.getMax());
System.out.println("Average: " + stats.getAverage());

Joining strings:

String csv = names.stream()
    .collect(Collectors.joining(", "));

String quoted = names.stream()
    .collect(Collectors.joining("', '", "'", "'"));
// 'Alice', 'Bob', 'Charlie'