A stream lets you describe what you want done to a collection instead of writing the loop that does it. It is the single biggest change in how Java is written since Java 8, and the mental model is small: a source, some intermediate operations, and one terminal operation that makes it all run.
The shape
List<String> names = List.of("Ana", "Bo", "Christopher", "Dee");
List<String> result = names.stream() // source
.filter(n -> n.length() > 2) // intermediate — returns another stream
.map(String::toUpperCase) // intermediate
.sorted() // intermediate
.toList(); // terminal — produces the answer
System.out.println(result); // [ANA, CHRISTOPHER, DEE]
The equivalent loop is longer and, more to the point, mixes what with how — you have to read the whole body to discover it is a filter and a transform.
Nothing happens until the terminal operation. Intermediate operations are lazy: they build a description of the work. This is not a detail, it is what makes the next section possible:
List<String> names = List.of("Ana", "Bo", "Christopher");
Optional<String> first = names.stream()
.filter(n -> n.length() > 2)
.findFirst(); // stops as soon as it has one
System.out.println(first.orElse("none")); // Ana — "Christopher" was never examined
A stream processes each element through the whole pipeline before moving to the next, so
findFirst, anyMatch and limit can short-circuit and skip the
rest of the collection entirely.
The intermediate operations you will use
List<String> words = List.of("banana", "apple", "cherry", "apple", "date");
System.out.println(words.stream().filter(w -> w.contains("a")).toList());
// [banana, apple, apple, date]
System.out.println(words.stream().map(String::length).toList());
// [6, 5, 6, 5, 4]
System.out.println(words.stream().distinct().toList());
// [banana, apple, cherry, date]
System.out.println(words.stream().sorted().limit(2).toList());
// [apple, apple]
System.out.println(words.stream().skip(3).toList());
// [apple, date]
System.out.println(words.stream()
.sorted(Comparator.comparing(String::length).thenComparing(Comparator.naturalOrder()))
.toList());
// [date, apple, apple, banana, cherry]
flatMap is the one that looks odd until it clicks. It flattens nested structure —
turning a stream of lists into a stream of their contents:
List<List<String>> nested = List.of(
List.of("a", "b"),
List.of("c"),
List.of());
System.out.println(nested.stream().flatMap(List::stream).toList()); // [a, b, c]
List<String> sentences = List.of("hello world", "goodbye moon");
System.out.println(sentences.stream()
.flatMap(s -> Arrays.stream(s.split(" ")))
.toList()); // [hello, world, goodbye, moon]
Terminal operations
List<Integer> numbers = List.of(3, 1, 4, 1, 5);
System.out.println(numbers.stream().count()); // 5
System.out.println(numbers.stream().anyMatch(n -> n > 4)); // true
System.out.println(numbers.stream().allMatch(n -> n > 0)); // true
System.out.println(numbers.stream().noneMatch(n -> n > 10)); // true
System.out.println(numbers.stream().max(Comparator.naturalOrder()).orElse(0)); // 5
System.out.println(numbers.stream().mapToInt(Integer::intValue).sum()); // 14
System.out.println(numbers.stream().mapToInt(Integer::intValue).average().orElse(0)); // 2.8
numbers.stream().limit(2).forEach(System.out::println); // 3, then 1
Note mapToInt before sum() and average(). A
Stream<Integer> has no sum — you convert to an
IntStream first, which also avoids boxing every element.
Collectors
collect is the general-purpose terminal operation, and three collectors cover most
real use:
record Person(String name, String city, int age) { }
class Demo {
void run() {
List<Person> people = List.of(
new Person("Ana", "Nuku'alofa", 30),
new Person("Bo", "Seattle", 25),
new Person("Cy", "Seattle", 35));
// Group into a Map
Map<String, List<Person>> byCity =
people.stream().collect(Collectors.groupingBy(Person::city));
System.out.println(byCity.get("Seattle").size()); // 2
// Group and count in one step
Map<String, Long> countByCity =
people.stream().collect(Collectors.groupingBy(Person::city, Collectors.counting()));
System.out.println(countByCity); // {Seattle=2, Nuku'alofa=1}
// (a HashMap — the iteration order is not part of the contract)
// Build a Map from two fields
Map<String, Integer> ages =
people.stream().collect(Collectors.toMap(Person::name, Person::age));
System.out.println(ages.get("Cy")); // 35
// Join into a String
System.out.println(people.stream().map(Person::name)
.collect(Collectors.joining(", ", "[", "]"))); // [Ana, Bo, Cy]
}
}
One trap in toMap: a duplicate key throws IllegalStateException rather
than overwriting. Pass a merge function — toMap(k, v, (a, b) -> b) — when duplicates
are possible.
Where a stream comes from
A collection is the usual source, but not the only one:
System.out.println(Stream.of("a", "b", "c").toList()); // straight from values
System.out.println(Arrays.stream(new int[]{1, 2, 3}).sum()); // from an array
System.out.println(IntStream.range(0, 5).boxed().toList()); // [0, 1, 2, 3, 4]
System.out.println(IntStream.rangeClosed(1, 5).sum()); // 15
System.out.println("a,b,c".chars().count()); // 5 — characters, commas included
System.out.println(Stream.iterate(1, n -> n * 2).limit(5).toList()); // [1, 2, 4, 8, 16]
IntStream.range is the stream equivalent of a counting for loop, and
Stream.iterate produces an infinite stream — which is only safe because of laziness. The
limit(5) is what makes it terminate; without it the pipeline never ends.
reduce, and when to leave it alone
List<Integer> numbers = List.of(1, 2, 3, 4);
System.out.println(numbers.stream().reduce(0, Integer::sum)); // 10
System.out.println(numbers.stream().reduce(1, (a, b) -> a * b)); // 24
// but for the common cases there is something clearer:
System.out.println(numbers.stream().mapToInt(Integer::intValue).sum()); // 10
reduce is not only for the cases a specialised method already covers. Here it is in
the console bank app this site uses for examples, adding up a customer's balances — there is no
sum() for BigDecimal, so reduce is the right tool:
public BigDecimal totalBalance(User user) {
return accountsOf(user).stream()
.map(Account::balance)
// reduce folds the list into one value: start at zero, add each balance.
// BigDecimal::add is a method reference standing in for (a, b) -> a.add(b).
.reduce(Money.ZERO, BigDecimal::add);
}
The identity matters: Money.ZERO is what comes back for a customer with no accounts,
which is correct and needs no special case.
reduce combines every element into one value, starting from an identity. It is the
general tool, and the specific ones — sum, max, count,
joining — say what they mean more directly. Reach for reduce when nothing
more specific exists.
Rules that keep streams honest
Do not mutate anything inside a stream. This is the most common misuse:
List<String> names = List.of("Ana", "Bo");
// Wrong — a side effect masquerading as a pipeline, and it breaks in parallel
List<String> collected = new ArrayList<>();
names.stream().forEach(collected::add);
// Right — let the stream produce the result
List<String> proper = names.stream().toList();
System.out.println(proper); // [Ana, Bo]
A stream is single-use. Reusing one throws IllegalStateException: stream
has already been operated upon or closed. Create a new one from the source instead.
Be careful with parallelStream(). It is one word and looks free. It
is worth it only for genuinely large datasets doing genuinely expensive per-element work — otherwise
the coordination costs more than it saves, and any shared mutable state becomes a race. Measure
before and after, or do not use it.
When a loop is still better
Streams are not automatically an improvement. Prefer a plain loop when:
- The body does several unrelated things, or is long.
- You need to
breakout on a complicated condition, or need the index. - You need to modify the source collection.
- The stream version needs a comment to explain it.
A four-stage pipeline that reads like a sentence is better than the loop. A pipeline with a nested lambda and a custom collector usually is not.
Next
Most pipelines end in a collector. Collectors is next — grouping, joining, partitioning, and building a map without tripping on duplicate keys.