Continuum

11.3

Streams, and When the Work Happens

A stream is not a collection. It is a description of work that has not started yet, and knowing exactly when it starts explains almost everything surprising about them.

Core20 min read4 exercises
01

Previously on

At the end of Section 11.2 you built a pipeline out of functions:

Function<Integer, Integer> pipeline = addTwo.andThen(triple);

That line ran no arithmetic. It made a description, and the work waited for apply.

This section does the same thing to loops. Everything in it is Predicate and Function from Section 11.2, plus Iterable from Section 10.2. What is new is when the work happens.

02

The problem

Take the names longer than four letters, put them in capitals, and collect them.

List<String> result = new ArrayList<>();
for (String n : names) {
    if (n.length() > 4) {
        result.add(n.toUpperCase());
    }
}

That works. It is also four lines of bookkeeping around one idea, and the idea is buried.

Read what the code says out loud. Make an empty list. Walk the collection. Test each one. If it passes, transform it. Add the transformed value to the list you made earlier. Return the list.

Now read what you meant. Longer than four, in capitals.

Almost everything in the loop is machinery: the empty list, the walking, the adding. You write it every time, it is the same every time, and it is where the mistakes live.

Now chain three of them. Filter, transform, sort, take the first ten:

List<String> filtered = new ArrayList<>();
for (String n : names) {
    if (n.length() > 4) filtered.add(n);
}

List<String> shouted = new ArrayList<>();
for (String n : filtered) {
    shouted.add(n.toUpperCase());
}

Collections.sort(shouted);

List<String> firstTen = shouted.subList(0, Math.min(10, shouted.size()));

Three loops, two throwaway lists, and one subList call that is easy to get wrong. With a million names you have now built two intermediate lists of nearly a million entries each, and you wanted ten.

The waste is not the typing. It is that each stage runs to completion before the next one starts. The transform step processes everything, including the 999,990 entries that the last line is about to discard.

03

The idea

A stream lets you describe the stages, and then runs them together over one element at a time.

List<String> result = names.stream()
    .filter(n -> n.length() > 4)
    .map(String::toUpperCase)
    .toList();

filter takes a Predicate. map takes a Function. Both are from Section 11.2, and String::toUpperCase is a method reference. Nothing here is a new kind of thing.

Every stream has exactly three parts.

The shape of every stream, without exception

  1. A sourceWhere the elements come from. list.stream(), Arrays.stream(array), Stream.of("a", "b"), a file, a generator.
  2. Intermediate operations, none or manyfilter, map, sorted, distinct, limit. Each returns a new stream. None of them do any work.
  3. One terminal operationtoList, count, forEach, findFirst, reduce. This is what makes everything run, and there is exactly one.

Without a terminal operation, nothing runs. Not “runs and throws the answer away”. Nothing runs:

names.stream().filter(n -> {
    System.out.println("filter sees " + n);
    return n.length() > 4;
});

Run that. Nothing is printed. The lambda is never called, no element is looked at, and no exception is raised. The code compiles, runs, and does nothing at all.

Streams do not change the source. names is exactly as it was afterwards. A stream reads; it never writes back.

A stream can be infinite. That only makes sense because of the laziness:

Stream.iterate(1, x -> x * 2).limit(8).toList();
// [1, 2, 4, 8, 16, 32, 64, 128]

Stream.iterate describes an endless sequence. It does not build one. limit(8) says stop after eight, and because nothing runs until the terminal operation, only eight values are ever produced.

04

Under the hood

Going deeper

The most important fact about streams is not in any diagram. Print from inside the lambdas and read the order.

List<String> names = List.of("Aditya", "Rohit", "Rohan", "Sonu");

names.stream()
    .filter(n -> { System.out.println("filter " + n); return n.length() > 4; })
    .map(n -> { System.out.println("    map " + n); return n.toUpperCase(); })
    .toList();

Here is the real output:

filter Aditya
    map Aditya
filter Rohit
    map Rohit
filter Rohan
    map Rohan
filter Sonu
result [ADITYA, ROHIT, ROHAN]

Read it twice. filter did not run on all four names before map ran on any. They take turns. Aditya goes through the filter and then straight through the map. Only then does Rohit start.

