Polyglot
Languages Java streams
Java § streams

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:

OperationEffect
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:

OperationEffect
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:

CollectorResult
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 elementNo 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> reusableStream<T> single-use
Where, Select, OrderByfilter, map, sorted
Aggregatereduce
ToListtoList() (since Java 16)
Query syntax (from x in xs ...)No equivalent
IQueryable<T> (provider-based)No equivalent (third-party libraries exist)
Materialising operators terminateSame

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.