Skip to content
academia.sh

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.
  • FJ18sorted() runs with natural order; uppercasing happens with Locale.ROOT.
  • FJ19distinct() and findFirst() 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.

  • FJ20findFirst() and findAny() 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 the toList() 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 try block. 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, findFirst keep 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.

Start typing to search.

↑↓ Esc navigate · open · close