The Streams API, introduced in Java 8, lets you express data transformations as declarative pipelines instead of manual loops. Used well, it makes intent obvious; used carelessly, it hides performance costs.
A basic pipeline
Streams compose three kinds of operations: a source, zero or more intermediate operations, and a terminal operation.
List<String> names = users.stream()
.filter(u -> u.age() >= 18)
.map(User::name)
.sorted()
.toList();Each intermediate operation is lazy — nothing runs until the terminal operation
(toList()) pulls values through.
Grouping and collecting
Collectors is where Streams gets powerful. Grouping into a map is a one-liner.
Map<Department, List<Employee>> byDept = employees.stream()
.collect(Collectors.groupingBy(Employee::department));
Map<Department, Long> headcount = employees.stream()
.collect(Collectors.groupingBy(Employee::department, Collectors.counting()));Reduce carefully
reduce folds a stream into a single value. Make sure your accumulator is
associative so it behaves correctly in parallel.
BigDecimal total = orders.stream()
.map(Order::amount)
.reduce(BigDecimal.ZERO, BigDecimal::add);Common pitfalls
- Don't reuse a stream — they're single-use. Create a new one each time.
- Avoid side effects inside
maporfilter; keep them pure. - Parallel streams aren't free — only use them for large, CPU-bound work with no shared mutable state.
Takeaway
Streams shine when they read like the problem statement. If a pipeline gets hard to follow, a plain loop is often the clearer choice.