skip to content
everything.functional.streamPipeline

how a stream pipeline runs

source → intermediate → terminal. lazy, one element at a time.

chapter
chapter 07.04
reading time
7 min read

a stream is a pipeline: a source, some intermediate operations (filter, map...) and exactly one terminal operation (toList, count, forEach...).

intermediate operations are lazy: they only record what to do. the terminal operation pulls elements through one at a time, and each element goes as far down the pipeline as it can before the next one is even looked at.

the picture

you know how list.stream().filter(...).map(...) looks like it loops over the list twice, once to filter and once to map? it doesn't. it loops once, and it might not even finish the loop.

think of a cook working a ticket rail. each order goes all the way from chopping to the plate before the next ticket is read. nobody chops onions for all fifty orders up front, and if the restaurant only needs two plates, the other forty-eight tickets are never touched.

hover a stage.

hover a stage. the three parts of every stream. there's always exactly one source and one terminal operation, and any number of intermediate operations in between.

one element at a time

this is the whole chapter. press play and follow one marble.

model =
terminal =
stdout
·
filter calls 0map calls 0

toList() is the terminal operation, so the pipeline starts. it pulls one element from the source, nothing more.

step 1/13
switch the model to see the picture most people have in their head, and why it's wrong. switch the terminal to none to see what happens without one (nothing). the amber marble is the one being worked on, red ones were dropped, green ones made it into the list.

see? the pipeline is vertical, not horizontal. element 2 is filtered, mapped, counted by limit and stored in the list before element 3 leaves the source. and because limit(2) is satisfied after element 4, elements 5 through 8 never exist as far as the pipeline is concerned.

here's the real program the figure is drawn from, with print statements inside the lambdas so you can see the order:

Main.java
import java.util.stream.*;
 
void main() {
    var result = Stream.of(1, 2, 3, 4, 5, 6, 7, 8)
            .filter(n -> { IO.println("filter " + n); return n % 2 == 0; })
            .map(n -> { IO.println("map    " + n); return n * 10; })
            .limit(2)
            .toList();
    IO.println(result);
}
output
filter 1
filter 2
map    2
filter 3
filter 4
map    4
[20, 40]
predict the output
java
List<String> names = List.of("ada", "bob", "cy");
names.stream().map(s -> { IO.print(s.toUpperCase() + " "); return s; });
IO.println("done");

laziness, proven

the stages are built when you call filter and map. the lambdas only run when the terminal operation starts pulling:

Main.java
import java.util.stream.*;
 
void main() {
    Stream<Integer> s = Stream.of(1, 2, 3)
            .filter(n -> { IO.println("filter " + n); return true; })
            .map(n -> { IO.println("map " + n); return n * 10; });
    IO.println("pipeline built");
    IO.println(s.toList());
}
output
pipeline built
filter 1
map 1
filter 2
map 2
filter 3
map 3
[10, 20, 30]

laziness isn't just a performance trick. it's what makes infinite sources usable. Stream.iterate(1, n -> n * 2) never ends, and this still finishes instantly, because findFirst stops pulling as soon as it has an answer:

Main.java
import java.util.stream.*;
 
void main() {
    int first = Stream.iterate(1, n -> n * 2)
            .filter(n -> n > 100)
            .findFirst()
            .orElseThrow();
    IO.println(first);
}
output
128

operations that can finish without seeing every element are called short-circuiting: limit, takeWhile, findFirst, findAny, anyMatch, allMatch, noneMatch. put one of those in front of an infinite source and you're fine. put sorted() or count() there instead and it runs forever.

what's actually in memory

each intermediate operation returns a brand new stage object that points back at the one before it. here's the pipeline from the figure, after .limit(2) and before .toList(), with the real class names from jdk 25:

stack
main
a
b
c
d
heap
ReferencePipeline$Head
previousStagenull
depth0
linkedOrConsumedtrue
ReferencePipeline$2filter
previousStage
depth1
linkedOrConsumedtrue
ReferencePipeline$3map
previousStage
depth2
linkedOrConsumedtrue
SliceOps$1limit
previousStage
depth3
linkedOrConsumedfalse
four stage objects, chained by previousStage. the class names are the JDK's anonymous classes ($2 is filter's stage, $3 is map's). no element has moved yet: this is just a linked list of instructions.

when toList() runs on d, it walks that chain backwards and wraps one sink inside another: the list's sink, inside limit's, inside map's, inside filter's. then it feeds the source's elements into the outermost sink, one by one. filter's sink calls map's sink only if the predicate passed, map's sink calls limit's, and so on. that nesting is why each element goes all the way down before the next one starts. limit stops the whole thing by answering "yes" when the source asks cancellationRequested().

one level deeper:stream flags, and the work the JDK skips+

every stage carries flags: SIZED (the count is known up front), ORDERED, DISTINCT, SORTED, SHORT_CIRCUIT. operations read them and skip work they can prove is pointless. a natural-order sorted() on a stream already flagged SORTED (say, from a TreeSet) does nothing. distinct() on a DISTINCT stream does nothing.

the surprising one: count() on a SIZED stream doesn't run the pipeline at all if nothing in it can change the size. on jdk 25, Stream.of(1, 2, 3).map(x -> { IO.println("map " + x); return x; }).count() prints nothing and returns 3. put a filter in front and suddenly every element goes through, because now the size isn't known. this has been the case since java 9, and it's the reason the peek docs warn you not to rely on it for side effects. more in stream operations.

keylines
  • source → intermediate ops → one terminal op. no terminal op, no work.
  • intermediate ops only build stages. the terminal op pulls elements through them.
  • each element goes all the way down the pipeline before the next one starts.
  • short-circuiting ops (limit, findFirst, anyMatch...) stop pulling early. that's what makes infinite streams safe.
  • a stream is single-use. a second terminal op throws IllegalStateException.