Command Palette

Search for a command to run...

PHASE 10Intermediate Java 8+ ~31 min· topic 4 of 8

Topic 10.4

Streams: The Basics

In one line

A stream is a pipeline that pulls elements from a source, passes them through intermediate operations like filter and map, and produces a result with one terminal operation. Streams are lazy (nothing runs until the terminal operation), can be used only once, and never change their source.

Think of it like this

A juice stall. Oranges go in at one end. Station one throws away bruised ones, station two peels, station three squeezes, and a cup comes out at the end. Each orange travels through all the stations before the next one starts, nobody stores a pile of peeled oranges, and the machine does nothing at all until a customer orders a cup. A Java stream works the same way: a source, a row of operations, and nothing happens until you ask for a result.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Stream
A sequence of elements flowing through a pipeline of operations. It holds no data itself and can be used once.
Source
Where a stream's elements come from: a collection, an array, a range of numbers, a file, or a function that generates values.
Intermediate operation
An operation like filter or map that returns another stream. It doesn't do any work until a terminal operation runs.
Terminal operation
The last operation, like toList(), count() or forEach, that produces a result and makes the whole pipeline run.
Pipeline
The chain of a source, intermediate operations and one terminal operation.
Lazy
Doing work only when the result is actually needed, not when the instruction is written.
Internal iteration
The library walks through the elements for you (as streams do), instead of you writing the loop yourself (external iteration).
Primitive stream
IntStream, LongStream or DoubleStream: streams of raw numbers, without wrapper objects.

Step by step

01From a loop to a pipeline

A for loop describes how: make an empty list, walk each element, check a condition, transform, add. That's external iteration: you drive.

A stream describes what: from these orders, keep the paid ones, keep the big ones, take the customer's name in capitals, give me a list. That's internal iteration: the library drives, which is what later lets it optimise, short-circuit, or run in parallel (Topic 10.8).

Main.javawhole filejava
// external iteration: you write the loop
List<String> result = new ArrayList<>();
for (Order o : orders) {
    if (o.paid() && o.amount() >= 400) {
        result.add(o.customer().toUpperCase());
    }
}

// internal iteration: you describe the result
List<String> result2 = orders.stream()
        .filter(Order::paid)
        .filter(o -> o.amount() >= 400)
        .map(o -> o.customer().toUpperCase())
        .toList();

02The three parts of every pipeline

Every stream pipeline has exactly one source, any number of intermediate operations, and exactly one terminal operation. Intermediate operations return a Stream, so you can keep chaining; terminal operations return something else (a List, a long, an Optional, void).

If your chain ends with an intermediate operation, the pipeline never runs. Nothing is printed, nothing is filtered, and the compiler won't warn you.

The three parts of every pipelinediagram
Rendering diagram…

03Lazy: building a pipeline does no work

Calling filter doesn't filter anything. It creates a small stage object that remembers the predicate and points back to the previous stage. The pipeline is a linked list of these stage objects.

When a terminal operation runs, the stream library wraps the stages into a chain of sinks (each one says "here's an element, pass it on if it survives") and pushes the source's elements into the first sink. You can watch this with print statements in the lambdas: nothing prints until the terminal operation.

Lazy: building a pipeline does no workdiagram
Rendering diagram…

04Vertical processing and early exit

Because each element flows through the whole pipeline before the next one starts, a pipeline with three operations is still a single pass over the data. There are no temporary lists between stages (the library "fuses" the operations into one loop).

It also means a terminal operation that needs only part of the data can stop early. findFirst() stops at the first element that survives the filters, so later elements are never filtered or mapped. These are called short-circuiting operations (Topic 10.5).

05Where streams come from

Collections: list.stream(), set.stream(), map.entrySet().stream(). Arrays: Arrays.stream(array) or Stream.of(a, b, c). Numbers: IntStream.range(0, n) (excludes n) and rangeClosed(1, n) (includes n). Text: "abc".chars() gives an IntStream of character codes, text.lines() (Java 11) a stream of lines.

Infinite sources: Stream.iterate(1, x -> x * 2) and Stream.generate(supplier) never end on their own; you must cut them with limit or a short-circuiting terminal. Java 9 added Stream.iterate(seed, hasNext, next), which works like a for loop and stops by itself, and Stream.ofNullable(x), a stream of zero or one element.

