Suppose you want the doubled value of every even number in a list. The classic approach is a loop: create a result list, walk the source, test each element, transform the survivors, and append. It works, but the what and the how are tangled together — you cannot see the intention, 'double the evens', for the mechanics of indexing and appending.
A stream separates intention from mechanics. You describe the pipeline — which elements to keep, how to transform them, what to finally produce — and let the library worry about the iteration. filter keeps the elements a Predicate accepts; map transforms each one through a Function; reduce folds them into a single value. The names read like a sentence, and that readability is the whole reason the API exists.
flowchart LR SRC["source<br/>Stream.of or a collection"] --> INT["intermediate ops<br/>filter and map"] INT --> TERM["terminal op<br/>reduce or forEach"] TERM --> OUT["a result or side effect"] style TERM fill:#c2410c,color:#fff
Every pipeline has three roles. The source is where elements come from — Stream.of(...), a collection's stream() method, or IntStream.range. Intermediate operations wire the pipeline together: filter, map, sorted, and limit each return a new stream and do no work yet. The terminal operation is the switch that finally runs everything: forEach, reduce, collect, or count.
The defining property sits between those last two roles. Intermediate operations are lazy — they do not process a single element until a terminal operation is invoked. You can build a pipeline that filters, maps, and sorts, and nothing at all happens until you call something that demands a result. Laziness is not an optimisation detail; it is the behaviour you must predict to read streams correctly.
import java.util.stream.Stream;
public class Main {
public static void main(String[] args) {
Stream.of(1, 2, 3, 4, 5, 6)
.filter(n -> n % 2 == 0)
.map(n -> n * 10)
.forEach(System.out::println);
}
}
Notice the output keeps the relative order of the source. Streams derived from ordered sources — such as Stream.of or a List — preserve encounter order through filter and map, so the doubled evens come out 20, 40, 60 in the order they went in, not sorted and not shuffled. Only an explicit sorted, an unordered source like a HashSet, or a deliberately unordered operation changes that.
filter never reorders or duplicates; it only decides keep-or-skip for each element. map transforms one-to-one, never changing the count. Both are lazy, both preserve order, and both return a fresh stream you can keep chaining. The terminal forEach is what finally walks that chain and produces the side effect of printing.
Laziness: a pipeline with no terminal does nothing
Laziness has a consequence that surprises every newcomer: a pipeline with no terminal operation does nothing at all. Add peek, filter, and map to a stream, never call reduce or collect or forEach, and the entire chain sits idle. The elements are never touched, the lambdas never run, no output appears. The pipeline is a description of work, not the work itself.
Laziness also lets the library fuse steps and short-circuit. In a chain like peek(print).filter(test).findFirst(), the engine does not filter the whole stream and then locate the first match. It walks one element at a time, running peek and filter only until a single element passes, then stops. Fewer elements are touched than a naive reading suggests, and predicting exactly how many is a skill this lesson trains.
flowchart TD
P["filter then map then peek"] --> Q{"terminal operation?"}
Q -->|"no"| N["nothing runs<br/>no output, no work"]
Q -->|"yes, forEach or reduce"| R["the whole pipeline fires"]
style N fill:#b45309,color:#fff
style R fill:#3776ab,color:#fff
import java.util.stream.Stream;
public class Main {
public static void main(String[] args) {
Stream.of("a", "b", "c")
.peek(s -> System.out.println("saw " + s))
.map(String::toUpperCase);
System.out.println("done");
}
}
reduce: folding many elements into one
reduce folds a stream down to a single value by repeatedly combining elements. Its two-argument form takes an identity — the starting accumulator — and a combining function that merges the accumulator with each element. To sum 1, 2, 3, 4, 5 you write reduce(0, (acc, n) -> acc + n): the accumulator begins at 0, absorbs 1 to become 1, then 3, 6, 10, and finally 15.
The identity must be a value that leaves other values unchanged when combined — 0 for addition, an empty string for concatenation, 1 for multiplication. Pick the wrong identity and your result is quietly off. Because reduce walks the stream in encounter order, you can trace it step by step on paper and predict the exact answer, which is exactly what the exercises ask.
flowchart LR I["identity 0"] --> A1["absorbs 1<br/>becomes 1"] A1 --> A2["absorbs 2<br/>becomes 3"] A2 --> A3["absorbs 3, 4, 5<br/>becomes 15"] style I fill:#3776ab,color:#fff
What does this program print? Read the pipeline carefully and type the exact output (three lines).
import java.util.stream.Stream;
public class Main {
public static void main(String[] args) {
Stream.of(1, 2, 3, 4, 5, 6)
.filter(n -> n % 2 == 0)
.map(n -> n * 10)
.forEach(System.out::println);
}
}
filterkeeps only the even numbers: 2, 4, 6.mapmultiplies each by 10, giving 20, 40, 60.forEachprints one value per line.
A pipeline calls filter and map but never calls a terminal operation such as forEach or collect. What happens?
Intermediate operations are lazy: they describe work but do not perform it. Without a terminal operation to demand a result, no element is ever touched and no lambda ever runs.
The laziness trap. The pipeline below is built but never started. Type the exact output (one line).
import java.util.stream.Stream;
public class Main {
public static void main(String[] args) {
Stream.of("a", "b", "c")
.peek(s -> System.out.println("saw " + s))
.map(String::toUpperCase);
System.out.println("done");
}
}
There is no terminal operation in the pipeline, so
peekandmapnever run.Only the standalone
printlnat the end actually executes.
A stream is used once with forEach, then filter is called on the same stream and forEach again. What does the second terminal call do?
A stream is consumed by its terminal operation. Reusing the same stream throws IllegalStateException — streams describe a one-shot computation, so call stream() on the source again for a second pass.
reduce, twice. One fold sums numbers, another joins letters into a word. Predict both lines.
import java.util.stream.Stream;
public class Main {
public static void main(String[] args) {
int sum = Stream.of(1, 2, 3, 4, 5)
.reduce(0, (acc, n) -> acc + n);
System.out.println("sum: " + sum);
String word = Stream.of("s", "t", "r", "e", "a", "m")
.reduce("", (acc, c) -> acc + c);
System.out.println("word: " + word);
}
}
The numeric fold starts at 0 and adds 1, 2, 3, 4, 5, reaching 15.
The text fold starts at an empty string and joins the letters in encounter order, spelling
stream.
Recap
- A pipeline has three roles: a source, zero or more intermediate operations (
filter,map,sorted), and exactly one terminal operation (forEach,reduce,collect). - Intermediate operations are lazy: they run only when a terminal operation demands a result, so a pipeline with no terminal does nothing.
- Laziness lets the library fuse and short-circuit, touching fewer elements than a naive reading suggests.
- Ordered streams preserve encounter order through
filterandmap. - A stream is single-use: a second terminal operation on the same stream throws
IllegalStateException. reduce(identity, accumulator)folds elements in encounter order; pick an identity that leaves values unchanged.
Next you will gather stream results into real containers with collect and groupingBy — and meet the ordering trap that waits inside every grouped map.