The stream pipeline
Most collection work is the same three verbs in different orders: keep some elements, transform them, gather a result.
Written as loops, those verbs smear across mutable helper variables. Written as a stream, each verb is one named stage, and a reviewer reads the intent line by line. Streams are the house style for this in modern Java codebases, so you will read them daily even before you write them.
A stream processes a collection in stages, each stage taking a lambda. It is Java's answer to Python's list comprehensions:
[n * n for n in nums if n % 2 == 0]
nums.stream() .filter(n -> n % 2 == 0) .map(n -> n * n) .collect(Collectors.toList());
The stages are:
| Stage | Role |
|---|---|
stream() | opens the pipeline |
filter(predicate) | keeps elements that pass the test |
map(function) | transforms each element |
| a terminal operation | ends the pipeline and produces the result |
collect(Collectors.toList()) builds a list, count() counts, and mapToInt(...).sum() totals.
Nothing runs until the terminal operation arrives, and the source collection is never modified, which is the guarantee that makes a pipeline safe to read as a description rather than as a sequence of mutations.
One list, three pipelines
A collected list, a sum, and a count, all from the same source.
import java.util.List; import java.util.stream.Collectors; public class Main { public static void main(String[] args) { List<Integer> nums = List.of(1, 2, 3, 4, 5, 6); List<Integer> evenSquares = nums.stream() .filter(n -> n % 2 == 0) .map(n -> n * n) .collect(Collectors.toList()); System.out.println(evenSquares); int total = nums.stream().mapToInt(n -> n).sum(); System.out.println(total); long bigOnes = nums.stream().filter(n -> n > 3).count(); System.out.println(bigOnes); } }
Output
[4, 16, 36] 21 3
The filter keeps 2, 4, and 6, and the map squares each survivor, so the order of the two stages matters. Squaring first and filtering after would give the same answer here but not in general.
nums is untouched afterwards, which is why three separate stream() calls on it all see the original six numbers. mapToInt is the bridge to primitive arithmetic, since a stream of Integer has no sum of its own.
More stages and endings
A few more pieces cover most day-to-day pipelines. Mid-pipeline stages include distinct() to drop duplicates, sorted() to order elements, and limit(n) to keep the first n.
Terminal operations besides collect:
| Terminal | Produces |
|---|---|
count() | how many elements survived, as a long |
anyMatch(predicate) | true as soon as one element passes |
mapToInt(n -> n).sum() | a total, since int math needs the mapToInt bridge |
toList() | a List, the modern shorthand for collect(Collectors.toList()) |
One rule keeps pipelines honest: a stream never modifies its source, and each stream can be consumed by exactly one terminal operation, so build a fresh stream() for each question you ask.
anyMatch also short-circuits, stopping at the first element that passes rather than examining the rest. Combined with laziness, that means a filter followed by anyMatch can finish after inspecting one element.
Four questions about one list
A count, a cleaned and sorted copy, a yes-or-no, and a sum.
import java.util.List; public class Main { public static void main(String[] args) { List<Integer> nums = List.of(4, 1, 7, 4, 2, 7, 9); long bigCount = nums.stream().filter(n -> n > 3).count(); System.out.println("greater than 3: " + bigCount); List<Integer> cleaned = nums.stream().distinct().sorted().toList(); System.out.println(cleaned); boolean anyEven = nums.stream().anyMatch(n -> n % 2 == 0); System.out.println("any even: " + anyEven); int total = nums.stream().mapToInt(n -> n).sum(); System.out.println("sum: " + total); } }
Output
greater than 3: 5 [1, 2, 4, 7, 9] any even: true sum: 34
The five values above 3 are 4, 7, 4, 7, and 9, counting the repeats, because filter never removes duplicates on its own. distinct().sorted() produces the five unique values in order without touching nums.
Each question opens its own stream, which is required rather than stylistic. Reusing a consumed stream throws an IllegalStateException at runtime.
What triggers the work
The terminal operation is what actually runs a pipeline, whether that is collect(), count(), or another ending.
Intermediate operations such as filter and map are lazy, meaning they only describe the pipeline. The terminal operation pulls elements through all the stages and produces the final value.
nums.stream().filter(n -> n > 3); // legal, and does nothing at all nums.stream().filter(n -> n > 3).count(); // now the filter runs
The first line compiles, produces a stream object, and never evaluates the lambda even once.
Laziness is not just an implementation detail. It lets a pipeline over a million elements stop early on anyMatch or limit(10), and it means each element flows through every stage in turn rather than the whole collection being copied at each step.
Filtering words two ways
One pipeline builds a transformed list, the other answers a count.
import java.util.List; import java.util.stream.Collectors; public class Main { public static void main(String[] args) { List<String> words = List.of("stream", "java", "lambda", "api", "code"); List<String> shortUpper = words.stream() .filter(w -> w.length() <= 4) .map(w -> w.toUpperCase()) .collect(Collectors.toList()); System.out.println(shortUpper); long longCount = words.stream().filter(w -> w.length() > 4).count(); System.out.println(longCount); } }
Output
[JAVA, API, CODE]
2The first pipeline keeps words of length 4 or less, then uppercases each survivor, and collect gathers them in the original relative order. The two long words are stream and lambda, so the count is 2.
count() returns a long rather than an int, since a stream can in principle be longer than an int can hold, so store it in a long or print it directly.