06Primitive streams and conversions

Stream<Integer> holds boxed Integer objects. IntStream holds plain ints, uses IntPredicate/IntUnaryOperator (Topic 10.2), and has numeric terminals: sum() returns int, average() returns OptionalDouble (empty for an empty stream, instead of dividing by zero), summaryStatistics() returns count, sum, min, average and max in one pass.

Switch between the worlds deliberately: mapToInt(String::length) turns a Stream<String> into an IntStream; mapToObj(n -> ...) goes back to objects; boxed() turns IntStream into Stream<Integer>, which you need for collectors.

Main.javawhole filejava
int total      = words.stream().mapToInt(String::length).sum();   // Stream<String> -> IntStream
OptionalDouble avg = IntStream.of(4, 8).average();                 // OptionalDouble[6.0]
List<Integer> nums = IntStream.range(0, 3).boxed().toList();       // [0, 1, 2]
List<String> tags  = IntStream.rangeClosed(1, 3)
        .mapToObj(n -> "#" + n).toList();                           // [#1, #2, #3]

07One use only, and hands off the source

A Stream object is a one-shot pipeline. Once a terminal operation has run, or once you've chained another operation onto it, it is marked as used. Store the source (the list) in a variable, not the stream, and call .stream() again whenever you need a new pipeline.

While a stream is running, its source must not be structurally modified (elements added or removed). This rule is called non-interference. Breaking it with an ArrayList source gives a ConcurrentModificationException (the fail-fast check from Topic 9.9).

terminal
$ java Main.java
── expected output ──
Asha
Ravi
Exception in thread "main" java.lang.IllegalStateException: stream has already been operated upon or closed
at java.base/java.util.stream.AbstractPipeline.sourceStageSpliterator(AbstractPipeline.java:279)
at java.base/java.util.stream.ReferencePipeline$Head.forEach(ReferencePipeline.java:762)
at Main.main(Main.java:8)

08Getting a result out

toList() (Java 16) is the shortest: an unmodifiable list that allows null elements. collect(Collectors.toList()) works on Java 8 and gives a list you can usually modify, but the documentation doesn't promise it. collect(Collectors.toUnmodifiableList()) (Java 10) is unmodifiable and rejects nulls. toArray(String[]::new) gives an array.

Prefer returning results over side effects: forEach(list::add) into an outside list works sequentially but breaks under parallel streams (Topic 10.8). A terminal that builds the result for you is always safe.

Try it yourself

  1. 1

    Predict the trace

    In the laziness example, add .limit(2) before .toList() in the first pipeline. Write down the lines you expect between "pipeline built" and "result", then run. Does kabir get filtered?

  2. 2

    Remove the terminal operation

    In the same example, replace List<String> result = pipeline.toList(); and the line after it with nothing. Predict what prints. Why is it legal Java even though no work happens?

  3. 3

    Swap to primitives

    Rewrite words.stream().mapToInt(String::length).sum() using map(String::length) and reduce(0, Integer::sum) instead. Same result? Which version creates Integer objects?

Code & diagrams

The same query as a loop and as a stream Java 16+ New tab
Sign in to run this example in your browser.

Expected output

loop:   [ASHA, MEERA, KABIR]
stream: [ASHA, MEERA, KABIR]
total paid: 3940
source untouched: 5 orders
Laziness and vertical processing, made visible Java 16+ New tab

Each name goes through filter and map before the next name starts. findFirst stops after the first survivor, so bo, meera and kabir are never even filtered.

Sign in to run this example in your browser.

Expected output

pipeline built, nothing has run yet
  filter asha
  map    asha
  filter bo
  filter meera
  map    meera
  filter kabir
  map    kabir
result: [ASHA, MEERA, KABIR]
with findFirst:
  filter asha
  map    asha
first: ASHA
Stream sources, including infinite ones Java 16+ New tab

The three-argument Stream.iterate and Stream.ofNullable arrived in Java 9; toList() in Java 16.

Sign in to run this example in your browser.

Expected output

