Streams express data transformations clearly and appear throughout modern Java codebases.
A stream is a pipeline over a sequence of elements: a source, zero or more intermediate operations, and one terminal operation:
List<String> result = orders.stream() // source
.filter(o -> o.total() > 100) // intermediate
.map(Order::customer) // intermediate
.distinct()
.sorted()
.toList(); // terminalIntermediate operations are lazy: nothing runs until the terminal operation pulls elements through, one at a time. That's why filter(...).findFirst() stops as soon as it finds a match.
Common operations:
- Intermediate:
filter,map,flatMap(one-to-many),distinct,sorted,limit,skip,peek(debugging). - Terminal:
toList()(Java 16+, unmodifiable),forEach,count,anyMatch/allMatch,findFirst,reduce,min/max, andcollect.
Collectors handles the richer cases: groupingBy(Order::customer) builds a Map<K, List<V>>, optionally with a downstream collector like counting() or summingDouble(...); toMap, joining(", "), and partitioningBy.
For primitives use IntStream, mapToInt(...), sum(), average() — they avoid boxing. IntStream.range(0, n) replaces many index loops.
Rules to keep in mind:
- A stream can be consumed only once.
- Keep lambdas free of side effects; don't add to an external list from
forEach— collect instead. parallel()is rarely a win for typical business code; measure before using it.
Streams are not always better than loops. If the pipeline needs complex state, early returns or checked exceptions, a plain loop is often clearer.
Example
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public class StreamsDemo {
record Order(String customer, String product, double total) { }
public static void main(String[] args) {
var orders = List.of(
new Order("ana", "book", 30), new Order("bo", "lamp", 120),
new Order("ana", "desk", 250), new Order("cy", "pen", 5));
List<String> big = orders.stream()
.filter(o -> o.total() > 100).map(Order::customer)
.sorted().toList();
System.out.println(big); // [ana, bo]
Map<String, Double> spend = orders.stream().collect(
Collectors.groupingBy(Order::customer, Collectors.summingDouble(Order::total)));
System.out.println(spend.get("ana")); // 280.0
double avg = orders.stream().mapToDouble(Order::total).average().orElse(0);
System.out.println(avg); // 101.25
}
}Remember
- source → lazy intermediate ops → one terminal op
groupingBy,toMap,joiningandpartitioningBycover most aggregation needs- Use primitive streams for numbers; avoid side effects in lambdas
Watch out for
- Reusing a stream after a terminal operation — it throws IllegalStateException.
- Building results by mutating an outside list in
forEachinstead of collecting.