Stream API
The Stream API, introduced in Java 8, lets you process sequences of elements — lists, sets, arrays, or any data source — using a clean, readable pipeline of operations. Instead of writing imperative for loops with mutable variables, you declare what you want and let the API figure out how to do it.
What is a Stream?
A Stream<T> is not a data structure. It doesn’t store elements — it carries them through a series of transformation steps called a pipeline. Once a stream has been consumed, you can’t reuse it.
Every stream pipeline has three parts:
| Part | Example | Purpose |
|---|---|---|
| Source | list.stream() | Produces the elements |
| Intermediate ops | .filter(), .map() | Transform lazily (zero to many) |
| Terminal op | .collect(), .count() | Triggers execution, produces result |
Note: Intermediate operations are lazy — nothing runs until a terminal operation is called. This makes it efficient to chain many operations without extra passes over data.
Creating a Stream
You can create a stream from many sources:
import java.util.List;
import java.util.stream.Stream;
public class StreamSources {
public static void main(String[] args) {
// From a Collection
List<String> names = List.of("Alice", "Bob", "Charlie");
Stream<String> fromList = names.stream();
// From individual values
Stream<Integer> fromValues = Stream.of(1, 2, 3, 4, 5);
// From an array
String[] arr = {"x", "y", "z"};
Stream<String> fromArray = Stream.of(arr);
// Infinite stream — first 5 even numbers
Stream<Integer> evens = Stream.iterate(0, n -> n + 2).limit(5);
evens.forEach(System.out::println);
}
}
Output:
0
2
4
6
8
Intermediate Operations
These operations transform the stream. They’re lazy and return a new stream.
filter()
Keeps only elements matching a predicate:
import java.util.List;
import java.util.stream.Collectors;
public class FilterDemo {
public static void main(String[] args) {
List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6, 7, 8);
List<Integer> evens = numbers.stream()
.filter(n -> n % 2 == 0)
.collect(Collectors.toList());
System.out.println(evens);
}
}
Output:
[2, 4, 6, 8]
map()
Transforms each element into something else:
import java.util.List;
import java.util.stream.Collectors;
public class MapDemo {
public static void main(String[] args) {
List<String> names = List.of("alice", "bob", "charlie");
List<String> upper = names.stream()
.map(String::toUpperCase)
.collect(Collectors.toList());
System.out.println(upper);
}
}
Output:
[ALICE, BOB, CHARLIE]
flatMap()
Use flatMap() when each element maps to multiple elements (flattens one level of nesting):
import java.util.List;
import java.util.stream.Collectors;
public class FlatMapDemo {
public static void main(String[] args) {
List<List<Integer>> nested = List.of(
List.of(1, 2),
List.of(3, 4),
List.of(5, 6)
);
List<Integer> flat = nested.stream()
.flatMap(List::stream)
.collect(Collectors.toList());
System.out.println(flat);
}
}
Output:
[1, 2, 3, 4, 5, 6]
sorted(), distinct(), limit(), skip()
These are simple but powerful shaping operations:
import java.util.List;
import java.util.stream.Collectors;
public class ShapingDemo {
public static void main(String[] args) {
List<Integer> nums = List.of(5, 3, 1, 4, 1, 5, 9, 2, 6);
List<Integer> result = nums.stream()
.distinct() // remove duplicates
.sorted() // sort ascending
.skip(2) // skip first 2
.limit(4) // take next 4
.collect(Collectors.toList());
System.out.println(result);
}
}
Output:
[3, 4, 5, 6]
peek()
Handy for debugging a pipeline without affecting it:
List<String> result = names.stream()
.peek(n -> System.out.println("before: " + n))
.filter(n -> n.length() > 3)
.peek(n -> System.out.println("after: " + n))
.collect(Collectors.toList());
Tip: Don’t use
peek()in production code for side effects — it’s a debug tool. Side effects belong inforEach().
Terminal Operations
Terminal operations kick off the pipeline and produce a result.
collect()
The most flexible terminal op — gathers elements into a collection or other result. Pair it with the Collectors utility class:
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
public class CollectDemo {
public static void main(String[] args) {
List<String> words = List.of("apple", "banana", "avocado", "blueberry");
// Group by first letter
Map<Character, List<String>> grouped = words.stream()
.collect(Collectors.groupingBy(w -> w.charAt(0)));
System.out.println(grouped);
}
}
Output:
{a=[apple, avocado], b=[banana, blueberry]}
reduce()
Combines all elements into a single value using an accumulator function:
import java.util.List;
import java.util.Optional;
public class ReduceDemo {
public static void main(String[] args) {
List<Integer> nums = List.of(1, 2, 3, 4, 5);
// Sum with identity value
int sum = nums.stream()
.reduce(0, Integer::sum);
System.out.println("Sum: " + sum);
// Without identity — returns Optional
Optional<Integer> max = nums.stream()
.reduce(Integer::max);
max.ifPresent(m -> System.out.println("Max: " + m));
}
}
Output:
Sum: 15
Max: 5
count(), min(), max(), findFirst(), anyMatch()
import java.util.List;
import java.util.Optional;
public class TerminalDemo {
public static void main(String[] args) {
List<Integer> nums = List.of(3, 1, 4, 1, 5, 9, 2, 6);
long count = nums.stream().filter(n -> n > 4).count();
Optional<Integer> min = nums.stream().min(Integer::compareTo);
boolean hasNine = nums.stream().anyMatch(n -> n == 9);
System.out.println("Count > 4: " + count);
System.out.println("Min: " + min.orElse(-1));
System.out.println("Has 9: " + hasNine);
}
}
Output:
Count > 4: 3
Min: 1
Has 9: true
Primitive Streams
Boxing integers into Integer objects has overhead. Java provides three specialized primitive streams:
IntStream— forintvaluesLongStream— forlongvaluesDoubleStream— fordoublevalues
import java.util.IntSummaryStatistics;
import java.util.stream.IntStream;
public class IntStreamDemo {
public static void main(String[] args) {
// Sum of 1 to 100
int sum = IntStream.rangeClosed(1, 100).sum();
System.out.println("Sum 1-100: " + sum);
// Statistics in one pass
IntSummaryStatistics stats = IntStream.of(3, 1, 4, 1, 5, 9)
.summaryStatistics();
System.out.println("Average: " + stats.getAverage());
System.out.println("Max: " + stats.getMax());
}
}
Output:
Sum 1-100: 5050
Average: 3.8333333333333335
Max: 9
Tip: Prefer
IntStream,LongStream, orDoubleStreamoverStream<Integer>when processing large numeric datasets. It avoids autoboxing and is significantly faster. See Autoboxing & Unboxing for why that matters.
Parallel Streams
Switch a stream to run on multiple CPU cores with a single method call:
import java.util.List;
public class ParallelDemo {
public static void main(String[] args) {
List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6, 7, 8);
int sum = numbers.parallelStream()
.mapToInt(Integer::intValue)
.sum();
System.out.println("Sum: " + sum);
}
}
Warning: Parallel streams use the common
ForkJoinPool. They help with CPU-bound work on large datasets (thousands of elements or more), but add overhead for small collections. Also avoid parallel streams when your operations have side effects or depend on order.
Under the Hood
Lazy Evaluation and the Pipeline
When you write .filter(...).map(...).collect(...), the JVM does not create intermediate lists. Instead, it builds a chain of Spliterator-backed stages. Each element moves through the entire pipeline before the next element starts. This means:
- Memory usage stays low — no intermediate collections.
- Short-circuiting terminals like
findFirst()oranyMatch()can stop after the first match without processing the rest.
Spliterator
Every stream source wraps a Spliterator (splittable iterator) — the mechanism that both drives sequential iteration and allows parallel splitting. The ArrayList spliterator, for example, knows the exact size in advance (SIZED | SUBSIZED characteristics), enabling the parallel splitter to divide work evenly.
Internal Pipeline Representation
Intermediate operations are represented as linked StatelessOp or StatefulOp nodes. Stateful operations (like sorted() and distinct()) must consume all upstream elements before emitting anything, which breaks lazy evaluation for the portion before them. For large pipelines with sorted() near the end, this can hold the entire dataset in memory.
Method References and Lambda Compilation
Lambda expressions passed to stream operations are compiled to invokedynamic bytecodes. The JIT compiler can often inline them as effectively as hand-written loops — meaning a well-written stream pipeline is usually not slower than an equivalent for loop. See JIT Compilation & Bytecode for the deeper picture.
Common Patterns
Chaining filter + map + collect
import java.util.List;
import java.util.stream.Collectors;
record Person(String name, int age) {}
public class ChainDemo {
public static void main(String[] args) {
List<Person> people = List.of(
new Person("Alice", 32),
new Person("Bob", 17),
new Person("Carol", 28),
new Person("Dave", 15)
);
List<String> adults = people.stream()
.filter(p -> p.age() >= 18)
.map(Person::name)
.sorted()
.collect(Collectors.toList());
System.out.println(adults);
}
}
Output:
[Alice, Carol]
Joining strings
import java.util.List;
import java.util.stream.Collectors;
public class JoinDemo {
public static void main(String[] args) {
List<String> tags = List.of("java", "streams", "functional");
String joined = tags.stream()
.collect(Collectors.joining(", ", "[", "]"));
System.out.println(joined);
}
}
Output:
[java, streams, functional]
Related Topics
- Stream Operations (filter/map/reduce) — a deeper look at every intermediate and terminal operation
- Collectors — group, partition, join, and summarize with the
Collectorsutility class - Lambda Expressions — the foundation that makes stream syntax possible
- Functional Interfaces —
Predicate,Function,Consumer, and friends used inside streams - Optional — the safe null-avoiding wrapper returned by
findFirst(),min(),max(), andreduce() - Method References — shorthand lambda syntax that pairs naturally with stream operations