Streams API
Overview
The Java Streams API (introduced in Java 8) revolutionized how Java developers process collections of data. A Stream is a sequence of elements from a source (like a List or array) that supports a pipeline of operations to transform, filter, or aggregate that data. It is not a data structure — it doesn't store data. It is a computation model.
Streams are designed around a three-phase pipeline:
1. Source: Where the stream originates (list.stream(), Arrays.stream(arr), Stream.of(...)).
2. Intermediate Operations: Lazy transformations that return a new Stream (filter, map, sorted, distinct, limit). They are lazy — they do nothing until a terminal operation is called.
3. Terminal Operations: They trigger the entire pipeline and produce a result or side effect (collect, count, forEach, reduce, findFirst, anyMatch).
Streams are by default lazy — intermediate operations are not executed until a terminal operation demands results. This means chaining 5 intermediate operations doesn't traverse the data 5 times; it traverses once with all 5 operations applied per element. This makes Streams highly efficient. They also support Parallel Streams (parallelStream()) to automatically leverage multi-core CPUs.
Syntax
Notice how stream pipelines are declarative. You declare what you want to achieve, rather than writing the manual loops to specify how to iterate over the data.
import java.util.*;
import java.util.stream.*;
public class Main {
public static void main(String[] args) {
List<Integer> numbers = List.of(5, 2, 8, 1, 9, 3, 7, 4, 6, 10);
// 1. Full Pipeline: filter → sort → map → collect
List<String> result = numbers.stream()
.filter(n -> n > 5) // Keep only 6, 7, 8, 9, 10
.sorted() // Sort ascending: 6, 7, 8, 9, 10
.map(n -> "Item #" + n) // Transform: "Item #6", etc.
.collect(Collectors.toList()); // Terminal: collect to List
System.out.println(result);
// 2. Aggregation Operations
int total = numbers.stream()
.reduce(0, Integer::sum); // Sum all elements
System.out.println("Total: " + total); // 55
long count = numbers.stream()
.filter(n -> n % 2 == 0) // Even numbers
.count(); // Count them
System.out.println("Even count: " + count); // 5
// 3. Short-circuiting Terminal Operations
boolean anyOver8 = numbers.stream().anyMatch(n -> n > 8); // true
Optional<Integer> first = numbers.stream()
.filter(n -> n > 7)
.findFirst(); // Optional[8]
// 4. Numeric Streams (Avoid Boxing!)
double avg = numbers.stream()
.mapToInt(Integer::intValue) // IntStream (no boxing)
.average()
.orElse(0);
System.out.println("Average: " + avg); // 5.5
}
}Common Pitfalls
- Reusing a Stream after a terminal operation has consumed it. A Stream can only be traversed ONCE. Calling
stream.count()and thenstream.forEach(...)will throwIllegalStateException: stream has already been operated upon or closed. Always create a fresh stream from the source for each terminal operation. - Using parallel streams carelessly. While
parallelStream()sounds like a free performance win, it introduces the overhead of thread pool management and splitting/merging data. For small lists or collections whose processing is CPU-intensive, parallel streams are far slower than sequential. Only use them after profiling with large data sets. - Performing side effects inside intermediate operations.
map()andfilter()are supposed to be pure functions — they should not modify external state. Doinglist.stream().forEach(item -> externalList.add(item))insidemap()causes confusing behavior due to Streams' lazy and potentially parallel nature. Usecollect()for aggregation instead.
Interview Questions
Intermediate operations (like filter(), map(), sorted()) are lazy — they don't process any data when called. They just record the operation to be applied. Terminal operations (like collect(), count(), forEach()) trigger the entire pipeline, processing all elements through all recorded intermediate operations in a single traversal.
map() and flatMap() in Streams?map() transforms each element into exactly one output element (1-to-1 transformation). flatMap() transforms each element into a Stream of zero or more elements, then flattens all those streams into a single stream (1-to-many). It is used for operations like splitting a list of sentences into a stream of individual words.
IntStream, LongStream, or DoubleStream over Stream<Integer>?The specialized primitive streams (IntStream, LongStream, DoubleStream) avoid autoboxing/unboxing, which creates Integer/Long/Double wrapper objects in the heap for every element. For numeric-heavy computations on large datasets, primitive streams can be 3-5x faster and generate far less garbage for the JVM's GC.
Real-World Example
Data transformation pipelines are a perfect fit for Streams. Processing a batch of orders from a database — filtering only shipped orders, extracting the total revenue per customer, and sorting the results — is expressed as a clean, readable, and highly optimized stream pipeline.
// Processing thousands of orders efficiently
Map<String, Double> revenueByCustomer = orders.stream()
.filter(order -> order.getStatus() == SHIPPED)
.filter(order -> order.getAmount() > 0)
.collect(Collectors.groupingBy(
Order::getCustomerId,
Collectors.summingDouble(Order::getAmount)
));
// Sorted leaderboard by revenue
revenueByCustomer.entrySet().stream()
.sorted(Map.Entry.<String, Double>comparingByValue().reversed())
.limit(10)
.forEach(e -> System.out.println(e.getKey() + ": $" + e.getValue()));Check Your Knowledge
Test your understanding of Streams API with these quick questions.