Streams
The Java Streams API (introduced in Java 8) is a functional-style facility for processing sequences of values: filter, map, reduce, group, and similar operations expressed as pipelines of intermediate and terminal operations. The mechanism is the Java analogue of LINQ, with substantial differences in evaluation model and surface — Java streams are single-use, predominantly lazy, and have explicit primitive specialisations (IntStream, LongStream, DoubleStream) to avoid boxing in numeric workloads.
The streams API is one of the most-used additions of modern Java; nearly every non-trivial codebase contains stream pipelines for collection processing. This page covers the principal operations, the evaluation model, and the conventions for using the API. The collection types the streams operate on are in Data structures.
Sources
A stream is created from a source. The principal source operations:
import java.util.stream.Stream;
import java.util.stream.IntStream;
// From a collection:
List<Integer> list = List.of(1, 2, 3, 4, 5);
Stream<Integer> s1 = list.stream();
Stream<Integer> s2 = list.parallelStream(); // for parallel processing
// From values:
Stream<String> s3 = Stream.of("a", "b", "c");
// From an array:
Stream<String> s4 = Arrays.stream(arr);
IntStream is = IntStream.of(1, 2, 3, 4, 5);
// Generated:
Stream<Integer> s5 = Stream.iterate(1, i -> i * 2); // 1, 2, 4, 8, ... (infinite)
Stream<Double> s6 = Stream.generate(Math::random); // infinite random doubles
// Range (primitive):
IntStream range = IntStream.range(0, 100); // 0..99
IntStream closed = IntStream.rangeClosed(1, 100); // 1..100
// From a file:
Stream<String> lines = Files.lines(path); // each line; AutoCloseable
// From a regex split:
Stream<String> tokens = Pattern.compile(",").splitAsStream(input);
The Stream<T> interface is the principal generic stream; IntStream, LongStream, DoubleStream are primitive specialisations that avoid the overhead of autoboxing.
Intermediate operations
Intermediate operations transform a stream into another stream. They are lazy — invoking an intermediate operation does not consume any elements; consumption happens only when a terminal operation runs.
The principal intermediate operations:
| Operation | Effect |
|---|---|
filter(pred) | Keep elements where pred(x) is true |
map(fn) | Project each element through fn |
mapToInt(fn) / mapToLong(fn) / mapToDouble(fn) | Project to a primitive stream |
flatMap(fn) | Project to a stream and flatten |
flatMapToInt(fn) / flatMapToLong(fn) / flatMapToDouble(fn) | Same, primitive |
distinct() | Remove duplicates |
sorted() | Sort by natural order |
sorted(Comparator) | Sort by comparator |
peek(consumer) | Side-effecting view (typically for debugging) |
limit(n) | First n elements |
skip(n) | Skip the first n |
takeWhile(pred) | Elements until pred first fails (since Java 9) |
dropWhile(pred) | Elements after pred first fails (since Java 9) |
mapMulti(consumer) | Generalised flat-map (since Java 16) |
boxed() | (On primitive streams) box to a Stream<Integer> etc. |
List<Integer> evenSquares = numbers.stream()
.filter(n -> n % 2 == 0)
.map(n -> n * n)
.toList();
double average = orders.stream()
.filter(Order::isActive)
.mapToDouble(Order::amount)
.average()
.orElse(0.0);
List<String> words = sentences.stream()
.flatMap(s -> Arrays.stream(s.split(" ")))
.distinct()
.sorted()
.toList();
Terminal operations
Terminal operations consume the stream and produce a result (or a side effect). After a terminal operation, the stream is closed and cannot be reused.
The principal terminal operations:
| Operation | Effect |
|---|---|
forEach(consumer) | Side-effect for each element; order unspecified for parallel |
forEachOrdered(consumer) | Side-effect in encounter order |
toList() | (Since Java 16) collect to an immutable list |
toArray() | Collect to an Object[] |
toArray(generator) | Collect to a typed array |
collect(collector) | General-purpose collection (with Collectors) |
reduce(identity, fn) | Reduce to a single value with an identity |
reduce(fn) | Reduce to an Optional<T> |
count() | Count of elements |
min(comparator) / max(comparator) | Optional<T> of the min/max |
findFirst() / findAny() | First or any element as Optional<T> |
anyMatch(pred) / allMatch(pred) / noneMatch(pred) | Boolean tests |
sum() / average() / min() / max() | (On primitive streams) numeric reductions |
boolean anyExpired = items.stream().anyMatch(Item::isExpired);
boolean allValid = items.stream().allMatch(Item::isValid);
int total = orders.stream().mapToInt(Order::amount).sum();
Optional<Item> mostExpensive = items.stream().max(Comparator.comparingDouble(Item::price));
long count = events.stream().filter(Event::isError).count();
The Collectors class
java.util.stream.Collectors provides factories for collectors — composable terminal accumulators. The principal collectors:
| Collector | Result |
|---|---|
toList() | List<T> (mutable; older API) |
toUnmodifiableList() | Immutable List<T> |
toSet() / toUnmodifiableSet() | Set |
toMap(keyFn, valueFn) | Map<K, V> |
toMap(keyFn, valueFn, mergeFn) | Map with collision handling |
joining() / joining(sep) / joining(sep, prefix, suffix) | String concatenation |
groupingBy(classifier) | Map<K, List<T>> |
groupingBy(classifier, downstream) | Map<K, R> with downstream collector |
partitioningBy(pred) | Map<Boolean, List<T>> |
counting() | Long count (typically as a downstream collector) |
summingInt(fn) / summingLong(fn) / summingDouble(fn) | Sum |
averagingInt(fn) / etc. | Average |
mapping(fn, downstream) | Map then collect downstream |
reducing(identity, fn) | Generalised reduction |
import static java.util.stream.Collectors.*;
Map<String, List<Order>> byCustomer = orders.stream()
.collect(groupingBy(Order::customer));
Map<String, Long> countByCategory = items.stream()
.collect(groupingBy(Item::category, counting()));
Map<String, Double> totalByCategory = orders.stream()
.collect(groupingBy(Order::category, summingDouble(Order::amount)));
String csv = items.stream()
.map(Item::name)
.collect(joining(", ", "[", "]")); // "[a, b, c]"
Map<Boolean, List<Order>> grouped = orders.stream()
.collect(partitioningBy(o -> o.amount() > 1000));
The collectors compose: groupingBy(classifier, downstream) admits arbitrary downstream collectors, producing aggregate-of-aggregate results.
toList() (Java 16+)
Java 16 added a direct Stream.toList() terminal operation that returns an immutable list:
List<Integer> result = numbers.stream()
.filter(n -> n > 0)
.toList();
The form is shorter and clearer than .collect(Collectors.toList()); the conventional contemporary choice when an immutable list is acceptable.
Deferred (lazy) execution
Stream pipelines are lazy: intermediate operations are not executed until a terminal operation triggers consumption.
Stream<Integer> pipeline = numbers.stream()
.filter(n -> { System.out.println("filter " + n); return n > 0; })
.map(n -> { System.out.println("map " + n); return n * 2; });
// No output yet — the pipeline has been built but not executed.
List<Integer> result = pipeline.toList();
// Now the filters and maps run, in interleaved order:
// filter 1, map 1, filter 2, map 2, ...
The interleaving is part of the stream model: each element flows through the entire pipeline before the next is processed. The mechanism admits short-circuiting:
Optional<Integer> first = numbers.stream()
.filter(n -> isExpensive(n))
.findFirst();
// Stops as soon as the first match is found.
findFirst, anyMatch, limit, takeWhile are short-circuiting; they may consume only a prefix of the source.
Single-use
A stream may be consumed at most once. Calling a terminal operation twice on the same stream throws IllegalStateException:
Stream<Integer> s = numbers.stream();
long count = s.count();
long again = s.count(); // throws IllegalStateException
The conventional pattern for multi-use is to materialise (toList()) and stream from the resulting collection, or to construct a new stream from the source.
Optional<T> results
Several stream operations return Optional<T> — the type representing a possibly-absent value:
Optional<Item> first = items.stream().findFirst();
Optional<Item> max = items.stream().max(Comparator.comparingDouble(Item::price));
if (first.isPresent()) {
Item i = first.get();
process(i);
}
// Idiomatic:
first.ifPresent(this::process);
String name = first.map(Item::name).orElse("unknown");
int total = items.stream()
.map(Item::price)
.reduce(0.0, Double::sum); // for non-empty reduce, use the identity form
The Optional<T> form is the conventional Java idiom for “may or may not have a value”; the full treatment is in References and primitives.
Parallel streams
Stream.parallel() (or Collection.parallelStream()) produces a parallel stream:
double total = orders.parallelStream()
.mapToDouble(Order::amount)
.sum();
The implementation distributes the work across threads from the common ForkJoinPool. The principal trade-offs:
- When parallel helps: large data, CPU-bound per-element work, no contention.
- When parallel hurts: small data (overhead exceeds benefit), I/O-bound work (the ForkJoinPool is for CPU-bound tasks), order-sensitive operations (parallel may rearrange).
- When parallel is incorrect: collectors that are not concurrent-safe, mutating shared state.
The conventional advice: do not parallelise by default; measure first. Most application workloads do not benefit.
Stream and IntStream differences
The primitive specialisations have a different surface:
Stream<Integer> | IntStream |
|---|---|
map(Function<T, R>) | map(IntUnaryOperator) |
mapToInt(ToIntFunction<T>) | mapToObj(IntFunction<R>) |
reduce(BinaryOperator<T>) | sum(), min(), max(), average() |
| Boxes per element | No boxing |
Conversion between the two:
Stream<Integer> boxed = IntStream.range(0, 100).boxed();
IntStream primitive = boxed.mapToInt(Integer::intValue);
The conventional choice is IntStream (or LongStream, DoubleStream) for numeric operations; Stream<T> for object operations.
Common patterns
Filter and collect
List<Order> active = orders.stream()
.filter(Order::isActive)
.toList();
Map to a different type
List<String> names = users.stream()
.map(User::name)
.toList();
Counting
long count = users.stream()
.filter(User::isActive)
.count();
Summing
double total = orders.stream()
.mapToDouble(Order::amount)
.sum();
Grouping
Map<String, List<Order>> byCustomer = orders.stream()
.collect(Collectors.groupingBy(Order::customer));
Top N
List<Order> top10 = orders.stream()
.sorted(Comparator.comparingDouble(Order::amount).reversed())
.limit(10)
.toList();
Joining
String csv = items.stream()
.map(Item::name)
.collect(Collectors.joining(", "));
Building a map
Map<Integer, User> byId = users.stream()
.collect(Collectors.toMap(User::id, Function.identity()));
Iterating with index
IntStream.range(0, items.size())
.forEach(i -> process(i, items.get(i)));
Combining two collections
// Java has no built-in zip; use IntStream.range:
List<Pair<A, B>> zipped = IntStream.range(0, Math.min(a.size(), b.size()))
.mapToObj(i -> new Pair<>(a.get(i), b.get(i)))
.toList();
Pitfalls
Multiple traversal
A stream cannot be re-used:
Stream<Integer> s = numbers.stream();
long count = s.count(); // OK
long again = s.count(); // throws IllegalStateException
The defence is to materialise (toList()) and re-stream from the list.
Side effects in intermediate operations
Stream operations should be pure functions of their input. Side effects in map, filter, peek are admissible but discouraged:
// Bad: side-effecting filter:
List<Integer> result = numbers.stream()
.filter(n -> { count++; return n > 0; }) // not safe in parallel
.toList();
// Better: collect, then count separately, or use a counting collector.
For unavoidable side effects (logging, debugging), peek is the conventional intermediate operation; for terminal side effects, forEach.
Large intermediate states
The Collectors.toList() and similar accumulators may require substantial memory for large streams. For aggregate-only results (sums, counts), use the corresponding terminal operations (sum(), count()); for large collections, consider streaming output instead of full materialisation.
Files.lines not closed
Files.lines(path) returns a stream that holds the file open. Use try-with-resources:
try (Stream<String> lines = Files.lines(path)) {
long count = lines.count();
}
Without the try-with-resources, the file handle leaks until GC.
Differences from LINQ
For readers from C#:
| LINQ (C#) | Streams (Java) |
|---|---|
IEnumerable<T> reusable | Stream<T> single-use |
Where, Select, OrderBy | filter, map, sorted |
Aggregate | reduce |
ToList | toList() (since Java 16) |
Query syntax (from x in xs ...) | No equivalent |
IQueryable<T> (provider-based) | No equivalent (third-party libraries exist) |
| Materialising operators terminate | Same |
The Java streams API is more limited than LINQ in scope — no query syntax, no expression trees, no LINQ-to-SQL — but covers the principal collection-processing use cases. For richer query mechanisms, third-party libraries (jOOλ, jOOQ for SQL) provide more elaborate facilities.