Stage 4: Modern Java, lesson 2 of 7

Stream API

Intermediate3 min read@since 16Code runs on your Java 25
Explain it forThe essentials plus production detail and pitfalls.

A stream processes a sequence of elements through a pipeline:

  1. Source: list.stream(), Stream.of(...), IntStream.range(...)
  2. Intermediate operations, which are lazy: filter, map, flatMap, sorted, distinct, limit
  3. A terminal operation, which triggers the work: collect, toList(), forEach, reduce, count, anyMatch

Nothing runs until the terminal operation, and a stream can be consumed only once.

Additions over the years: takeWhile and dropWhile (Java 9), Stream.toList() and mapMulti (Java 16), and gatherers for custom intermediate operations (final in Java 24).

Creating streams

Streams come from collections, arrays, values, ranges, generators and files.

Java
orders.stream();
Stream.of("a", "b", "c");
Arrays.stream(new int[]{3, 1, 2});
IntStream.rangeClosed(1, 5);                   // 1..5
Stream.iterate(1, n -> n * 2).limit(10);        // 1, 2, 4, ...
Files.lines(Path.of("app.log"));                // close it (try-with-resources)

Intermediate operations

These return a new stream and run lazily: filter, map, flatMap (flatten nested collections), distinct, sorted, limit, skip, peek (for debugging), and takeWhile/dropWhile (Java 9).

Java
List<String> tags = courses.stream()
    .flatMap(c -> c.tags().stream())      // List<List<String>> -> Stream<String>
    .map(String::toLowerCase)
    .distinct()
    .sorted()
    .toList();

Terminal operations

A terminal operation runs the pipeline and produces a result: collect, toList (Java 16), forEach, count, min/max, reduce, anyMatch/allMatch/noneMatch, and findFirst/findAny (which return an Optional).

Java
long paid = orders.stream().filter(Order::isPaid).count();
boolean anyBig = orders.stream().anyMatch(o -> o.amount() > 10_000);
Optional<Order> latest = orders.stream().max(Comparator.comparing(Order::placedAt));

Primitive streams

IntStream, LongStream and DoubleStream avoid boxing and add sum, average and summaryStatistics. Convert with mapToInt and back with boxed.

Java
int total = orders.stream().mapToInt(Order::quantity).sum();
OptionalDouble avg = orders.stream().mapToDouble(Order::amount).average();
IntSummaryStatistics stats = IntStream.of(4, 8, 15).summaryStatistics();

Laziness, short-circuiting and single use

Nothing happens until a terminal operation runs, and operations like limit, findFirst and anyMatch stop early. A stream can be consumed only once; reusing it throws IllegalStateException.

Java
Stream<String> s = names.stream().filter(n -> {
    System.out.println("checking " + n);   // not printed yet
    return n.startsWith("A");
});
s.findFirst();       // now it runs, and stops at the first match
// s.count();        // IllegalStateException: already used

reduce

reduce folds all elements into one value using an identity value and an associative function. For sums and joins, prefer the specialised sum() and Collectors.joining.

Java
int product = IntStream.rangeClosed(1, 5).reduce(1, (a, b) -> a * b);   // 120
BigDecimal total = items.stream().map(Item::price).reduce(BigDecimal.ZERO, BigDecimal::add);

Parallel streams

parallelStream() splits work across the common ForkJoinPool. It helps only for large, CPU-heavy, independent work, and hurts for small collections, I/O or shared mutable state. Measure before using it, and never modify shared variables inside.

Java
long primes = LongStream.rangeClosed(2, 5_000_000)
    .parallel()
    .filter(Maths::isPrime)
    .count();

Stream gatherers (Java 24)

Gatherers add custom intermediate operations. The built-in ones include windowFixed (batches) and windowSliding (moving windows).

Java
List<List<Integer>> batches = Stream.of(1, 2, 3, 4, 5)
    .gather(Gatherers.windowFixed(2))
    .toList();          // [[1, 2], [3, 4], [5]]

Example

Java
record Order(String customer, String city, double amount) {}

List<Order> orders = List.of(
    new Order("Asha", "Pune", 1200), new Order("Ravi", "Delhi", 300),
    new Order("Asha", "Pune", 800),  new Order("Kabir", "Delhi", 2500));

List<String> bigSpenders = orders.stream()
    .filter(o -> o.amount() > 1000)
    .map(Order::customer)
    .distinct()
    .toList();                                  // Java 16+: [Asha, Kabir]

Map<String, Double> revenueByCity = orders.stream()
    .collect(Collectors.groupingBy(Order::city,
             Collectors.summingDouble(Order::amount)));   // {Pune=2000.0, Delhi=2800.0}

double avg = orders.stream().mapToDouble(Order::amount).average().orElse(0);

Common mistake

Calling a terminal operation twice on the same stream throws IllegalStateException ("stream has already been operated upon or closed"). Create a new stream instead.

Under the hood

Streams process one element at a time through the whole pipeline, and short-circuiting operations (findFirst, limit, anyMatch) stop early. parallelStream() uses the shared common ForkJoinPool; it helps only for large, CPU-bound work on easily split sources such as ArrayList or arrays, and it hurts for I/O or small lists. Stream.toList() returns an unmodifiable list, whereas Collectors.toList() currently returns a mutable one.

Check yourself

A stream has filter and map but no terminal operation. What happens?

How this connects

Part of Java from zero, Job-ready backend developer, Crack the Java interview.

Was this lesson helpful?

Finished reading? Mark it complete to track your progress.