Stream Operations (filter/map/reduce)
Java Streams let you express data-processing pipelines — filtering, transforming, aggregating — in clean, readable one-liners instead of verbose for-loops. Once you understand filter, map, and reduce, you have the core toolkit to tackle almost any collection problem.
What is a Stream Operation?
A Stream is a sequence of elements that supports a pipeline of operations. You get a Stream from a source (like a List or an array), chain zero or more intermediate operations (lazy), and finish with one terminal operation (eager — this is what kicks off the actual work).
source → filter → map → reduce/collect
Every operation returns a new Stream (or a result); the original collection is never modified.
Note: Streams are single-use. Once a terminal operation runs, the stream is exhausted and cannot be reused.
filter — Keeping What You Need
filter(Predicate<T>) is an intermediate operation. It accepts a lambda that returns true for elements you want to keep and false for those you want to drop.
import java.util.List;
import java.util.stream.Collectors;
public class FilterExample {
public static void main(String[] args) {
List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
List<Integer> evens = numbers.stream()
.filter(n -> n % 2 == 0)
.collect(Collectors.toList());
System.out.println(evens);
}
}
Output:
[2, 4, 6, 8, 10]
You can chain multiple filter calls — each narrows the stream further:
List<String> names = List.of("Alice", "Bob", "Anna", "Charlie", "Amy");
List<String> result = names.stream()
.filter(name -> name.startsWith("A"))
.filter(name -> name.length() > 3)
.collect(Collectors.toList());
System.out.println(result); // [Alice, Anna]
Output:
[Alice, Anna]
Tip: Chaining two
filtercalls is just as efficient as combining conditions with&&— the JIT compiler fuses them. Prefer separate filters when it aids readability.
map — Transforming Elements
map(Function<T, R>) converts each element from type T to type R. The result stream has the same number of elements but potentially a different type.
import java.util.List;
import java.util.stream.Collectors;
public class MapExample {
public static void main(String[] args) {
List<String> words = List.of("hello", "world", "java", "streams");
List<String> upper = words.stream()
.map(String::toUpperCase)
.collect(Collectors.toList());
System.out.println(upper);
}
}
Output:
[HELLO, WORLD, JAVA, STREAMS]
Mapping to a different type
import java.util.List;
import java.util.stream.Collectors;
public class MapTypeChange {
public static void main(String[] args) {
List<String> prices = List.of("9.99", "24.50", "3.00");
List<Double> parsed = prices.stream()
.map(Double::parseDouble)
.collect(Collectors.toList());
System.out.println(parsed);
}
}
Output:
[9.99, 24.5, 3.0]
Primitive-specialised map variants
To avoid boxing overhead when working with primitives, use mapToInt, mapToLong, or mapToDouble. These return specialised streams (IntStream, LongStream, DoubleStream) with extra numeric helpers like sum() and average().
import java.util.List;
public class MapToInt {
public static void main(String[] args) {
List<String> names = List.of("Alice", "Bob", "Charlie");
int totalLength = names.stream()
.mapToInt(String::length)
.sum();
System.out.println("Total characters: " + totalLength);
}
}
Output:
Total characters: 16
Tip: Prefer
mapToInt/mapToDoubleovermapwhenever you will call a numeric terminal operation. It skips theInteger/Doubleboxing step and is measurably faster on large streams.
flatMap — One-to-Many Transformations
flatMap is map’s sibling for when each element maps to multiple elements. It “flattens” the resulting streams into one.
import java.util.List;
import java.util.stream.Collectors;
public class FlatMapExample {
public static void main(String[] args) {
List<List<Integer>> nested = List.of(
List.of(1, 2, 3),
List.of(4, 5),
List.of(6, 7, 8, 9)
);
List<Integer> flat = nested.stream()
.flatMap(List::stream)
.collect(Collectors.toList());
System.out.println(flat);
}
}
Output:
[1, 2, 3, 4, 5, 6, 7, 8, 9]
reduce — Aggregating to a Single Value
reduce is a terminal operation that combines stream elements into a single result using an accumulator function. There are three overloads:
| Overload | Returns | Use when |
|---|---|---|
reduce(identity, BinaryOperator) | T | You have a safe starting value |
reduce(BinaryOperator) | Optional<T> | No identity; stream may be empty |
reduce(identity, BiFunction, BinaryOperator) | U | Different result type (parallel-friendly) |
Sum with an identity value
import java.util.List;
public class ReduceSum {
public static void main(String[] args) {
List<Integer> numbers = List.of(1, 2, 3, 4, 5);
int sum = numbers.stream()
.reduce(0, Integer::sum);
System.out.println("Sum: " + sum);
}
}
Output:
Sum: 15
Finding max without an identity
import java.util.List;
import java.util.Optional;
public class ReduceMax {
public static void main(String[] args) {
List<Integer> numbers = List.of(3, 7, 1, 9, 4);
Optional<Integer> max = numbers.stream()
.reduce(Integer::max);
max.ifPresent(m -> System.out.println("Max: " + m));
}
}
Output:
Max: 9
Note: The no-identity overload returns
Optional<T>because the stream could be empty. Always handle the empty case — useifPresent,orElse, ororElseThrow. See Optional for details.
Building a string with reduce
import java.util.List;
public class ReduceString {
public static void main(String[] args) {
List<String> words = List.of("Java", "Streams", "Are", "Powerful");
String sentence = words.stream()
.reduce("", (a, b) -> a.isEmpty() ? b : a + " " + b);
System.out.println(sentence);
}
}
Output:
Java Streams Are Powerful
Tip: For joining strings, prefer
Collectors.joining(" ")over reduce — it uses aStringBuilderinternally and is more efficient.
Combining filter + map + reduce
The real power shows when you chain all three together:
import java.util.List;
public class Pipeline {
public static void main(String[] args) {
List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
// Sum of squares of even numbers
int result = numbers.stream()
.filter(n -> n % 2 == 0) // keep evens: 2,4,6,8,10
.mapToInt(n -> n * n) // square each: 4,16,36,64,100
.sum(); // aggregate
System.out.println("Sum of squares of evens: " + result);
}
}
Output:
Sum of squares of evens: 220
Quick Reference: Common Intermediate & Terminal Operations
| Operation | Type | Purpose |
|---|---|---|
filter(Predicate) | Intermediate | Drop elements that don’t match |
map(Function) | Intermediate | Transform each element |
flatMap(Function) | Intermediate | Flatten nested streams |
distinct() | Intermediate | Remove duplicates |
sorted() / sorted(Comparator) | Intermediate | Sort elements |
limit(n) | Intermediate | Take first n elements |
skip(n) | Intermediate | Skip first n elements |
peek(Consumer) | Intermediate | Debug/side-effect without changing stream |
reduce(…) | Terminal | Fold to a single value |
collect(Collector) | Terminal | Accumulate into a collection/string/map |
forEach(Consumer) | Terminal | Iterate with a side effect |
count() | Terminal | Count remaining elements |
findFirst() / findAny() | Terminal | Return first/any element as Optional |
anyMatch / allMatch / noneMatch | Terminal | Boolean short-circuit tests |
See Stream API for the full API and Collectors for collecting results.
Under the Hood
Lazy evaluation
Intermediate operations build a pipeline description — no iteration happens yet. When a terminal operation is called, the JVM traverses the source once, passing each element through the entire pipeline before moving to the next. This means a filter → map pipeline on a million-element list does exactly one pass, not two.
element 1 → filter? yes → map → accumulate
element 2 → filter? no → skip
element 3 → filter? yes → map → accumulate
…
Short-circuit terminals like findFirst() can make this even cheaper — the pipeline stops as soon as the first matching element is found.
Spliterator & parallel streams
Every collection provides a Spliterator that knows how to split itself for parallel work. Changing .stream() to .parallelStream() hands each split to the common ForkJoinPool. reduce with an associative identity is safe for parallel use; stateful operations like sorted() force a merge step.
Boxing cost
map(n -> n * 2) on a Stream<Integer> boxes every int into an Integer object, creating GC pressure. mapToInt(n -> n * 2) on an IntStream keeps everything on the stack as a primitive. For hot paths with large data, always prefer primitive streams.
Stream vs loop performance
For small collections (< ~1 000 elements) a traditional enhanced for-each loop is often faster due to JVM overhead from lambda creation and stream infrastructure. Streams shine for readability and — when combined with parallelStream() — for CPU-bound operations on large datasets.
Related Topics
- Stream API — overview of the Stream interface and how to create streams
- Collectors — collect stream results into lists, maps, grouped data, and more
- Lambda Expressions — the syntax powering every stream predicate and function
- Optional — handle the empty-stream case returned by
reduceandfindFirst - Functional Interfaces —
Predicate,Function,BinaryOperatorand their role in stream operations - for-each Loop — when a plain loop is simpler or faster than a stream