Stream Foundations and Pipeline Architecture
Part 3 of 9 in Functional Java Unleashed
Java’s Stream API is one of the most widely-used language features since generics, and also one of the most misunderstood. Developers reach for .stream().filter(...).map(...).collect(...) without always understanding what happens between those dots — or why it matters.
This post walks through three concrete examples that build on each other:
- A declarative pipeline — how streams replace hand-written loops with a source-intermediate-terminal chain.
- Lazy evaluation — why nothing actually executes until you ask for the result, and what that means for correctness.
- Pipeline architecture — how elements flow through stages, how short-circuit operations cut processing short, and how the same source can feed multiple independent streams.
All three examples use Java 21 with the standard library only.
The code
The first example compares an imperative loop against its stream equivalent. Both do the same thing — filter strings longer than three characters, uppercase them, sort them — but the declarative version expresses what you want rather than how to iterate:
// Declarative pipeline
List<String> result = words.stream()
.filter(w -> w.length() > 3)
.map(String::toUpperCase)
.sorted()
.collect(Collectors.toList());
The second example demonstrates lazy evaluation. Intermediate operations like peek, filter, and map build up a pipeline description — but absolutely nothing runs until a terminal operation is called:
Stream<Integer> lazyStream = numbers.stream()
.peek(n -> System.out.println(" [debug] filter saw " + n))
.filter(n -> n > 10)
.map(n -> n * 2);
// Nothing prints here — pipeline is just a description
long count = lazyStream.count(); // Everything runs now
The third example traces element flow through a full pipeline, shows short-circuit operations (limit, findFirst) stopping processing early, demonstrates parallel execution, and proves that the same source can feed multiple independent streams:
// Short-circuit: limit(2) stops after two matching elements
List<String> limited = names.stream()
.filter(n -> n.length() > 3)
.limit(2)
.collect(Collectors.toList());
// Same source, different streams — each independent
double avgLen = source.stream()
.mapToInt(String::length)
.average()
.orElse(0);
Map<String, Long> freq = source.stream()
.collect(Collectors.groupingBy(s -> s.substring(0, 3), Collectors.counting()));
The full source files are available in the recording below.
Running it
Example 1: Declarative vs imperative
The imperative loop preserves original insertion order: [ALPHA, GAMMA, DELTA, EPSILON, ZETA, THETA]. The stream pipeline with .sorted() produces [ALPHA, DELTA, EPSILON, GAMMA, THETA, ZETA] — alphabetically sorted. Same operations, different structure.
The third block reuses the same source-intermediate pipeline (filter → map → sorted) but changes only the terminal operation from collect(Collectors.toList()) to .count(), producing 6. This shows that the intermediate stages are just a description — they produce whatever value your terminal op asks for.
Example 2: Lazy evaluation in practice
Watch carefully between the lines “Building the pipeline” and “Pipeline built. Calling count() now…” — nothing is printed. The peek calls inside the stream chain have not executed at all. This proves that building a stream is just assembling a description; nothing touches the data until a terminal operation forces evaluation.
Once count() runs, all eight elements flow through the filter’s peek stage (visitCount = 8), but only five pass the .filter(n > 10) predicate and reach the map stage. The five values that survive — 25, 47, 62, 14, 91 — are each doubled to produce [28, 50, 94, 124, 182] after sorting.
The second call to count() on the same stream throws IllegalStateException: stream has already been operated upon or closed. A stream is a one-shot iterator; once consumed it cannot be reused. You must rebuild from the source (numbers.stream()) for each new pipeline.
Example 3: Pipeline architecture
Part 1 traces every stage. Each name passes through filter’s peek first, then those with length ≤ 3 (Bob, Eve) are removed by .filter(n -> n.length() > 3), and the remaining five flow through map to uppercase before sorted(Comparator.reverseOrder()) produces [GRACE, FRANK, DIANA, CHARLIE, ALICE].
Part 2 (limit(2)) shows short-circuit in action. The stream peeks Alice, Bob, Charlie — only three names are touched before limit(2) stops the pipeline. Bob doesn’t pass the filter (length 3), so it gets through the peek but is dropped by .filter(n -> n.length() > 3). Result: [Alice, Charlie].
Part 3 (findFirst) peeks Alice, Bob, Charlie, Diana — and stops. Only four elements are processed before Diana matches .filter(n -> n.startsWith("D")). This is the optimization benefit of short-circuit operations with lazy evaluation.
Part 4 compares sequential vs parallel. Both process 10,000 integers (divisible by 7, squared, sorted). The results are identical — count=1428 in both cases, and a boolean check confirms the lists match exactly. In this container the parallel version is actually slower (3.5ms vs 1.9ms) because the workload is small and overhead dominates, but with real-world data sizes parallel execution scales across available cores.
Part 5 shows three different terminal operations on streams rebuilt from the same source list. Each stream is independent — you can call .average(), .groupingBy(...), and .reduce(...) on separate .stream() calls from the same backing collection without interference. The average length of apple|banana|cherry|date|elderberry is 6.20, prefix grouping yields five groups each with count 1, and .reduce((a,b) → a.length() >= b.length() ? a : b) correctly identifies “elderberry” as the longest.
Takeaway
A stream pipeline has three layers: a source (collection, array, or generator), zero or more intermediate operations (filter, map, sorted — all lazy and chainable), and exactly one terminal operation (collect, count, forEach, reduce) that triggers evaluation. The declarative style lets you compose transformations as data flows through stages, and lazy evaluation means intermediate steps only execute for elements that survive to them — with short-circuit operations cutting the pipeline short when they can.