Java streams: sources, laziness and terminal operations
Why nothing happens until the terminal operation, what separates intermediate from terminal ops, and where streams stop being the clearer choice.
Streams I — sources, intermediate vs terminal ops, laziness
No parallel track. Laziness is the part people don't understand, and it's what makes streams different from just calling
mapon a list.
L-059 · Streams
What a stream is — and isn't
A stream is a pipeline for processing a sequence of elements. It is not a data structure.
| Collection | Stream | |
|---|---|---|
| Stores elements | ✅ | ❌ — it computes them |
| Can iterate more than once | ✅ | ❌ single-use |
| Evaluation | Eager | Lazy |
| Modifies the source | Possibly | ❌ Never |
| Bounded | Yes | Can be infinite |
Stream<String> s = list.stream();
s.forEach(System.out::println);
s.forEach(System.out::println); // 💥 IllegalStateException: stream has already been operated upon
A stream is consumed by its terminal operation. To process twice, create two streams.
The three parts of a pipeline
list.stream() // SOURCE
.filter(s -> s.length() > 3) // INTERMEDIATE (lazy, returns a Stream)
.map(String::toUpperCase) // INTERMEDIATE
.limit(5) // INTERMEDIATE
.toList(); // TERMINAL (eager, produces a result)
Nothing runs until the terminal operation. That's the whole design.
Sources
collection.stream() // any Collection
Arrays.stream(array)
Stream.of("a", "b", "c")
Stream.iterate(1, n -> n * 2) // INFINITE
Stream.iterate(1, n -> n < 100, n -> n * 2) // Java 9 — with a predicate, finite
Stream.generate(Math::random) // INFINITE
IntStream.range(0, 10) // 0..9 (exclusive)
IntStream.rangeClosed(1, 10) // 1..10 (inclusive)
Files.lines(path) // lines of a file — MUST be closed
"a,b,c".chars() // IntStream of code units
Pattern.compile(",").splitAsStream("a,b,c")
Files.lines returns a stream backed by an open file handle — use it in try-with-resources
(Day 061), or you leak a descriptor (Day 027).
Intermediate operations — all lazy
| Operation | Does | Stateless? |
|---|---|---|
filter |
Keep matching | ✅ |
map |
Transform 1→1 | ✅ |
flatMap |
Transform 1→many, flatten | ✅ |
mapMulti |
Java 16 — 1→many without a Stream per element | ✅ |
peek |
Side effect, pass through | ✅ |
distinct |
Remove duplicates (by equals) |
❌ stateful |
sorted |
Sort | ❌ stateful, fully buffering |
limit |
First n | ❌ short-circuiting |
skip |
Drop first n | ❌ |
takeWhile / dropWhile |
Java 9 — prefix-based | ❌ |
Stateful operations must see elements before emitting, which changes the pipeline's behaviour:
sortedmust consume the ENTIRE stream before emitting anything. On an infinite stream it never terminates.distinctmust remember everything seen — memory grows with distinct count.
That distinction matters for both correctness and memory.
Terminal operations — eager
// Reduction
count() sum() min() max() average() reduce()
// Collection
toList() // Java 16 — returns an UNMODIFIABLE list
collect(Collectors.toList()) // modifiable
toArray()
// Search — SHORT-CIRCUITING
anyMatch() allMatch() noneMatch() findFirst() findAny()
// Iteration
forEach() forEachOrdered()
toList() (Java 16+) returns an unmodifiable list, unlike collect(Collectors.toList()). A real
difference that catches people migrating.
Laziness — the core idea
Elements flow through the whole pipeline one at a time, not stage by stage.
List.of("a", "bb", "ccc", "dddd").stream()
.peek(s -> System.out.println("filter sees: " + s))
.filter(s -> s.length() > 2)
.peek(s -> System.out.println(" map sees: " + s))
.map(String::toUpperCase)
.findFirst();
Output:
filter sees: a
filter sees: bb
filter sees: ccc
map sees: ccc
Notice what did NOT happen: "dddd" was never examined at all. findFirst short-circuited as
soon as it had an answer.
Two consequences:
- Vertical, not horizontal. Each element traverses the full pipeline before the next starts — not filter-everything-then-map-everything. That means no intermediate collections.
- Short-circuiting works.
findFirst,anyMatchandlimitstop the source early.
This is why streams can handle infinite sources:
Stream.iterate(1, n -> n * 2) // infinite
.filter(n -> n % 3 == 0)
.limit(5) // ← makes it finite
.toList(); // [6, 12, 24, 48, 96]
Without laziness this could never terminate.
map vs flatMap
List<List<String>> nested = List.of(List.of("a","b"), List.of("c","d"));
nested.stream().map(List::size); // Stream<Integer> — 1 to 1
nested.stream().flatMap(List::stream); // Stream<String> — 1 to many, FLATTENED
flatMap takes a function returning a stream and concatenates the results.
// Words from sentences
sentences.stream()
.flatMap(s -> Arrays.stream(s.split(" ")))
.distinct()
.toList();
// Drop empties (Day 058)
optionals.stream().flatMap(Optional::stream).toList();
The mental model: map reshapes each element; flatMap reshapes and flattens one level.
Primitive streams
IntStream.rangeClosed(1, 5).sum(); // no boxing
list.stream().mapToInt(String::length).sum(); // Stream<String> → IntStream
intStream.boxed(); // IntStream → Stream<Integer>
intStream.average(); // OptionalDouble
intStream.summaryStatistics(); // count, sum, min, max, average in ONE pass
IntStream, LongStream and DoubleStream avoid boxing (Days 028, 057). In a hot loop the
difference is substantial.
summaryStatistics() is underused — it computes five aggregates in a single pass instead of five
separate traversals.
Streams are not always the answer
| Use a stream | Use a loop |
|---|---|
| Transforming, filtering, aggregating | Simple iteration with side effects |
| The pipeline reads declaratively | You need index access |
| You want easy parallelism | You need to break with complex conditions |
| Chained transformations | Checked exceptions in the body (Day 057) |
| Performance-critical hot paths |
A plain for loop is often faster for small collections — no stream setup, no lambda
indirection. The JIT optimises loops extremely well.
Never use forEach to mutate external state:
List<String> results = new ArrayList<>();
stream.forEach(results::add); // 💀 — and unsafe in parallel
List<String> results = stream.toList(); // ✅
That defeats the point and breaks under parallelStream (Day 060).
Type this yourself
import java.util.*;
import java.util.stream.*;
public class StreamsDemo {
public static void main(String[] args) {
// ---- 1. Laziness, visible ----
System.out.println("--- laziness ---");
Optional<String> first = Stream.of("a", "bb", "ccc", "dddd", "eeeee")
.peek(s -> System.out.println(" filter sees: " + s))
.filter(s -> s.length() > 2)
.peek(s -> System.out.println(" map sees: " + s))
.map(String::toUpperCase)
.findFirst();
System.out.println(" result: " + first.orElse("?"));
System.out.println(" ↑ 'dddd' and 'eeeee' were NEVER examined");
// ---- 2. Nothing runs without a terminal op ----
System.out.println("\n--- no terminal operation ---");
Stream<String> lazy = Stream.of("x", "y")
.peek(s -> System.out.println(" THIS SHOULD NOT PRINT: " + s));
System.out.println(" pipeline built, nothing executed");
// ---- 3. Single use ----
System.out.println("\n--- single use ---");
Stream<String> once = Stream.of("a", "b");
once.count();
try { once.count(); }
catch (IllegalStateException e) { System.out.println(" second use: " + e.getMessage()); }
// ---- 4. Infinite streams ----
System.out.println("\n--- infinite source, made finite ---");
System.out.println(" powers of 2 divisible by 3: "
+ Stream.iterate(1, n -> n * 2).filter(n -> n % 3 == 0).limit(5).toList());
System.out.println(" Java 9 3-arg iterate: "
+ Stream.iterate(1, n -> n < 100, n -> n * 3).toList());
// ---- 5. sorted() must buffer EVERYTHING ----
System.out.println("\n--- stateful ops ---");
System.out.println(" sorted() on an infinite stream would never terminate");
System.out.println(" distinct() must remember every element seen");
// ---- 6. map vs flatMap ----
System.out.println("\n--- map vs flatMap ---");
List<List<String>> nested = List.of(List.of("a","b"), List.of("c","d","e"));
System.out.println(" map(List::size): " + nested.stream().map(List::size).toList());
System.out.println(" flatMap(List::stream):" + nested.stream().flatMap(List::stream).toList());
List<String> sentences = List.of("the quick brown", "fox jumps over", "the lazy dog");
System.out.println(" distinct words: " + sentences.stream()
.flatMap(s -> Arrays.stream(s.split(" ")))
.distinct().sorted().toList());
// ---- 7. Primitive streams ----
System.out.println("\n--- primitive streams ---");
IntSummaryStatistics stats = IntStream.rangeClosed(1, 100).summaryStatistics();
System.out.printf(" 1..100 count=%d sum=%d min=%d max=%d avg=%.1f (ONE pass)%n",
stats.getCount(), stats.getSum(), stats.getMin(), stats.getMax(), stats.getAverage());
List<String> words = List.of("apple", "fig", "banana");
System.out.println(" total length: " + words.stream().mapToInt(String::length).sum());
// ---- 8. takeWhile vs filter ----
System.out.println("\n--- takeWhile vs filter ---");
List<Integer> nums = List.of(1, 2, 3, 10, 4, 5);
System.out.println(" filter(<5): " + nums.stream().filter(n -> n < 5).toList());
System.out.println(" takeWhile(<5): " + nums.stream().takeWhile(n -> n < 5).toList());
System.out.println(" dropWhile(<5): " + nums.stream().dropWhile(n -> n < 5).toList());
System.out.println(" ↑ takeWhile STOPS at the first failure; filter checks everything");
// ---- 9. toList() is unmodifiable ----
System.out.println("\n--- toList() vs collect(toList()) ---");
List<String> immutable = Stream.of("a").toList();
List<String> mutable = Stream.of("a").collect(Collectors.toList());
try { immutable.add("b"); }
catch (UnsupportedOperationException e) { System.out.println(" toList() → immutable"); }
mutable.add("b");
System.out.println(" collect(toList()) → modifiable: " + mutable);
// ---- 10. Streams aren't always faster ----
int n = 20_000_000;
int[] data = new int[n];
for (int i = 0; i < n; i++) data[i] = i;
long start = System.currentTimeMillis();
long sum1 = 0;
for (int v : data) if (v % 2 == 0) sum1 += v;
long loopTime = System.currentTimeMillis() - start;
start = System.currentTimeMillis();
long sum2 = Arrays.stream(data).filter(v -> v % 2 == 0).asLongStream().sum();
long streamTime = System.currentTimeMillis() - start;
System.out.printf("%n--- loop vs stream over %,d ints ---%n", n);
System.out.printf(" for loop: %,5d ms%n", loopTime);
System.out.printf(" stream: %,5d ms%n", streamTime);
System.out.println(" (sums equal: " + (sum1 == sum2) + ")");
}
}
Three things to observe:
- The laziness trace — the last two elements are never touched.
- The pipeline with no terminal operation prints nothing at all.
takeWhileversusfilter— one stops at the first failure, the other examines everything.
Common mistakes
| Mistake | Correction |
|---|---|
| Reusing a stream | Single-use. Create a new one. |
| Expecting a pipeline to run without a terminal op | Intermediates are lazy; nothing happens. |
sorted() on an infinite stream |
It must buffer everything — never terminates. |
forEach to build a collection |
Use toList or collect. Mutating external state breaks in parallel. |
Assuming toList() is modifiable |
Java 16's toList() is unmodifiable. |
peek for anything but debugging |
Its execution isn't guaranteed if the pipeline can skip it. |
Not closing Files.lines |
Leaks a file descriptor. Use try-with-resources. |
| Streams everywhere | A plain loop is often clearer and faster. |
Interview questions
Q: What is a stream, and how does it differ from a collection?
A pipeline for processing a sequence, not a data structure — it stores nothing and computes elements on demand. It's single-use, consumed by its terminal operation, never modifies its source, is lazily evaluated, and can be infinite. A collection stores elements and can be traversed repeatedly.
Q: What does laziness mean here?
Intermediate operations build a pipeline but don't execute; nothing runs until a terminal operation. Then elements flow through the entire pipeline one at a time rather than stage by stage, so there are no intermediate collections and short-circuiting operations like
findFirst,anyMatchandlimitcan stop consuming the source early. It's also what allows infinite sources to work.
Q: What's the difference between map and flatMap?
maptransforms each element one-to-one.flatMaptakes a function returning a stream and concatenates the results, flattening one level — turning a stream of lists into a stream of their elements.
Q: Which intermediate operations are stateful, and why does it matter?
sortedanddistinctprimarily.sortedmust consume the entire stream before emitting anything, so it never terminates on an infinite source.distinctmust retain every element seen, so memory grows with the distinct count. Stateless operations likefilterandmapprocess one element at a time.
Q: filter or takeWhile?
filterexamines every element and keeps those matching.takeWhileemits elements from the start until the predicate first fails, then stops — so it only makes sense on an ordered stream and it short-circuits.
Q: Are streams faster than loops?
Usually not for simple operations on small collections — there's pipeline setup and lambda indirection, and the JIT optimises plain loops extremely well. Streams win on readability for chained transformations, and on parallelism when the work per element is genuinely substantial.
Mini task
- Run
StreamsDemo. Confirm the laziness trace and the loop-versus-stream timings. - Build a pipeline over an infinite stream that terminates. Then add
sorted()beforelimit()and observe what happens. - Flatten a
Map<String, List<Order>>into a stream of orders. - Compute five statistics over a collection twice — once with five traversals, once with
summaryStatistics(). - Write a stream that reads a file with
Files.linesand closes it properly.
Exit questions
- Name five differences between a stream and a collection.
- What are the three parts of a pipeline, and which are lazy?
- Explain what happens element-by-element in a lazy pipeline.
- Why can a stream have an infinite source?
- Which intermediate operations are stateful, and what does that cost?
mapvsflatMap— with an example.filtervstakeWhile?- Why do primitive streams exist?
- What's the difference between
toList()andcollect(Collectors.toList())? - When is a loop the better choice?
Articulation drill
Two minutes: "What does it mean that streams are lazy, and why does it matter?"
Nothing runs until the terminal op; elements flow vertically through the whole pipeline; therefore short-circuiting works and infinite sources are usable.
Previous: Day 058 · Tomorrow: Day 060 — collectors, grouping, and the real cost of parallel streams