Skip to content
Path to Engineer
All lessons
JavaCollections14 min read

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.

Day 59 of the 488-day pathway. Published in full — nothing held back.

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 map on 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:

  • sorted must consume the ENTIRE stream before emitting anything. On an infinite stream it never terminates.
  • distinct must 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:

  1. Vertical, not horizontal. Each element traverses the full pipeline before the next starts — not filter-everything-then-map-everything. That means no intermediate collections.
  2. Short-circuiting works. findFirst, anyMatch and limit stop 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:

  1. The laziness trace — the last two elements are never touched.
  2. The pipeline with no terminal operation prints nothing at all.
  3. takeWhile versus filter — 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, anyMatch and limit can stop consuming the source early. It's also what allows infinite sources to work.

Q: What's the difference between map and flatMap?

map transforms each element one-to-one. flatMap takes 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?

sorted and distinct primarily. sorted must consume the entire stream before emitting anything, so it never terminates on an infinite source. distinct must retain every element seen, so memory grows with the distinct count. Stateless operations like filter and map process one element at a time.

Q: filter or takeWhile?

filter examines every element and keeps those matching. takeWhile emits 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

  1. Run StreamsDemo. Confirm the laziness trace and the loop-versus-stream timings.
  2. Build a pipeline over an infinite stream that terminates. Then add sorted() before limit() and observe what happens.
  3. Flatten a Map<String, List<Order>> into a stream of orders.
  4. Compute five statistics over a collection twice — once with five traversals, once with summaryStatistics().
  5. Write a stream that reads a file with Files.lines and closes it properly.

Exit questions

  1. Name five differences between a stream and a collection.
  2. What are the three parts of a pipeline, and which are lazy?
  3. Explain what happens element-by-element in a lazy pipeline.
  4. Why can a stream have an infinite source?
  5. Which intermediate operations are stateful, and what does that cost?
  6. map vs flatMap — with an example.
  7. filter vs takeWhile?
  8. Why do primitive streams exist?
  9. What's the difference between toList() and collect(Collectors.toList())?
  10. 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