Streams

Java · Practical · 15 min

Streams express data transformations clearly and appear throughout modern Java codebases.

A stream is a pipeline over a sequence of elements: a source, zero or more intermediate operations, and one terminal operation:

List<String> result = orders.stream()        // source
    .filter(o -> o.total() > 100)               // intermediate
    .map(Order::customer)                       // intermediate
    .distinct()
    .sorted()
    .toList();                                  // terminal

Intermediate operations are lazy: nothing runs until the terminal operation pulls elements through, one at a time. That's why filter(...).findFirst() stops as soon as it finds a match.

Common operations:

  • Intermediate: filter, map, flatMap (one-to-many), distinct, sorted, limit, skip, peek (debugging).
  • Terminal: toList() (Java 16+, unmodifiable), forEach, count, anyMatch/allMatch, findFirst, reduce, min/max, and collect.

Collectors handles the richer cases: groupingBy(Order::customer) builds a Map<K, List<V>>, optionally with a downstream collector like counting() or summingDouble(...); toMap, joining(", "), and partitioningBy.

For primitives use IntStream, mapToInt(...), sum(), average() — they avoid boxing. IntStream.range(0, n) replaces many index loops.

Rules to keep in mind:

  • A stream can be consumed only once.
  • Keep lambdas free of side effects; don't add to an external list from forEach — collect instead.
  • parallel() is rarely a win for typical business code; measure before using it.

Streams are not always better than loops. If the pipeline needs complex state, early returns or checked exceptions, a plain loop is often clearer.

Example

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

public class StreamsDemo {
    record Order(String customer, String product, double total) { }

    public static void main(String[] args) {
        var orders = List.of(
                new Order("ana", "book", 30), new Order("bo", "lamp", 120),
                new Order("ana", "desk", 250), new Order("cy", "pen", 5));

        List<String> big = orders.stream()
                .filter(o -> o.total() > 100).map(Order::customer)
                .sorted().toList();
        System.out.println(big);                           // [ana, bo]

        Map<String, Double> spend = orders.stream().collect(
                Collectors.groupingBy(Order::customer, Collectors.summingDouble(Order::total)));
        System.out.println(spend.get("ana"));              // 280.0

        double avg = orders.stream().mapToDouble(Order::total).average().orElse(0);
        System.out.println(avg);                           // 101.25
    }
}

Remember

  • source → lazy intermediate ops → one terminal op
  • groupingBy, toMap, joining and partitioningBy cover most aggregation needs
  • Use primitive streams for numbers; avoid side effects in lambdas

Watch out for

  • Reusing a stream after a terminal operation — it throws IllegalStateException.
  • Building results by mutating an outside list in forEach instead of collecting.

Check yourself

  1. When does `list.stream().filter(x -> expensive(x))` call expensive()?
  2. Which collector turns a stream of orders into a Map from customer to list of orders?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.oracle.com/javase/tutorial/collections/streams/index.html