That is the opposite of what the code looks like. The code looks like two stages, one after the other. What actually happens is one element at a time, carried all the way down.

Look at the last two lines. Sonu reached the filter and failed, so map never saw it. In the three loop version, the intermediate list would have held Sonu until the next loop dropped it.

How does that work? Each intermediate operation builds a small object holding your lambda and a link to the next stage. filter does not filter. It returns a new stream that remembers a Predicate and knows who comes after it.

When the terminal operation arrives, it walks the chain backwards to the source. There it gets a Spliterator, which is the iterator from Section 10.2 with extra abilities, and pulls elements one at a time. Each element is pushed through every stage before the next is pulled.

Short-circuiting comes free. Once the machinery is one element at a time, some terminal operations can stop early:

names.stream()
    .filter(n -> { System.out.println("checking " + n); return n.startsWith("R"); })
    .findFirst();
checking Aditya
checking Rohit
found Rohit

Two of four. findFirst got its answer and stopped, and Rohan and Sonu were never looked at. anyMatch, allMatch, noneMatch and limit all do the same. This is why Stream.iterate(...).limit(8) terminates on an endless source.

A stream is a walk, not a container. Use it twice and it says so:

Stream<String> s = names.stream();
s.count();
s.count();   // IllegalStateException: stream has already been operated upon or closed

It has more in common with an Iterator than with the list. Once walked, it is finished. Making another is cheap: call names.stream() again.

Where did stream() come from? It was added to Collection in Java 8 as a default method. It had to be. Any other kind of method on an interface would have broken every class that already implemented Collection, including yours. Section 8.5 said default methods existed for exactly this, and this is the case they were built for.

05

What it costs

The loop is not always the loser, and there are four places where it wins.

Debugging is the obvious one. A for loop takes a breakpoint on any line and shows you every variable. A stream is one statement, and stepping through it drops you into the JDK’s internals with names like ReferencePipeline$3$1.accept. Printing from inside a lambda works, and it is often the only thing that does.

Stack traces get worse in the same way. A failure inside a stream produces fifteen frames of pipeline machinery around the one frame that is yours. The line number is right, and you have to hunt for it.

Then there is the cost you can measure. A plain loop over an int[] is hard to beat. A stream builds pipeline objects, calls through an interface at every stage, and boxes if you use the object versions. On a small collection in a hot loop it can be several times slower, which Section 11.6 comes back to with numbers.

The last one is quieter and does the most damage. A stream with no terminal operation does nothing, and looks fine:

list.stream().map(String::toUpperCase);   // compiles, runs, changes nothing

No error, no warning, no output. It is the functional version of writing x + 1; on a line by itself, and it is easier to miss because the line looks busy.

06

Check yourself

Answer from memory before opening the explanation. Then compare your rule with the answer and revisit the example if they differ.

  1. You write a stream with `filter` and `map` and no terminal operation. What runs?

    Show the answer

    Nothing. Not one element is looked at, and neither lambda is ever called.

    Intermediate operations do not do work. Each one returns a new stream carrying a note of what was asked for. The notes pile up and sit there.

    A terminal operation is what makes any of it run. Until one arrives, you have written a description of some work, and descriptions do nothing.

    This is also why a stream with no terminal operation is a silent bug. The code compiles, it runs, no exception appears, and your list is unchanged.

  2. For a list of four names, does `filter` run on all four before `map` runs on any?

    Show the answer

    No. They interleave, one element at a time.

    The real order, printed from inside the lambdas: filter Aditya, map Aditya, filter Rohit, map Rohit, filter Rohan, map Rohan, filter Sonu. Then the answer.

    Each element is carried the whole way down the pipeline before the next one starts. Sonu fails the filter, so map never sees it at all.

    That is why a stream over a million elements does not build a million element list at every stage. There is only ever one element in flight.

  3. Why can a stream not be used twice?

    Show the answer

    Because it holds a position, not data. A stream is closer to the iterator from Section 10.2 than to the list it came from.

    Once a terminal operation has walked it, the walk is over. Ask for another and you get IllegalStateException: stream has already been operated upon or closed.

    The fix is to make a new one. list.stream() is cheap and creates a fresh walk over the same list, so call it again rather than storing a stream in a variable and reaching for it twice.

