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
filterormapthat returns another stream. It doesn't do any work until a terminal operation runs. - Terminal operation
- The last operation, like
toList(),count()orforEach, 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,LongStreamorDoubleStream: 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).
// 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.
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.
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.
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).
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
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. Doeskabirget filtered? - 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
Swap to primitives
Rewrite
words.stream().mapToInt(String::length).sum()usingmap(String::length)andreduce(0, Integer::sum)instead. Same result? Which version createsIntegerobjects?
Code & diagrams
Expected output
loop: [ASHA, MEERA, KABIR]
stream: [ASHA, MEERA, KABIR]
total paid: 3940
source untouched: 5 ordersEach 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.
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: ASHAThe three-argument Stream.iterate and Stream.ofNullable arrived in Java 9; toList() in Java 16.
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: falseExpected 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 pipeline is a straight line, not a tree. Chaining map onto base used base up, so a second branch from it is refused.
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.
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));.
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");.
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
AbstractPipelinenode with apreviousStagelink and stream flags (SIZED,ORDERED,DISTINCT,SORTED,SHORT_CIRCUIT). The terminal operation callswrapSinkto build a chain ofSinkobjects from the last stage back to the first, thencopyIntopushes elements from the source'sSpliterator(forEachRemaining, ortryAdvanceone by one when the pipeline can short-circuit). - ▸
Flags enable optimisations: natural-order
sorted()on a stream already flaggedSORTED(from a natural-orderTreeSet) is skipped,distinct()on aSetsource is skipped, and since Java 9count()on aSIZEDpipeline returns the size without running the pipeline, solist.stream().peek(...).count()doesn't callpeekat all. Side effects in intermediate operations are not guaranteed to run. - ▸
The source abstraction is
Spliterator:tryAdvance,forEachRemaining,trySplit(for parallelism, Topic 10.8),estimateSizeandcharacteristics.ArrayList's spliterator is late-binding (it binds to the list when traversal starts, not whenstream()is called) and checksmodCountat the end, which is where theConcurrentModificationExceptioncomes from. - ▸
BaseStreamextendsAutoCloseable. Streams over collections hold nothing, but I/O streams likeFiles.lines(path)andFiles.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
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
A pipeline has three parts: one source, zero or more intermediate operations (
filter,map,sorted,limit...), each returning a newStream, and exactly one terminal operation (toList,count,forEach,sum,collect...), which produces a result or a side effect and closes the pipeline. - 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
A stream can be consumed only once. After a terminal operation (or after another operation has been chained onto it), using the same
Streamobject again throwsIllegalStateException: stream has already been operated upon or closed. To process the data again, create a new stream from the source. - 5
For numbers, use the primitive streams
IntStream,LongStreamandDoubleStream. They avoid boxing and add numeric terminals:sum(),average(),max(),summaryStatistics(). Convert withmapToInt(...)(objects to ints),mapToObj(...)(ints to objects) andboxed()(inttoInteger). - 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 (anArrayListin practice). Collectors in general are Topic 10.6, and the full catalogue of operations is Topic 10.5.
Explain it without notes
What is a stream, and how is it different from a collection?
Explain laziness in streams. What actually happens when you call filter and when you call toList?
Why can a stream be used only once?
Why do primitive streams exist, and how do you move between Stream<T> and IntStream?
Practice
From List.of("tea", "coffee", "juice", "milk"), print a list of the words with more than 3 letters, upper-cased, using a stream.
Print the sum of squares of the odd numbers from 1 to 9 with a single IntStream pipeline.
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/returnfrom 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
peekbeforecount()). 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
/dsastay 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
IntStreamfor numbers and convert withmapToInt,mapToObjandboxed.Done when you can explain one-shot use, non-interference, and
toList()versusCollectors.toList().