filter, map, reduce, collect — transform data without mutation.
Published September 21, 2026
A stream (Java 8+) is a pipeline for processing a sequence of elements: you describe what should happen to the data ("keep the active users, take their emails, sort them"), and the library works out how to iterate. The source collection is never modified. Each step produces a new, lazily-computed view of the data.
// Imperative: how to loop
List<String> emails = new ArrayList<>();
for (User u : users) {
if (u.isActive()) emails.add(u.getEmail().toLowerCase());
}
Collections.sort(emails);
// Declarative: what you want
List<String> emails = users.stream()
.filter(User::isActive)
.map(u -> u.getEmail().toLowerCase())
.sorted()
.toList();
source ──▶ intermediate operations (0 or more) ──▶ one terminal operation
users.stream() .filter(...).map(...).sorted() .toList()
collection.stream(), Arrays.stream(array), Stream.of(a, b, c), IntStream.range(0, 10), Files.lines(path).filter, map, flatMap, sorted, distinct, limit, skip, peek.toList, collect, forEach, reduce, count, findFirst, anyMatch, min/max.Intermediate operations don't process anything when you call them. They only build up the pipeline. Work starts at the terminal operation, and then each element flows through all the steps one at a time, not stage by stage:
Stream.of("a", "bb", "ccc", "dddd")
.filter(s -> { System.out.println("filter " + s); return s.length() > 1; })
.map(s -> { System.out.println("map " + s); return s.toUpperCase(); })
.findFirst();
// Output:
// filter a
// filter bb
// map bb ← "ccc" and "dddd" are never touched: findFirst already has its answer
This is what makes short-circuiting operations (findFirst, anyMatch, limit, takeWhile) efficient. It also means a pipeline with no terminal operation does nothing at all. That's a common bug when someone expects peek or map alone to run.
List<Order> orders = ...;
// filter + map + collect
List<String> ids = orders.stream()
.filter(o -> o.total().compareTo(new BigDecimal("100")) > 0)
.map(Order::id)
.toList();
// primitive streams avoid boxing and add numeric terminals
double avg = orders.stream().mapToDouble(o -> o.total().doubleValue()).average().orElse(0);
int totalItems = orders.stream().mapToInt(Order::itemCount).sum();
// search and match
Optional<Order> firstLarge = orders.stream().filter(o -> o.itemCount() > 10).findFirst();
boolean anyUnpaid = orders.stream().anyMatch(o -> !o.paid());
// sorting with a comparator
List<Order> newestFirst = orders.stream()
.sorted(Comparator.comparing(Order::createdAt).reversed())
.limit(20)
.toList();
// flatMap: one-to-many, then flatten
List<String> allSkus = orders.stream()
.flatMap(o -> o.lines().stream()) // Stream<Order> → Stream<OrderLine>
.map(OrderLine::sku)
.distinct()
.toList();
Grouping and aggregating into maps (groupingBy, partitioningBy, toMap) have their own lesson, Collectors and groupingBy.
reduce: combining elements into one valueint sum = IntStream.rangeClosed(1, 5).reduce(0, Integer::sum); // 15
BigDecimal revenue = orders.stream()
.map(Order::total)
.reduce(BigDecimal.ZERO, BigDecimal::add);
The first argument is the identity: the starting value, which must leave any element unchanged when combined with it (0 for addition, 1 for multiplication). The combining function must be associative, meaning (a+b)+c == a+(b+c). That's what lets the work be split up in a parallel stream.
filter, map, flatMap) look at one element at a time. They stream through with almost no memory.sorted, distinct) must see all (or many) elements before they can emit anything. sorted() buffers the whole stream, so on huge or infinite streams it can use a lot of memory or never finish.Stream.iterate(1, n -> n * 2).limit(10).toList(); // 1, 2, 4, ... 512
Stream.iterate(1, n -> n < 1000, n -> n * 2).toList(); // Java 9: with a stop condition
Stream.generate(UUID::randomUUID).limit(5).toList();
An infinite stream is fine as long as something short-circuits it (limit, takeWhile, findFirst).
A stream can be consumed only once:
Stream<String> s = names.stream();
s.count();
s.count(); // IllegalStateException: stream has already been operated upon or closed
Don't mutate the source, and avoid side effects in lambdas:
// ❌ modifying external state from inside a pipeline — breaks in parallel, hard to reason about
List<String> result = new ArrayList<>();
names.stream().filter(n -> n.length() > 3).forEach(result::add);
// ✅ let the terminal operation build the result
List<String> result = names.stream().filter(n -> n.length() > 3).toList();
toList() vs collect(Collectors.toList()): Stream.toList() (Java 16) returns an unmodifiable list. Collectors.toList() makes no promise, and currently returns a mutable ArrayList. Calling add on the result of toList() throws UnsupportedOperationException.
peek is for debugging, not for doing real work. It may not run for every element when the pipeline short-circuits or the size is already known.
long count = hugeList.parallelStream().filter(this::expensiveCheck).count();
A parallel stream splits the work across the shared common ForkJoinPool (one thread per CPU core, minus one). It helps only when all of these are true:
ArrayList and arrays do; LinkedList and I/O-backed streams don't),It actively hurts in a web server when the lambdas do blocking I/O (database or HTTP calls). Every parallel stream in the JVM shares that one small pool, so a few slow requests can stall parallel work everywhere. Measure before and after, and default to sequential.
Streams read well for transform, filter, aggregate pipelines. A plain loop is often clearer when you need to break out early with complex conditions, update several variables at once, or throw checked exceptions. Checked exceptions are awkward inside lambdas. Use whichever reads better. Performance is usually similar for sequential code.
Q: What's the difference between intermediate and terminal operations?
A: Intermediate operations (filter, map, sorted...) return a new stream and are lazy: they only describe work. A terminal operation (toList, count, forEach, reduce...) returns a non-stream result, triggers the whole pipeline, and consumes the stream. Without a terminal operation nothing executes.
Q: What's the difference between map and flatMap?
A: map turns each element into exactly one new element, so a Stream<Order> becomes a Stream<List<Line>>. flatMap turns each element into a stream of zero or more elements and flattens them into one stream, so Stream<Order> becomes Stream<Line>. Use flatMap whenever each element expands into several.
Q: Is a stream faster than a for-loop?
A: Not inherently. Sequential streams have a small overhead (lambdas, pipeline objects), and the JIT usually makes the difference negligible. Streams win on readability for data transformations. Primitive streams (IntStream) avoid boxing, which matters for numeric work. Parallel streams can be faster for large CPU-bound tasks, and slower or dangerous otherwise.
Q: Why can't a stream be reused?
A: A stream is a one-shot pipeline over its source, not a data structure. Once a terminal operation has pulled the elements through, its internal state is used up. If you need to process the data twice, keep the source collection and call .stream() again, or pass around a Supplier<Stream<T>>.
Q: When does sorted() hurt performance?
A: sorted() is stateful. It must buffer every element before emitting the first, so it costs O(n log n) time and O(n) memory, and it defeats short-circuiting before it. If you only need the top few, it's still simpler to sorted().limit(k) for moderate sizes, but for very large inputs a bounded PriorityQueue or a database ORDER BY ... LIMIT is better.