07

Exercises

Write each solution in VS Code, predict its result, and then run the checks. Reading an example does not replace implementing it.

4 exercises80 pointsabout 80 minutes

The Continuum VS Code extension runs the checks for exercises marked checked. For a manual exercise, run the program and compare its behaviour with the stated requirements and sample output.
A

Prove Nothing Ran

Warm up·15 min·15 points

ex-11-3-a

Two runs and one prediction, and the prediction is the exercise.

The first run should print nothing. Not an empty result, not a warning: nothing at all. Sit with that for a moment, because a stream that silently does nothing is a bug you will meet for real one day.

The third part is the one that matters. Write down the order you expect before you run it. Most people expect all the filtering, then all the mapping. That is not what happens, and seeing your own prediction be wrong is worth more than reading the right answer here.

What your program must do

  • Write a filter that prints, with no terminal operation, and record what appears
  • Add a terminal operation and run it again
  • Add a printing map as well, and predict the order of the lines before running
  • Say which name reaches the filter but never reaches the map, and why
NothingRan.java
import java.util.*;

public class NothingRan {
    public static void main(String[] args) {
        List<String> names = List.of("Aditya", "Rohit", "Rohan", "Sonu");

        // TODO: a stream with a filter that PRINTS, and no terminal operation.
        //       Predict the output before running.

        // TODO: now add .toList() to the end and run it again

        // TODO: put a print inside a map as well, and predict the ORDER of the
        //       two kinds of line before running. This is the important one.
    }
}
Hint 1
With no terminal operation there is no output at all. The lambda is never called even once, and no exception is raised.
Hint 2
Do not expect all four filter lines and then all the map lines. Predict again before you run it.
Hint 3almost the answer
The order is filter Aditya, map Aditya, filter Rohit, map Rohit, and so on. Each name is carried the whole way down before the next one starts. Sonu fails the filter, so map never sees it.
What this is really testing

Whether you believe intermediate operations are lazy, or have watched them not happen. This is the one fact that explains every other surprise about streams.

B

Count What the Filter Sees

Real work·20 min·20 points

ex-11-3-b

Put a counter inside the filter and let the terminal operation tell you how much work it wanted.

Every number you get is a fact about the terminal operation, not about the filter. The same filter over the same thousand numbers gets asked for wildly different amounts of work depending on what is on the end of the chain.

Save the endless stream for last, and read the hint before you run it without a limit. It will not stop on its own.

What your program must do

  • Count how many elements findFirst looks at, and compare with toList
  • Do the same for anyMatch, allMatch and noneMatch, and explain each number
  • Build an endless stream and cut it off with limit
  • Say why limit on an endless source works at all
ShortCircuit.java
import java.util.*;
import java.util.stream.*;

public class ShortCircuit {
    static int checks = 0;

    public static void main(String[] args) {
        List<Integer> numbers = new ArrayList<>();
        for (int i = 1; i <= 1000; i++) numbers.add(i);

        // TODO: findFirst on a filter that counts every check.
        //       How many of the 1000 get looked at?

        // TODO: same filter with toList instead. How many now?

        // TODO: anyMatch, allMatch, noneMatch. Count for each and explain the numbers.

        // TODO: Stream.iterate(1, x -> x * 2) with a limit. Then try it WITHOUT
        //       the limit and be ready to stop the program yourself.
    }
}
Hint 1
Increase a counter inside the filter lambda. Reset it to zero between the runs, or the numbers will not mean anything.
Hint 2
allMatch stops at the first element that fails, and anyMatch stops at the first that passes. So the count depends on your data as much as your code.
Hint 3almost the answer
limit works on an endless source because nothing runs until the terminal operation, and then only as much as is asked for. The source is a rule for making the next value, not a list of values.
What this is really testing

Whether short-circuiting is a word you know or a thing you have counted. On an endless source it is the difference between an answer and a program that never stops.

C

The Same Job, Both Ways

Real work·25 min·25 points

ex-11-3-c

Write both, then look at what the loop version had to keep.