Stream.of:      [a, b, c]
Arrays.stream:  6
range(0, 5):    [0, 1, 2, 3, 4]
rangeClosed:    15
iterate+limit:  [1, 2, 4, 8, 16, 32]
iterate, 3 arg: [1, 3, 9, 27, 81]
generate+limit: [hi, hi, hi]
String.chars:   [1, 2, 3]
map entries:    rice, tea
ofNullable:     0
empty:          false
Primitive streams: sum, average and statistics Java 16+ New tab
Sign in to run this example in your browser.

Expected output

total letters: 23
average length: 4.6
IntSummaryStatistics{count=5, sum=23, min=2, average=4.600000, max=6}
max 6, min 2
average of nothing: OptionalDouble.empty
[*, **, ***]
boxed: [5, 6]
A stream is used once; the source can be streamed again Java 16+ New tab

A pipeline is a straight line, not a tree. Chaining map onto base used base up, so a second branch from it is refused.

Sign in to run this example in your browser.

Expected output

count: 3
second use: stream has already been operated upon or closed
fresh stream: 3
branching: stream has already been operated upon or closed
[X, Y, Z]

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Reuse a stream

Store Stream<String> names = List.of("Asha", "Ravi").stream(); and call names.forEach(System.out::println); twice.

terminal
$ java Main.java
── what you'll see ──
Asha
Ravi
Exception in thread "main" java.lang.IllegalStateException: stream has already been operated upon or closed
at java.base/java.util.stream.AbstractPipeline.sourceStageSpliterator(AbstractPipeline.java:279)
at java.base/java.util.stream.ReferencePipeline$Head.forEach(ReferencePipeline.java:762)
at Main.main(Main.java:8)

Break #2

Modify the source while streaming

With List<Integer> nums = new ArrayList<>(List.of(1, 2, 3));, run nums.stream().forEach(n -> nums.add(n * 10));.

terminal
$ java Main.java
── what you'll see ──
Exception in thread "main" java.util.ConcurrentModificationException
at java.base/java.util.ArrayList$ArrayListSpliterator.forEachRemaining(ArrayList.java:1714)
at java.base/java.util.stream.ReferencePipeline$Head.forEach(ReferencePipeline.java:762)
at Main.main(Main.java:7)

Break #3

Add to the list returned by toList()

Write List<String> names = List.of("asha", "ravi").stream().map(String::toUpperCase).toList(); names.add("MEERA");.

terminal
$ java Main.java
── what you'll see ──
Exception in thread "main" java.lang.UnsupportedOperationException
at java.base/java.util.ImmutableCollections.uoe(ImmutableCollections.java:142)
at java.base/java.util.ImmutableCollections$AbstractImmutableCollection.add(ImmutableCollections.java:147)
at Main.main(Main.java:6)

Myth vs fact

Myth

A stream is a fancy kind of collection.

Fact

A stream holds no elements. It's a one-time computation over a source; the data stays in the source (or is generated on demand).

Myth

Each intermediate operation makes a full pass over the data.

Fact

Operations are fused: each element flows through the whole chain before the next starts, so filter().map().filter() is one pass, with no intermediate lists (except for stateful operations like sorted, Topic 10.5).

Myth

stream.toList() and collect(Collectors.toList()) are the same.

Fact

toList() (Java 16) returns an unmodifiable list. Collectors.toList() makes no promise about mutability and in practice returns an ArrayList. Code that adds to the result breaks when you switch.

Myth

Streams are always slower than loops.

Fact

