JAVA / STREAMS, LAMBDAS AND OPTIONAL
Pipelines with sources, intermediate and terminal stages
Structure a stream as source, lazy intermediate stages and one terminal stage, and predict exactly when and in what order each element is processed.
What you will learn
- Return type tells you the stage kind: Stream is lazy, anything else is terminal
- Build a pipeline with no terminal stage and confirm none of its lambdas run
- Read interleaved prints as proof elements flow one at a time through all stages
- Rebuild a pipeline from a Supplier<Stream<T>> instead of reusing a consumed one
Understanding Pipelines with sources, intermediate and terminal stages
Every stream pipeline has the same three-part shape. A source knows how to hand out elements one at a time: List.stream(), Arrays.stream(array), Stream.of(a, b), Files.lines(path), Stream.iterate(seed, next). Between the source and the answer sit zero or more intermediate stages, each of which returns a new Stream and does nothing except remember what it was asked to do. Exactly one terminal stage ends the chain, and you can identify it by return type alone: if a call gives you back something that is not a Stream (long, Optional<T>, List<T>, void), that call is what makes the work happen.
The mental model that matters is a pull, not a series of passes. The terminal stage drives the loop: it asks the source for one element, that element travels through map, then through filter, then into the terminal, and only then is the next element produced. This depth-first, one-element-at-a-time flow explains three things at once: printlns placed in different stages interleave instead of grouping by stage; the data is walked once no matter how many stages you chain, with no intermediate lists allocated; and a short-circuiting terminal such as findFirst or anyMatch can stop asking the source early, which is why an unbounded source is not automatically a hang.
A Stream is a one-time view over a source, not a container. Each stage links to the one before it, and the pipeline flips an internal consumed flag as soon as a terminal runs, so a second terminal call, or attaching another intermediate stage to a stream you already used, throws IllegalStateException instead of silently rewalking the data; that strictness exists because sources like an open file or a generator may not be replayable at all. The underlying collection is untouched, so the fix is to rebuild the chain rather than store it, for example by keeping a Supplier<Stream<T>> and calling get() per run. And a pipeline with no terminal stage is not a slow pipeline, it is dead code: not one of its lambdas ever executes.
import java.util.List;
import java.util.stream.Stream;
public class Pipeline {
public static void main(String[] args) {
List<String> words = List.of("air", "breeze", "cloud", "dew");
// source + two intermediate stages: nothing has run yet
Stream<Integer> lengths = words.stream()
.map(w -> {
System.out.println("map " + w);
return w.length();
})
.filter(len -> {
System.out.println(" filter " + len);
return len > 3;
});
System.out.println("pipeline built, source untouched");
// terminal stage: this is what pulls elements through
List<Integer> kept = lengths.toList();
System.out.println("kept " + kept);
}
}A stream pipeline is a lazy description of work in which intermediate stages only record intent and the single terminal stage pulls each element through the whole chain once.
Worked examples
A stream is consumed once
Shows that a second terminal stage on the same stream variable fails instead of rerunning the work.
import java.util.List;
import java.util.stream.Stream;
public class OneShot {
public static void main(String[] args) {
Stream<String> tags = List.of("beta", "alpha", "gamma").stream()
.map(String::toUpperCase);
System.out.println("count " + tags.count());
try {
tags.forEach(System.out::println);
} catch (IllegalStateException e) {
System.out.println("reuse rejected: " + e.getMessage());
}
}
}Example explained
Line 1map returns a new Stream, so tags refers to the last stage of a pipeline, not to the list.
Line 2count() is terminal, so it evaluates the pipeline and marks it consumed.
Line 3forEach then finds the consumed flag set and throws before a single element is touched.
Line 4The list itself is unaffected: List.of(...).stream() can be called again any number of times.
Rebuild instead of reuse
Wraps the source and its intermediate stage in a Supplier so two terminal stages each get a fresh pipeline.
import java.util.List;
import java.util.function.Supplier;
import java.util.stream.Stream;
public class RebuildPipeline {
public static void main(String[] args) {
List<String> names = List.of("ada", "grace", "alan", "barbara");
Supplier<Stream<String>> longNames =
() -> names.stream().filter(n -> n.length() > 3);
System.out.println("how many " + longNames.get().count());
System.out.println("first " + longNames.get().findFirst().orElse("none"));
}
}Example explained
Line 1Each get() call builds a brand new source plus filter chain, so neither terminal stage sees a consumed pipeline.
Line 2count() returns 3 because ada is the only name of length 3 or less.
Line 3findFirst() stops at grace: the list source is ordered, so the first passing element ends the traversal.
An unbounded source is safe until pulled
Demonstrates that a short-circuiting terminal asks the source for only as many elements as it needs.
import java.util.stream.Stream;
public class Pull {
public static void main(String[] args) {
int firstBig = Stream.iterate(1, n -> n + 1)
.map(n -> {
System.out.println("source gave " + n);
return n * n;
})
.filter(square -> square > 30)
.findFirst()
.orElseThrow();
System.out.println("answer " + firstBig);
}
}Example explained
Line 1Stream.iterate describes an endless sequence, which is harmless because nothing is produced until a terminal stage asks.
Line 2findFirst is short-circuiting, so once one element reaches it, it stops requesting more upstream.
Line 3The mapper prints exactly six times, matching the elements the terminal needed; a stage-by-stage model would predict a hang.
Important notes
count() can skip your intermediate lambdas when the source size is known and no stage can change it, for example source plus map only, so never use count() to test whether a stage runs; add a filter or use toList().
Streams over external resources, such as Files.lines, hold a file handle that a terminal stage does not release, so wrap those in try-with-resources.
Common mistakes
Writing words.stream().filter(w -> w.length() > 3); as a statement and expecting something to happen: with no terminal stage the pipeline is only a description, the filter lambda never runs, and the list is of course unchanged.
Saving a stream in a variable and calling two terminal stages on it, such as count() then forEach: the second call throws IllegalStateException with the message stream has already been operated upon or closed.
Assuming stages run as separate passes (map everything, then filter everything), which produces wrong predictions about print order and about how many elements an expensive or infinite source is asked to produce.
Try it yourself
Change, predict, then run
Build List.of(3, 8, 1, 9, 4).stream().map(n -> n * 10) with a println inside the mapper, store it in a Stream<Integer> variable and run the program to confirm nothing prints. Then write down the print order you expect before adding .filter(n -> n > 50).toList(), run it, and compare with your prediction.
Open the Java workspaceCheck your understanding
For list.stream().map(f).filter(p).findFirst() over a 1000-element list where the very first element already passes p after mapping, how many times is f applied?
- 1000 times, because map completes over the whole list before filter starts
- 1 time, because the terminal stage pulls one element through the whole chain and findFirst then stops
- 0 times, because findFirst only needs the source element, not the mapped value
- 2 times, once when map is called and once when filter is called
Show answer
Elements are pulled individually through every stage, and findFirst short-circuits as soon as one element reaches it, so f runs once. The 1000 answer assumes each stage finishes over the entire source before the next begins, which is how chained loops or successive collection copies behave, not how a stream pipeline evaluates.