Two lists of nearly a million entries were built so that ten could be returned. Nothing about the code looks wasteful. The waste is that each stage finished completely before the next one started, and the last line then threw almost all of it away.

The final part is the one to spend time on. Move limit(10) above the map and then below it. The answer does not change. The number of times your lambda runs changes enormously, and that is a thing you now have a reason to think about every time you write a chain.

What your program must do

  • Write the job with loops and count the intermediate lists and their sizes
  • Write it as a stream and confirm the answers match
  • Time both versions
  • Put a print inside the stream's map and say how many times it runs, and why
BothWays.java
import java.util.*;
import java.util.stream.*;

public class BothWays {
    public static void main(String[] args) {
        List<String> names = new ArrayList<>();
        for (int i = 0; i < 1_000_000; i++) names.add("name" + i);

        // Job: names longer than 7 characters, in capitals, first ten only.

        // TODO: write it with loops. Count the intermediate lists you had to build
        //       and how many entries each one held.

        // TODO: write it as one stream.

        // TODO: check the two answers match, then time both

        // TODO: add a print inside the stream's map. How many times does it run?
    }
}
Hint 1
The loop version needs one list for the filtered names and another for the capitalised ones. Both hold close to a million entries, and you wanted ten.
Hint 2
The stream version is filter, then map, then limit(10), then toList.
Hint 3almost the answer
Put limit(10) before map and the map runs ten times. Put it after and it runs on every name that passed the filter. Same answer, very different amount of work, and the order of the lines is the only difference.
What this is really testing

Whether you can see what the loop version spends that the stream version does not. The answer is memory, and on a big list it is most of the memory.

D

Use It Twice

Real work·20 min·20 points

ex-11-3-d

A stream keeps a place in a walk. It does not keep the elements.

Once you have that, the exception explains itself. The walk is finished, and asking a finished walk to start again is not a thing it can do. The list is untouched and perfectly happy to give you another stream, which is why the fix is one word.

The third part is worth predicting before running. Making a stream reads nothing, so adding to the list afterwards still counts. It follows from laziness, and it catches people out because it feels like the stream should have taken a snapshot.

What your program must do

  • Reuse one stream and record the exact exception message
  • Fix it without storing the stream in a variable
  • Add to the list after making the stream but before the terminal operation, and predict the result
  • Confirm whether a map changes the list it came from
Twice.java
import java.util.*;
import java.util.stream.*;

public class Twice {
    public static void main(String[] args) {
        List<String> names = new ArrayList<>(List.of("Aditya", "Rohit", "Rohan"));

        // TODO: store a stream in a variable, use a terminal operation twice,
        //       and read the exception message carefully

        // TODO: fix it, without storing the stream

        // TODO: make a stream, then ADD to the list, then run the terminal
        //       operation. Does the new name appear? Predict first.

        // TODO: does the source list change after a map to uppercase? Check.
    }
}
Hint 1
The message is stream has already been operated upon or closed. It is an IllegalStateException, not an IllegalArgumentException, because nothing was wrong with what you passed. Something was wrong with when you asked.
Hint 2
The fix is to call names.stream() again. Making a stream is cheap. Storing one and reusing it is the mistake.
Hint 3almost the answer
The late addition does appear, because the source is not read until the terminal operation runs. That is the same laziness as everything else in this section, showing up somewhere you might not have expected it.
What this is really testing

Whether you think of a stream as a container or as a position. Everything about reuse follows from which of those two it actually is.

08

After the credits

You have filter, map and toList. There are about thirty more, and they divide along the line this section drew.

Section 11.4 goes through them, and the division is the one you already know. Intermediate operations return a stream and do nothing: sorted, distinct, skip, peek, flatMap. Terminal operations produce an answer and make everything run: count, reduce, min, anyMatch, collect.

collect is the one worth waiting for. toList is the easy case and collect is the general one. It can group employees by department into a Map<String, List<Employee>> in a single line, using the HashMap from Section 10.5 without ever naming it.

There is also a gap in what you have seen. findFirst returned something that was not a String:

Optional<String> first = names.stream().filter(...).findFirst();

That Optional is Section 11.5, and it is Java’s answer to a problem older than streams.

Threads you opened in this section