Stream Operations: Intermediate and Terminal
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.
| Operation | Signature | Description |
|---|---|---|
filter | Stream<T> filter(Predicate<T>) | Keep elements matching the predicate |
map | Stream<R> map(Function<T,R>) | Transform each element (one-to-one) |
flatMap | Stream<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 |
peek | Stream<T> peek(Consumer<T>) | Perform action on each element without consuming (mainly for debugging) |
takeWhile | Stream<T> takeWhile(Predicate<T>) | Take elements while predicate is true, then stop (Java 9+) |
dropWhile | Stream<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.
| Operation | Return type | Description |
|---|---|---|
forEach | void | Apply action to each element (not guaranteed ordered for parallel) |
forEachOrdered | void | Apply action in encounter order (even for parallel streams) |
collect | R (from Collector) | Mutable reduction into a collection, string, or summary |
toList() | List<T> | Collect into an unmodifiable list (Java 16+) |
reduce | Optional<T> / T | Combine elements into a single result |
count | long | Number of elements |
min / max | Optional<T> | Minimum/maximum by comparator |
anyMatch | boolean | True if any element matches predicate (short-circuiting) |
allMatch | boolean | True if all elements match predicate (short-circuiting) |
noneMatch | boolean | True if no elements match predicate (short-circuiting) |
findFirst | Optional<T> | First element (short-circuiting) |
findAny | Optional<T> | Any element, useful for parallel (short-circuiting) |
toArray | Object[] / 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:
names.stream()— Create a stream from the list..filter(n -> n.length() > 4)— Build a plan: keep “Alice”, “Charlie”, “Diana”, “Frank” (drop “Bob”, “Eve”)..map(String::toUpperCase)— Build a plan: uppercase each..sorted()— Build a plan: sort alphabetically. This is stateful — needs to buffer all elements before sorting..collect(Collectors.toList())— Execute the pipeline. Trigger the filter, then the map, then the sort, then collect into a newArrayList.
Tripos traps
- Intermediate operation without terminal —
names.stream().filter(...);does nothing. The stream is never executed. This is a common beginner mistake and a Tripos code-reading trap. - Reusing a stream —
Stream<String> s = list.stream(); s.count(); s.collect(...);— the second call throwsIllegalStateExceptionbecause the stream is already consumed. forEachvscollect— Do not useforEachto accumulate into an external collection. Usecollect.forEachis for side-effects (printing, logging), not for building results.- Type inference with lambdas —
names.stream().map(n -> n.length()).sorted()— thesorted()call expectsStream<Integer>to have a natural order, which it does. Butsorted(Comparator)on aStream<Object>would fail.