Lesson 08 / 15
Stream API
Java's stream chain guarantees three of ten operations independent of the source, defers five to the source's order, and leaves two to the caller. An intermediate operation never runs without a terminal operation, but even once a terminal operation gets called, which item gets visited is not guaranteed. Consuming the same stream twice and growing the source while iterating both fall with an exception.
Contents
The previous lesson measured what a single lambda expression captures from its surroundings, and showed that four syntax forms bind to the same target. The standard library’s real form of use is not a single lambda like this, it is several lambdas chained one after another — a stream chain. Even though every link works correctly on its own, a separate question is born once it gets strung into a chain: which part of the chain depends on the source’s order, which one gives the same order under every condition, which one never runs without a terminal operation, and which rule is left entirely to the caller?
This lesson classifies ten separate claims by running them: for each
one, is the observed behavior independent of the source (an
interface guarantee), bound to the source’s own order, or a rule
the caller follows? The measurement’s shape is the same as before
— code gets written, run, the result gets compared — but what gets
compared here is not two implementations, it is the same operation’s
behavior on two separate sources. To avoid confusion with java.io
streams, every bare “stream” word used from here on gets the qualifier
from this paragraph: the stream chain, or stream for short.
The Stream Chain’s Order Comes from the Source, Not the Interface
- FJ17 — The same five words get held in two sources: an ordered List and an unordered HashSet. The same stream chain runs on both, and the results get compared with equals; the raw iteration order never gets printed anywhere.
- FJ18 —
sorted()runs with natural order; uppercasing happens withLocale.ROOT. - FJ19 —
distinct()andfindFirst()get tried on the ordered source; they do not get repeated with the unordered source, because what gets measured is the existence of first-seen order, and this concept is undefined on an unordered source.
// StreamOrder.java - does the stream chain's order come from the source or the interface
import java.util.*;
import java.util.stream.*;
public class StreamOrder {
public static void main(String[] args) {
List<String> orderedSource = List.of("pear", "cherry", "apple", "grape", "fig");
Set<String> unorderedSource = new HashSet<>(orderedSource);
List<String> sorted1 = orderedSource.stream().sorted().toList();
List<String> sorted2 = unorderedSource.stream().sorted().toList();
System.out.println("sorted(), alphabetical order on ordered source : "
+ sorted1.equals(List.of("apple", "cherry", "fig", "grape", "pear")));
System.out.println("sorted(), same result on unordered source : " + sorted2.equals(sorted1));
List<String> expectedUpper = List.of("PEAR", "CHERRY", "APPLE", "GRAPE", "FIG");
List<String> mappedOrdered = orderedSource.stream().map(x -> x.toUpperCase(Locale.ROOT)).toList();
List<String> mappedUnordered = unorderedSource.stream().map(x -> x.toUpperCase(Locale.ROOT)).toList();
System.out.println("map(), keeps input order on ordered source : " + mappedOrdered.equals(expectedUpper));
System.out.println("map(), keeps input order on unordered source : " + mappedUnordered.equals(expectedUpper));
List<String> expectedLong = List.of("pear", "cherry", "apple", "grape");
List<String> filtered = orderedSource.stream().filter(x -> x.length() > 3).toList();
System.out.println("filter(), keeps order on ordered source : " + filtered.equals(expectedLong));
List<String> repeated = List.of("pear", "cherry", "pear", "apple", "cherry");
List<String> unique = repeated.stream().distinct().toList();
System.out.println("distinct(), kept in first-seen order : "
+ unique.equals(List.of("pear", "cherry", "apple")));
Optional<String> first1 = orderedSource.stream().findFirst();
Optional<String> first2 = orderedSource.stream().findFirst();
System.out.println("findFirst(), do two calls give the same item : " + first1.equals(first2));
}
}
sorted(), alphabetical order on ordered source : true sorted(), same result on unordered source : true map(), keeps input order on ordered source : true map(), keeps input order on unordered source : false filter(), keeps order on ordered source : true distinct(), kept in first-seen order : true findFirst(), do two calls give the same item : true
The first two lines measure a guarantee independent of the source:
sorted() produces alphabetical order on the ordered source, and
gives the same result on the unordered HashSet source too.
Whatever container the input gets held in, sorted() fulfills its own
promise on its own — this is an interface guarantee, and it needs
nothing from the source’s own order.
The third and fourth lines show exactly the opposite. map() carries
the input’s order on the ordered List source (true), but does not
carry that order on the unordered HashSet source, even though it
works on the same words (false). map() itself never changed; what
changed was the source. The order promise belongs not to map(),
but to the container underneath it — this is the lesson’s boundary
measurement, and it confirms the topic’s claim: the sentence “a stream
keeps order” is the source’s promise, not the stream’s.
The remaining three lines fall into the same source-dependent set.
filter() keeps the order of the remaining items on the ordered
source; distinct() keeps the first-seen one among duplicates and
does not disturb the order; findFirst() gives the same item on two
separate calls on the ordered source. What the three share: what
decides the result is the source’s encounter order, and this order
exists only when the source is ordered. Repeated with an unordered
source, the concept of “first seen” or “first item” itself becomes
undefined.
This lesson uses “source-dependent” as a separate bucket, because the
trio’s middle term from the collections topic — implementation
behavior — does not correspond to a different choice here. There,
what got chosen was a separate class, like HashMap versus TreeMap;
here, map() itself never changes, the only thing that changes is the
source’s structure (ordered or not). The outcome comes out the
same direction: the observation is in the hands of a third party
invisible in the call’s writing, and that party in this topic is not
the interface, it is the source.
The same row also has a looser end. findFirst() says “give the same
item on an ordered source”; the neighboring method findAny() gives
no such promise, it only guarantees returning an item that
matches.
- FJ20 —
findFirst()andfindAny()get called separately on the same ordered source, from the same stream chain, and the returned values get compared with equals.
// FindAny.java - do findFirst and findAny give the same promise
import java.util.*;
public class FindAny {
public static void main(String[] args) {
List<String> source = List.of("pear", "cherry", "apple", "grape", "fig");
Optional<String> first = source.stream().findFirst();
Optional<String> any = source.stream().findAny();
System.out.println("findFirst() result: " + first.get());
System.out.println("findAny() result : " + any.get());
System.out.println("are the two equal : " + first.equals(any));
}
}
findFirst() result: pear findAny() result : pear are the two equal : true
In this ordered, single-threaded stream chain, the two give the same
item — the API’s looseness never shows up here, because putting that
looseness to actual use only comes up once the stream gets
parallelized, and this course does not measure stream
parallelization. The only thing measured is this: two separate methods
giving the same result with two separate promises shows that a
promise’s tightness can be independent of the outcome — findAny()
promises less, but we do not pay this lesser promise’s cost in this
run. findAny() does not enter the table, because the one promise it
gives — “a matching item” — is already unconditional; the only thing
bound to the source is findFirst()’s “which item” question.
An Intermediate Operation Never Runs Without a Terminal
Intermediate operations in a stream chain, like map, filter,
sorted, do not run the moment they get built; they only get added to
the chain. This is the here-asked form of lazy evaluation, a
concept built in a previous topic — the concept does not get
reopened, the question asked is this: is this laziness the stream
chain’s promise, or is it only an implementation detail?
- FJ21 — The same intermediate operation gets built three times:
with no terminal at all, with the
count()terminal, and with thetoList()terminal. Each time, a counter counts how many times the intermediate operation ran.
// Laziness.java - does an intermediate operation run without a terminal operation
import java.util.*;
import java.util.stream.*;
public class Laziness {
public static void main(String[] args) {
List<String> source = List.of("pear", "cherry", "apple", "grape", "fig");
int[] counter1 = {0};
Stream<String> pipeline = source.stream().peek(x -> counter1[0]++);
System.out.println("steps run with no terminal called : " + counter1[0]);
int[] counter2 = {0};
long length = source.stream().peek(x -> counter2[0]++).count();
System.out.println("steps run with the count() terminal : " + counter2[0] + " (length=" + length + ")");
int[] counter3 = {0};
source.stream().peek(x -> counter3[0]++).toList();
System.out.println("steps run with the toList() terminal : " + counter3[0]);
}
}
steps run with no terminal called : 0 steps run with the count() terminal : 0 (length=5) steps run with the toList() terminal : 5
The first line gives the expected result: when no terminal ever gets called, the intermediate operation never runs, the counter stays at zero. This is an unconditional guarantee — the stream chain’s own promise — and this is the extent of lazy evaluation’s counterpart here from the fourth topic.
The second line narrows this guarantee in one direction. Even though
count() gets called, the counter is still zero: the length got
computed correctly (5), but the intermediate operation never ran.
The reason is in the source itself: the list List.of(...) produces
already knows its size, and once an intermediate operation that does
not change item count, like map/filter, gets added to the chain,
the stream chain can compute the result straight from the source’s
size without visiting items one by one. Since peek is part of this
chain too, this computation that takes a shortcut through the source’s
size never calls it at all. The third line confirms this: toList()
genuinely needs every item to produce its result, so there is no
shortcut, and the intermediate operation runs five times.
The conclusion here is narrower than it looks at first glance: what
gets guaranteed is “never running without a terminal,” not “running
for every item once a terminal gets called.” The second is an
implementation decision — if the stream chain can produce the
result another way, it can skip the intermediate step. peek, for
this reason, is only for observation and debugging; if it gets built
as part of business logic, whether it runs stays dependent on the
terminal.
A Stream Is Single-Use, and Cannot Grow Its Source While Iterating
The last two rows fall into the caller’s rule: these two are constraints the caller has to follow, independent of the source’s type or the stream chain’s shape.
- FJ22 — A stream object gets consumed twice; the second
consumption gets tried in a separate
tryblock. In a separate stream chain, an item gets added to the source list during iteration.
// Rule.java - the caller's two rules: single use and no growing while iterating
import java.util.*;
import java.util.stream.*;
public class Rule {
public static void main(String[] args) {
List<String> source = List.of("pear", "cherry", "apple", "grape", "fig");
Stream<String> singleUse = source.stream();
singleUse.count();
try {
singleUse.count();
System.out.println("consuming the same stream a second time: fell through");
} catch (IllegalStateException e) {
System.out.println("consuming the same stream a second time -> exception: " + e.getClass().getSimpleName());
}
List<String> growing = new ArrayList<>(source);
try {
growing.stream().forEach(x -> {
if (x.equals("apple")) growing.add("mango");
});
System.out.println("growing the source while iterating: fell through, size=" + growing.size());
} catch (ConcurrentModificationException e) {
System.out.println("growing the source while iterating -> exception: " + e.getClass().getSimpleName());
}
}
}
consuming the same stream a second time -> exception: IllegalStateException growing the source while iterating -> exception: ConcurrentModificationException
Both rules give an exception when they fall, they do not stay
silent. Once count() gets called once on singleUse, the stream
counts as consumed; the second call fails with IllegalStateException
because it tries to reuse the stream itself — a rule that comes from
the stream object’s own state, independent of the source’s type. This
behavior shows that a stream object internally carries a flag marked
“consumed”: once that flag flips, calling a new terminal on it —
whichever one — gets met with the same exception.
The second rule rests on a different mechanism: while the growing
list gets iterated with forEach, the same list gets grown, and this
breaks the list’s own modification counter, raising
ConcurrentModificationException — even though the stream’s terminal
is forEach, the exception that falls is actually the source’s
own protection; the stream chain here is only an intermediary using
the source’s iterator. The difference between these two matters: the
first exception is the stream API’s own rule and holds for every
stream object; the second is bound to the stream’s source — a
stream chain built from an unmodifiable source can never grow while
iterating in the first place, so this rule never enters play there at
all.
The ten rows measured across this lesson can get gathered in a single table:
| # | Measured | Result | Source |
|---|---|---|---|
| 1 | sorted(), ordered source |
alphabetical order | interface guarantee |
| 2 | sorted(), unordered source |
same result | interface guarantee |
| 3 | map(), ordered source |
order kept | source-dependent |
| 4 | map(), unordered source |
order not kept | source-dependent |
| 5 | filter(), ordered source |
order kept | source-dependent |
| 6 | distinct(), ordered source |
first-seen kept | source-dependent |
| 7 | findFirst(), ordered source |
consistent result | source-dependent |
| 8 | intermediate operation, no terminal | 0 runs | interface guarantee |
| 9 | second consumption of the same stream | exception | caller’s rule |
| 10 | growing the source while iterating | exception | caller’s rule |
The distribution is this: three are a guarantee independent of the
source, five depend on the source’s or the terminal’s choice,
two are rules the caller has to follow and that fall with an
exception when broken. count() skipping peek did not enter the
table as a separate row, because what it measures is not a source
dependency, it is the terminal’s own choice — it should get read as a
lower bound of row eight. The majority of the ten operations — five
— carry the promise not of the interface, but of the source; this
runs the same direction as the order the previous topic built: most of
the standard library’s visible behavior is the promise not of the
interface it speaks through, but of the choice underneath it.
Summary
- A stream chain’s promise about order usually comes from the source:
map,filter,distinct,findFirstkeep encounter order only if the source is ordered. sorted()is a guarantee independent of the source: it produces the same result with an ordered or unordered source. Boundary measurement: the sentence “a stream keeps order” is mostly the source’s promise.- An intermediate operation never runs without a terminal operation;
this is an unconditional guarantee. But visiting every item once a
terminal gets called is not guaranteed —
count()can skip the intermediate operation entirely,toList()cannot. - Consuming the same stream a second time falls with
IllegalStateException; this is a rule belonging to the stream object’s own state, independent of the source’s type. - Growing the source collection while iterating the stream falls with
ConcurrentModificationException; the exception that falls is not the stream’s protection, it is the underlying collection’s. - Three of the ten measurements are a guarantee independent of the source, five are source-dependent, two are the caller’s rule; the majority stayed source-dependent.
Next Step
This lesson measured what a stream chain says, and what it does not:
order is mostly the source’s promise, laziness is an unconditional
guarantee but visiting every item is not, and two rules — single use,
no growing while iterating — fall directly to the caller. What
accumulates at the end of the chain is usually not a single value, it
is a container — a list, a map, a number — and this lesson always
used ready-made collecting methods (toList(), count()). The next
lesson looks at the piece that builds that container: what promise a
collector gives, whether the result container’s type is the
interface’s promise, and, once a repeated key gets encountered, which
collector throws an exception and which one merges.
To keep your progress and take notes, Log in
My notes
Log in to take notes.