The Streams API, introduced in Java 8, fundamentally changed how Java developers process collections. Instead of writing loops that describe how to iterate, you write pipelines that describe what to compute. The result is code that is shorter, easier to parallelise, and far more composable than the equivalent imperative loop.
This guide is designed as a reference you will return to. Every method in the API is covered with a working example and annotated output. The final sections address collectors in depth, parallel streams, and the mistakes that trip up developers who are new to the functional style.
What Is a Stream?
A Stream<T> is a sequence of elements that supports sequential and parallel aggregate operations. It is not a data structure โ it does not store elements. Instead, it wraps a data source (a collection, an array, an I/O channel) and lets you express a chain of transformations called a pipeline.
A stream pipeline has three parts:
- Source โ a collection, array, generator function, or I/O channel that supplies the elements.
- Intermediate operations โ zero or more lazy transformations (filter, map, sorted โฆ) that return another stream.
- Terminal operation โ exactly one eager operation (collect, forEach, reduce โฆ) that triggers the pipeline and produces a result or side effect.
Streams are lazy: intermediate operations are not executed until a terminal operation is invoked. They are also single-use: once a terminal operation has run, the stream is consumed and cannot be reused.
1. Creating Streams
import java.util.Arrays;
import java.util.List;
import java.util.stream.IntStream;
import java.util.stream.Stream;
public class StreamCreation {
public static void main(String[] args) {
// 1. From a Collection (most common)
List names = List.of("Alice", "Bob", "Carol");
Stream fromList = names.stream();
// 2. From varargs / inline values
Stream fromOf = Stream.of(1, 2, 3, 4, 5);
// 3. From an array
String[] arr = {"x", "y", "z"};
Stream fromArray = Arrays.stream(arr);
// 4. Primitive streams (avoid boxing overhead)
IntStream range = IntStream.range(1, 6); // 1, 2, 3, 4, 5
IntStream rangeClosed = IntStream.rangeClosed(1, 5); // 1, 2, 3, 4, 5
// 5. Infinite stream with generate()
Stream randoms = Stream.generate(Math::random).limit(3);
// 6. Infinite stream with iterate()
Stream evens = Stream.iterate(0, n -> n + 2).limit(5);
evens.forEach(System.out::println);
// Output: 0 2 4 6 8
}
}
2. Intermediate Operations
Intermediate operations are lazy and return a new stream. They can be chained in any order, but the order matters for performance: put the cheapest, most selective filters first.
filter() โ keep elements matching a predicate
List numbers = List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
// Keep only even numbers
List evens = numbers.stream()
.filter(n -> n % 2 == 0) // predicate: true if even
.toList(); // terminal: collect to List (Java 16+)
System.out.println(evens);
// Output: [2, 4, 6, 8, 10]
map() โ transform each element
List words = List.of("hello", "world", "java");
// Convert each word to uppercase
List upper = words.stream()
.map(String::toUpperCase) // method reference to String.toUpperCase()
.toList();
System.out.println(upper);
// Output: [HELLO, WORLD, JAVA]
flatMap() โ flatten nested structures
flatMap maps each element to a stream, then flattens all resulting streams into one. The classic use case is a list of lists.
List> nested = List.of(
List.of(1, 2, 3),
List.of(4, 5),
List.of(6, 7, 8, 9)
);
// Flatten into a single stream of integers
List flat = nested.stream()
.flatMap(List::stream) // each inner List becomes a stream; flatMap merges them
.toList();
System.out.println(flat);
// Output: [1, 2, 3, 4, 5, 6, 7, 8, 9]
sorted() โ order elements
List names = List.of("Carol", "Alice", "Bob", "Dave");
// Natural order
List asc = names.stream().sorted().toList();
System.out.println(asc); // [Alice, Bob, Carol, Dave]
// Reverse order via Comparator
List desc = names.stream()
.sorted(Comparator.reverseOrder())
.toList();
System.out.println(desc); // [Dave, Carol, Bob, Alice]
// Sort by string length, then alphabetically
List byLen = names.stream()
.sorted(Comparator.comparingInt(String::length).thenComparing(Comparator.naturalOrder()))
.toList();
System.out.println(byLen); // [Bob, Alice, Carol, Dave]
distinct(), limit(), skip()
List nums = List.of(1, 2, 2, 3, 3, 3, 4, 5);
// distinct() removes duplicates (uses equals())
System.out.println(nums.stream().distinct().toList());
// Output: [1, 2, 3, 4, 5]
// limit(n) truncates to the first n elements
System.out.println(nums.stream().limit(4).toList());
// Output: [1, 2, 2, 3]
// skip(n) discards the first n elements
System.out.println(nums.stream().skip(5).toList());
// Output: [3, 4, 5]
peek() โ observe without modifying
peek is an intermediate operation that applies a Consumer to each element as it flows through the pipeline. Its primary use is debugging; never rely on it for side effects in production code.
List result = List.of(1, 2, 3, 4, 5).stream()
.filter(n -> n % 2 != 0)
.peek(n -> System.out.println("After filter: " + n)) // debug observer
.map(n -> n * n)
.peek(n -> System.out.println("After map : " + n))
.toList();
// Output:
// After filter: 1
// After map : 1
// After filter: 3
// After map : 9
// After filter: 5
// After map : 25
3. Terminal Operations
collect() โ gather into a collection
import java.util.stream.Collectors;
List names = List.of("Alice", "Bob", "Carol", "Dave", "Alice");
// Collect to a mutable ArrayList
List list = names.stream().collect(Collectors.toList());
// Collect to an unmodifiable List (Java 10+)
List unmodifiable = names.stream().collect(Collectors.toUnmodifiableList());
// Collect to a Set (removes duplicates)
var set = names.stream().collect(Collectors.toSet());
// Join with a delimiter
String joined = names.stream()
.distinct()
.collect(Collectors.joining(", ", "[", "]"));
System.out.println(joined);
// Output: [Alice, Bob, Carol, Dave]
// Group by first letter
var grouped = names.stream()
.collect(Collectors.groupingBy(s -> s.charAt(0)));
System.out.println(grouped);
// Output: {A=[Alice, Alice], B=[Bob], C=[Carol], D=[Dave]}
reduce() โ fold elements into one value
List nums = List.of(1, 2, 3, 4, 5);
// Sum with an identity value (returns int, not Optional)
int sum = nums.stream()
.reduce(0, Integer::sum); // 0 is the identity; Integer::sum is the accumulator
System.out.println(sum); // 15
// Product without identity (returns Optional)
var product = nums.stream()
.reduce((a, b) -> a * b);
product.ifPresent(System.out::println); // 120
// Concatenate strings
String sentence = Stream.of("Java", "Streams", "are", "powerful")
.reduce("", (a, b) -> a.isEmpty() ? b : a + " " + b);
System.out.println(sentence); // Java Streams are powerful
Matching and finding
List data = List.of(3, 7, 1, 9, 4, 6);
// anyMatch โ returns true if at least one element matches
boolean hasEven = data.stream().anyMatch(n -> n % 2 == 0);
System.out.println(hasEven); // true
// allMatch โ returns true if every element matches
boolean allPositive = data.stream().allMatch(n -> n > 0);
System.out.println(allPositive); // true
// noneMatch โ returns true if no element matches
boolean noneNegative = data.stream().noneMatch(n -> n < 0);
System.out.println(noneNegative); // true
// findFirst โ returns the first element as Optional
data.stream()
.filter(n -> n > 5)
.findFirst()
.ifPresent(System.out::println); // 7
// count, min, max
System.out.println(data.stream().count()); // 6
System.out.println(data.stream().min(Integer::compare)); // Optional[1]
System.out.println(data.stream().max(Integer::compare)); // Optional[9]
4. Collectors in Depth
import java.util.stream.Collectors;
import java.util.*;
record Person(String name, String city, int age) {}
List people = List.of(
new Person("Alice", "London", 30),
new Person("Bob", "London", 25),
new Person("Carol", "Paris", 35),
new Person("Dave", "Paris", 28),
new Person("Eve", "London", 22)
);
// --- groupingBy: group people by city ---
Map> byCity = people.stream()
.collect(Collectors.groupingBy(Person::city));
// {London=[Alice, Bob, Eve], Paris=[Carol, Dave]}
// --- groupingBy + counting: how many people per city ---
Map countByCity = people.stream()
.collect(Collectors.groupingBy(Person::city, Collectors.counting()));
System.out.println(countByCity);
// {London=3, Paris=2}
// --- groupingBy + averaging: average age per city ---
Map avgAgeByCity = people.stream()
.collect(Collectors.groupingBy(Person::city,
Collectors.averagingInt(Person::age)));
System.out.println(avgAgeByCity);
// {London=25.666..., Paris=31.5}
// --- partitioningBy: split into two groups (true/false) ---
Map> over28 = people.stream()
.collect(Collectors.partitioningBy(p -> p.age() > 28));
System.out.println(over28.get(true)); // [Alice, Carol]
System.out.println(over28.get(false)); // [Bob, Dave, Eve]
// --- toMap: city -> oldest person in that city ---
Map oldestByCity = people.stream()
.collect(Collectors.toMap(
Person::city,
Person::name,
(existing, incoming) -> existing // merge function: keep first
));
System.out.println(oldestByCity); // {London=Alice, Paris=Carol}
5. Primitive Streams: IntStream, LongStream, DoubleStream
Storing primitives in a Stream<Integer> forces boxing/unboxing on every element. For numeric work, use the specialised primitive streams instead โ they are significantly faster for large data sets.
// IntStream.range produces an int sequence without boxing
int sumOf1to100 = IntStream.rangeClosed(1, 100).sum();
System.out.println(sumOf1to100); // 5050
// Statistics in one pass
IntSummaryStatistics stats = IntStream.of(3, 1, 4, 1, 5, 9, 2, 6)
.summaryStatistics();
System.out.println("Min : " + stats.getMin()); // 1
System.out.println("Max : " + stats.getMax()); // 9
System.out.println("Sum : " + stats.getSum()); // 31
System.out.println("Avg : " + stats.getAverage()); // 3.875
System.out.println("Count : " + stats.getCount()); // 8
// Map a Stream to an IntStream to avoid boxing
List words = List.of("hello", "world", "streams");
int totalChars = words.stream()
.mapToInt(String::length) // Stream -> IntStream (no boxing)
.sum();
System.out.println(totalChars); // 19
6. Parallel Streams
Switching a sequential stream to parallel requires only changing .stream() to .parallelStream() (or calling .parallel() on any existing stream). The framework splits the source, processes chunks concurrently on the common ForkJoinPool, and merges the results.
// Parallel sum โ safe because addition is associative and stateless
long count = 1_000_000L;
long parallelSum = LongStream.rangeClosed(1, count)
.parallel()
.sum();
System.out.println(parallelSum); // 500000500000
// WARNING: parallel streams are NOT always faster.
// Parallelism pays off only when:
// 1. The data source is large (tens of thousands of elements minimum)
// 2. The work per element is non-trivial (avoid for simple filter/map)
// 3. The pipeline is stateless and the operations are order-independent
// 4. No shared mutable state is accessed inside the lambda
7. Common Pitfalls
| Pitfall | What goes wrong | Fix |
|---|---|---|
| Reusing a stream | IllegalStateException: stream has already been operated upon | Create a new stream from the source each time |
| Mutating state inside a lambda | Race conditions in parallel; undefined behaviour in sequential | Use stateless lambdas; collect into new objects rather than modifying existing ones |
Calling .collect(Collectors.toList()) when .toList() (Java 16+) suffices | Verbose, returns a mutable list unnecessarily | Prefer .toList() for read-only results |
Using forEach to build a collection | Defeats laziness, breaks parallelism, order not guaranteed | Use collect() instead of mutating an external list inside forEach |
Over-using peek for side effects | peek may not fire on all elements if the pipeline short-circuits | Use peek only for debugging; use forEach or map for real side effects / transforms |
| Parallel streams on small data | Thread-pool overhead makes it slower than sequential | Benchmark; only parallelise if data set is large and work per element is heavy |
Quick-Reference: Every Operation at a Glance
| Operation | Type | Returns | Notes |
|---|---|---|---|
filter(Predicate) | Intermediate | Stream<T> | Keeps elements matching predicate |
map(Function) | Intermediate | Stream<R> | 1-to-1 transform |
flatMap(Function) | Intermediate | Stream<R> | 1-to-many transform, then flatten |
distinct() | Intermediate | Stream<T> | Uses equals() |
sorted() | Intermediate | Stream<T> | Natural or custom Comparator |
limit(n) | Intermediate | Stream<T> | First n elements |
skip(n) | Intermediate | Stream<T> | Drop first n elements |
peek(Consumer) | Intermediate | Stream<T> | Debug observer |
collect(Collector) | Terminal | R | Most versatile terminal op |
toList() | Terminal | List<T> | Java 16+; unmodifiable |
reduce(identity, BinaryOp) | Terminal | T | Fold to single value |
forEach(Consumer) | Terminal | void | Side effects; order not guaranteed in parallel |
count() | Terminal | long | |
min/max(Comparator) | Terminal | Optional<T> | |
anyMatch/allMatch/noneMatch | Terminal | boolean | Short-circuit |
findFirst/findAny | Terminal | Optional<T> | findAny may be faster in parallel |
See Also
- Java Collections Framework: Choosing the Right Data Structure
- Parameterized Tests in JUnit 6: All Sources Explained with Examples
- Multithreading Example in Java
Conclusion
The Streams API is built around three ideas: laziness (work is deferred until a terminal operation triggers the pipeline), composability (intermediate operations chain cleanly without intermediate allocations), and optionality of parallelism (add .parallel() and the framework handles the thread management). Fluency with filter, map, flatMap, and the collector library covers the vast majority of real-world use cases. The edge cases โ primitive streams for performance, reduce for custom aggregations, partitioningBy and groupingBy for analytics โ are what separate developers who know the API from developers who truly understand it.