Module 9 · Modern Java: Functional Style & Concurrency ⏱ 19 min

Stream API Core Operations (map, filter, reduce)

By the end of this lesson you will be able to:
  • Name the three roles in a stream pipeline and classify filter, map, and reduce into them
  • Use laziness to predict when, and whether, a pipeline's intermediate steps actually run
  • Trace a reduce fold step by step to predict its result

Suppose you want the doubled value of every even number in a list. The classic approach is a loop: create a result list, walk the source, test each element, transform the survivors, and append. It works, but the what and the how are tangled together — you cannot see the intention, 'double the evens', for the mechanics of indexing and appending.

A stream separates intention from mechanics. You describe the pipeline — which elements to keep, how to transform them, what to finally produce — and let the library worry about the iteration. filter keeps the elements a Predicate accepts; map transforms each one through a Function; reduce folds them into a single value. The names read like a sentence, and that readability is the whole reason the API exists.

flowchart LR
  SRC["source<br/>Stream.of or a collection"] --> INT["intermediate ops<br/>filter and map"]
  INT --> TERM["terminal op<br/>reduce or forEach"]
  TERM --> OUT["a result or side effect"]
  style TERM fill:#c2410c,color:#fff
A stream pipeline has three roles: a source, zero or more intermediate operations, and exactly one terminal operation that produces a result.

Every pipeline has three roles. The source is where elements come from — Stream.of(...), a collection's stream() method, or IntStream.range. Intermediate operations wire the pipeline together: filter, map, sorted, and limit each return a new stream and do no work yet. The terminal operation is the switch that finally runs everything: forEach, reduce, collect, or count.

The defining property sits between those last two roles. Intermediate operations are lazy — they do not process a single element until a terminal operation is invoked. You can build a pipeline that filters, maps, and sorts, and nothing at all happens until you call something that demands a result. Laziness is not an optimisation detail; it is the behaviour you must predict to read streams correctly.

Main.java — filter keeps the evens, map doubles each, forEach prints them. Real, compiling Java.
import java.util.stream.Stream;

public class Main {
    public static void main(String[] args) {
        Stream.of(1, 2, 3, 4, 5, 6)
            .filter(n -> n % 2 == 0)
            .map(n -> n * 10)
            .forEach(System.out::println);
    }
}

Notice the output keeps the relative order of the source. Streams derived from ordered sources — such as Stream.of or a List — preserve encounter order through filter and map, so the doubled evens come out 20, 40, 60 in the order they went in, not sorted and not shuffled. Only an explicit sorted, an unordered source like a HashSet, or a deliberately unordered operation changes that.

filter never reorders or duplicates; it only decides keep-or-skip for each element. map transforms one-to-one, never changing the count. Both are lazy, both preserve order, and both return a fresh stream you can keep chaining. The terminal forEach is what finally walks that chain and produces the side effect of printing.

Laziness: a pipeline with no terminal does nothing

Laziness has a consequence that surprises every newcomer: a pipeline with no terminal operation does nothing at all. Add peek, filter, and map to a stream, never call reduce or collect or forEach, and the entire chain sits idle. The elements are never touched, the lambdas never run, no output appears. The pipeline is a description of work, not the work itself.

Laziness also lets the library fuse steps and short-circuit. In a chain like peek(print).filter(test).findFirst(), the engine does not filter the whole stream and then locate the first match. It walks one element at a time, running peek and filter only until a single element passes, then stops. Fewer elements are touched than a naive reading suggests, and predicting exactly how many is a skill this lesson trains.

flowchart TD
  P["filter then map then peek"] --> Q{"terminal operation?"}
  Q -->|"no"| N["nothing runs<br/>no output, no work"]
  Q -->|"yes, forEach or reduce"| R["the whole pipeline fires"]
  style N fill:#b45309,color:#fff
  style R fill:#3776ab,color:#fff
A decision the whole pipeline hinges on: with no terminal operation, nothing runs at all.
Main.java — the pipeline is built but never started, so only the final line prints. Real, compiling Java.
import java.util.stream.Stream;

public class Main {
    public static void main(String[] args) {
        Stream.of("a", "b", "c")
            .peek(s -> System.out.println("saw " + s))
            .map(String::toUpperCase);
        System.out.println("done");
    }
}

reduce: folding many elements into one

reduce folds a stream down to a single value by repeatedly combining elements. Its two-argument form takes an identity — the starting accumulator — and a combining function that merges the accumulator with each element. To sum 1, 2, 3, 4, 5 you write reduce(0, (acc, n) -> acc + n): the accumulator begins at 0, absorbs 1 to become 1, then 3, 6, 10, and finally 15.

The identity must be a value that leaves other values unchanged when combined — 0 for addition, an empty string for concatenation, 1 for multiplication. Pick the wrong identity and your result is quietly off. Because reduce walks the stream in encounter order, you can trace it step by step on paper and predict the exact answer, which is exactly what the exercises ask.

flowchart LR
  I["identity 0"] --> A1["absorbs 1<br/>becomes 1"]
  A1 --> A2["absorbs 2<br/>becomes 3"]
  A2 --> A3["absorbs 3, 4, 5<br/>becomes 15"]
  style I fill:#3776ab,color:#fff
reduce folds a stream by feeding each element into the accumulator, starting from the identity value.
Exercise

What does this program print? Read the pipeline carefully and type the exact output (three lines).

import java.util.stream.Stream;

public class Main {
    public static void main(String[] args) {
        Stream.of(1, 2, 3, 4, 5, 6)
            .filter(n -> n % 2 == 0)
            .map(n -> n * 10)
            .forEach(System.out::println);
    }
}
Exercise

A pipeline calls filter and map but never calls a terminal operation such as forEach or collect. What happens?

Exercise

The laziness trap. The pipeline below is built but never started. Type the exact output (one line).

import java.util.stream.Stream;

public class Main {
    public static void main(String[] args) {
        Stream.of("a", "b", "c")
            .peek(s -> System.out.println("saw " + s))
            .map(String::toUpperCase);
        System.out.println("done");
    }
}
Exercise

A stream is used once with forEach, then filter is called on the same stream and forEach again. What does the second terminal call do?

Exercise

reduce, twice. One fold sums numbers, another joins letters into a word. Predict both lines.

import java.util.stream.Stream;

public class Main {
    public static void main(String[] args) {
        int sum = Stream.of(1, 2, 3, 4, 5)
            .reduce(0, (acc, n) -> acc + n);
        System.out.println("sum: " + sum);

        String word = Stream.of("s", "t", "r", "e", "a", "m")
            .reduce("", (acc, c) -> acc + c);
        System.out.println("word: " + word);
    }
}

Recap

  • A pipeline has three roles: a source, zero or more intermediate operations (filter, map, sorted), and exactly one terminal operation (forEach, reduce, collect).
  • Intermediate operations are lazy: they run only when a terminal operation demands a result, so a pipeline with no terminal does nothing.
  • Laziness lets the library fuse and short-circuit, touching fewer elements than a naive reading suggests.
  • Ordered streams preserve encounter order through filter and map.
  • A stream is single-use: a second terminal operation on the same stream throws IllegalStateException.
  • reduce(identity, accumulator) folds elements in encounter order; pick an identity that leaves values unchanged.

Next you will gather stream results into real containers with collect and groupingBy — and meet the ordering trap that waits inside every grouped map.

Checkpoint quiz

Why does a pipeline of filter and map with no terminal operation print nothing?

What does Stream.of(1,2,3,4,5).reduce(0, (acc, n) -> acc + n) evaluate to?

Go deeper — technical resources