Skip to content
Part IA Michaelmas Term

Stream Operations: Intermediate and Terminal

Stream pipeline showing intermediate and terminal operations

Stream operations are divided into two categories: intermediate (lazy, return a new stream) and terminal (eager, trigger execution and produce a result or side-effect). Nothing runs until a terminal operation is invoked. This laziness allows the pipeline to be optimised as a whole.

Intermediate operations (lazy)

Each intermediate operation returns a new Stream and does NOT trigger any data processing.

OperationSignatureDescription
filterStream<T> filter(Predicate<T>)Keep elements matching the predicate
mapStream<R> map(Function<T,R>)Transform each element (one-to-one)
flatMapStream<R> flatMap(Function<T,Stream<R>>)Transform each element to a stream and flatten (one-to-many)
sorted()Stream<T> sorted()Natural order sort (stateful — needs all elements)
sorted(Comparator)Stream<T> sorted(Comparator<T>)Custom order sort (stateful)
distinct()Stream<T> distinct()Remove duplicates (stateful)
limit(n)Stream<T> limit(long n)Truncate to first n elements (short-circuiting)
skip(n)Stream<T> skip(long n)Discard first n elements
peekStream<T> peek(Consumer<T>)Perform action on each element without consuming (mainly for debugging)
takeWhileStream<T> takeWhile(Predicate<T>)Take elements while predicate is true, then stop (Java 9+)
dropWhileStream<T> dropWhile(Predicate<T>)Drop elements while predicate is true, then take the rest (Java 9+)

Stateful vs stateless intermediate operations

Stateless: Each element is processed independently — filter, map, flatMap, peek, takeWhile.

Stateful: The operation needs to see the entire stream (or a window of it) to compute its result — sorted, distinct, skip, limit, dropWhile. These require buffering and can degrade parallel performance.

map vs flatMap

// map: one-to-one
List<String> words = List.of("Hello", "World");
words.stream()
    .map(w -> w.split(""))          // Stream<String[]>
    .collect(Collectors.toList());  // [[H,e,l,l,o], [W,o,r,l,d]]

// flatMap: one-to-many, flattened
words.stream()
    .flatMap(w -> Arrays.stream(w.split("")))  // Stream<String>
    .collect(Collectors.toList());             // [H,e,l,l,o,W,o,r,l,d]

Terminal operations (eager)

Each terminal operation triggers the pipeline execution and produces a result or side-effect. After a terminal operation, the stream is consumed and cannot be reused.

OperationReturn typeDescription
forEachvoidApply action to each element (not guaranteed ordered for parallel)
forEachOrderedvoidApply action in encounter order (even for parallel streams)
collectR (from Collector)Mutable reduction into a collection, string, or summary
toList()List<T>Collect into an unmodifiable list (Java 16+)
reduceOptional<T> / TCombine elements into a single result
countlongNumber of elements
min / maxOptional<T>Minimum/maximum by comparator
anyMatchbooleanTrue if any element matches predicate (short-circuiting)
allMatchbooleanTrue if all elements match predicate (short-circuiting)
noneMatchbooleanTrue if no elements match predicate (short-circuiting)
findFirstOptional<T>First element (short-circuiting)
findAnyOptional<T>Any element, useful for parallel (short-circuiting)
toArrayObject[] / T[]Collect into array

Short-circuiting terminal operations

These may terminate the pipeline before processing all elements:

  • anyMatch, allMatch, noneMatch — stop when the boolean outcome is determined.
  • findFirst, findAny — stop when an element is found.
  • limit(n) — this is an intermediate operation, but it short-circuits the upstream source.

collect and Collectors

collect is the most general terminal operation. It uses a Collector to accumulate elements into a mutable result container.

// To collection
.collect(Collectors.toList())
.collect(Collectors.toSet())
.collect(Collectors.toCollection(LinkedList::new))

// To map
.collect(Collectors.toMap(Person::getId, Function.identity()))
.collect(Collectors.toMap(Person::getId, p -> p, (a, b) -> a))  // merge on duplicate keys

// Grouping
.collect(Collectors.groupingBy(Person::getCity))
.collect(Collectors.groupingBy(Person::getCity, Collectors.counting()))

// Partitioning (true/false split)
.collect(Collectors.partitioningBy(p -> p.getAge() >= 18))

// Joining strings
.collect(Collectors.joining(", "))

// Summarising numbers
.collect(Collectors.summarizingInt(Person::getAge))  // IntSummaryStatistics

Full pipeline example

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

List<String> result = names.stream()            // source
    .filter(n -> n.length() > 4)                // intermediate: keep long names
    .map(String::toUpperCase)                   // intermediate: uppercase
    .sorted()                                   // intermediate: sort
    .collect(Collectors.toList());              // terminal: collect to list

// result = ["ALICE", "CHARLIE", "DIANA", "FRANK"]

Step-by-step pipeline decomposition:

  1. names.stream() — Create a stream from the list.
  2. .filter(n -> n.length() > 4) — Build a plan: keep “Alice”, “Charlie”, “Diana”, “Frank” (drop “Bob”, “Eve”).
  3. .map(String::toUpperCase) — Build a plan: uppercase each.
  4. .sorted() — Build a plan: sort alphabetically. This is stateful — needs to buffer all elements before sorting.
  5. .collect(Collectors.toList())Execute the pipeline. Trigger the filter, then the map, then the sort, then collect into a new ArrayList.

Tripos traps

  1. Intermediate operation without terminalnames.stream().filter(...); does nothing. The stream is never executed. This is a common beginner mistake and a Tripos code-reading trap.
  2. Reusing a streamStream<String> s = list.stream(); s.count(); s.collect(...); — the second call throws IllegalStateException because the stream is already consumed.
  3. forEach vs collect — Do not use forEach to accumulate into an external collection. Use collect. forEach is for side-effects (printing, logging), not for building results.
  4. Type inference with lambdasnames.stream().map(n -> n.length()).sorted() — the sorted() call expects Stream<Integer> to have a natural order, which it does. But sorted(Comparator) on a Stream<Object> would fail.