For tiny inputs a loop wins by a little (a stream allocates a few pipeline objects). For typical sizes, after JIT warm-up, a simple stream and a loop perform about the same. Boxing (Stream<Integer> instead of IntStream) is a far bigger cost than the stream itself.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Internally each stage is an AbstractPipeline node with a previousStage link and stream flags (SIZED, ORDERED, DISTINCT, SORTED, SHORT_CIRCUIT). The terminal operation calls wrapSink to build a chain of Sink objects from the last stage back to the first, then copyInto pushes elements from the source's Spliterator (forEachRemaining, or tryAdvance one by one when the pipeline can short-circuit).

  • ▸

    Flags enable optimisations: natural-order sorted() on a stream already flagged SORTED (from a natural-order TreeSet) is skipped, distinct() on a Set source is skipped, and since Java 9 count() on a SIZED pipeline returns the size without running the pipeline, so list.stream().peek(...).count() doesn't call peek at all. Side effects in intermediate operations are not guaranteed to run.

  • ▸

    The source abstraction is Spliterator: tryAdvance, forEachRemaining, trySplit (for parallelism, Topic 10.8), estimateSize and characteristics. ArrayList's spliterator is late-binding (it binds to the list when traversal starts, not when stream() is called) and checks modCount at the end, which is where the ConcurrentModificationException comes from.

  • ▸

    BaseStream extends AutoCloseable. Streams over collections hold nothing, but I/O streams like Files.lines(path) and Files.list(dir) hold open file handles and must be closed with try-with-resources (Topic 7.6). onClose(Runnable) registers clean-up actions.

Remember this

  1. 1

    A stream (java.util.stream.Stream<T>) is not a collection. It stores no elements; it describes a computation over elements that come from a source: a collection (list.stream()), an array (Arrays.stream(arr)), a range (IntStream.range(0, 10)), a generator function, a file (Files.lines(path)) and more. Processing a stream never modifies its source.

  2. 2

    A pipeline has three parts: one source, zero or more intermediate operations (filter, map, sorted, limit...), each returning a new Stream, and exactly one terminal operation (toList, count, forEach, sum, collect...), which produces a result or a side effect and closes the pipeline.

  3. 3

    Streams are lazy. Intermediate operations only record what to do. When the terminal operation runs, elements are pulled through the whole pipeline one at a time ("vertically"): element 1 is filtered, mapped and collected before element 2 is even looked at. This is why findFirst() can stop after the first match, and why a stream over an infinite source can still finish.

  4. 4

    A stream can be consumed only once. After a terminal operation (or after another operation has been chained onto it), using the same Stream object again throws IllegalStateException: stream has already been operated upon or closed. To process the data again, create a new stream from the source.

  5. 5

    For numbers, use the primitive streams IntStream, LongStream and DoubleStream. They avoid boxing and add numeric terminals: sum(), average(), max(), summaryStatistics(). Convert with mapToInt(...) (objects to ints), mapToObj(...) (ints to objects) and boxed() (int to Integer).

  6. 6

    To get a list out, stream.toList() (Java 16) returns an unmodifiable list; collect(Collectors.toList()) (Java 8) returns a list whose mutability isn't promised (an ArrayList in practice). Collectors in general are Topic 10.6, and the full catalogue of operations is Topic 10.5.

Explain it without notes

01

What is a stream, and how is it different from a collection?

02

Explain laziness in streams. What actually happens when you call filter and when you call toList?

03

Why can a stream be used only once?

04

Why do primitive streams exist, and how do you move between Stream<T> and IntStream?

Practice

01

From List.of("tea", "coffee", "juice", "milk"), print a list of the words with more than 3 letters, upper-cased, using a stream.

02

Print the sum of squares of the odd numbers from 1 to 9 with a single IntStream pipeline.

03

Use Stream.iterate with three arguments to print the powers of 2 below 1000 as a list.

Trade-offs

  • ↔

    Streams make data processing declarative and composable, and they make parallelism a one-word change. Loops are easier to debug step by step, can break/continue/return from the method, can throw checked exceptions, and can update several local variables at once.

  • ↔

    Laziness saves work and enables infinite sources, but it also means side effects inside intermediate operations may run in a surprising order, or not at all (for example peek before count()). Keep lambdas in pipelines free of side effects.

  • ↔

    A long pipeline can be harder to read than a loop when it needs many intermediate records or index arithmetic. Use streams for filter/map/group/aggregate work, and loops for algorithms with complex state (many DSA solutions in /dsa stay loops for this reason).

Done when you can

  • Done when you can name the three parts of a pipeline and classify any operation as intermediate or terminal.

  • Done when you can predict the print order of a pipeline with side-effecting lambdas.

  • Done when you can create streams from collections, arrays, ranges, iterate and generate.

  • Done when you use IntStream for numbers and convert with mapToInt, mapToObj and boxed.

  • Done when you can explain one-shot use, non-interference, and toList() versus Collectors.toList().