Topics Core Java Streams from java8
Back Sign up to track progress

Streams in Java 8 — Technical Documentation

Principal Java Architect | Enterprise Reference | Java 8–21


Introduction

The Stream API, introduced in Java 8 (java.util.stream), provides a declarative, functional-style mechanism for processing sequences of elements. A Stream<T> is not a data structure — it is a pipeline of computations over a source (collection, array, I/O channel, or generator function).

Three defining characteristics:

  • Non-storage: A stream holds no data. It pulls elements from its source on demand.
  • Functional: Operations produce new streams or terminal results; they never mutate the source.
  • Lazy: Intermediate operations are not executed until a terminal operation is invoked.
List<String> names = List.of("Alice", "Bob", "Charlie", "Dave");

List<String> result = names.stream()               // source
        .filter(n -> n.length() > 3)               // intermediate
        .map(String::toUpperCase)                   // intermediate
        .sorted()                                   // intermediate
        .collect(Collectors.toList());              // terminal
// result: [ALICE, CHARLIE, DAVE]

Nothing in the pipeline executes until collect() is called.


Why This Concept Exists

The Problem with Imperative Collection Processing

Before Java 8, transforming a list required explicit loops, mutable accumulator variables, and nested conditionals:

List<String> result = new ArrayList<>();
for (String name : names) {
    if (name.length() > 3) {
        result.add(name.toUpperCase());
    }
}
Collections.sort(result);

This style has four problems:

  1. Noise: The iteration mechanism drowns the business intent.
  2. Mutation: Accumulators are error-prone in concurrent code.
  3. Non-composable: Logic cannot be passed around or reused without abstractions.
  4. Serial-only: Parallelizing requires manual thread management.

What Streams Solve

Streams shift the focus from how to iterate to what to compute. The pipeline is a description of the transformation; the JVM decides how to execute it (serially or in parallel). Switching to parallel execution requires changing one method call:

names.parallelStream()
     .filter(n -> n.length() > 3)
     .map(String::toUpperCase)
     .collect(Collectors.toList());

Internal Working

Pipeline Architecture

A stream pipeline consists of three parts:

Source → [Intermediate Operations]* → Terminal Operation

Internally, the Stream API is built on spliterators (Spliterator<T>), which are iterators that support splitting for parallel execution. Every stream source provides a Spliterator.

Lazy Evaluation and Fusion

Intermediate operations (filter, map, sorted, etc.) do not process data when called. They build a linked list of StatelessOp or StatefulOp pipeline stages. When the terminal operation fires, the JVM traverses the chain and, where possible, fuses multiple operations into a single pass over the source.

stream.filter(predicate).map(mapper).forEach(consumer);

Internally, this becomes a single loop:

for each element:
    if predicate(element):
        consumer(mapper(element))

No intermediate collections are created for filter + map — this is loop fusion.

Stateless vs. Stateful Operations

TypeExamplesBehaviour
Statelessfilter, map, peek, flatMapProcess each element independently; fully fuseable
Statefulsorted, distinct, limit, skipMust buffer or coordinate across elements; break fusion

sorted() must see all elements before emitting the first result. This is why inserting sorted() into a pipeline can eliminate the lazy-evaluation benefit for everything after it.

Short-Circuiting

Some operations allow the pipeline to stop early:

  • Terminal: findFirst(), findAny(), anyMatch(), allMatch(), noneMatch()
  • Intermediate: limit(n)

With a short-circuiting terminal, the source is consumed only until the condition is met — the rest of the source is never visited.

Optional<String> first = names.stream()
        .filter(n -> n.startsWith("C"))
        .findFirst(); // stops after first match

Parallel Streams Internals

parallelStream() uses the Fork/Join framework (specifically ForkJoinPool.commonPool()). The Spliterator recursively splits the data source into sub-tasks. Each sub-task processes its chunk and results are combined with the collector's combiner function.

Source Spliterator
    ├── Sub-spliterator 1 → worker thread 1 → partial result
    ├── Sub-spliterator 2 → worker thread 2 → partial result
    └── Sub-spliterator 3 → worker thread 3 → partial result
                                        ↓
                               combiner merges results

The default pool size equals Runtime.getRuntime().availableProcessors() - 1.


Core Concepts

Creating Streams

// From collection
list.stream()
list.parallelStream()

// From array
Arrays.stream(array)
Arrays.stream(array, startInclusive, endExclusive)

// From values
Stream.of("a", "b", "c")
Stream.ofNullable(maybeNull)          // Java 9+

// Infinite streams
Stream.iterate(0, n -> n + 1)         // 0, 1, 2, 3...
Stream.iterate(0, n -> n < 100, n -> n + 1) // Java 9+: with predicate
Stream.generate(Math::random)

// From range (primitives)
IntStream.range(0, 10)                // 0..9
IntStream.rangeClosed(1, 10)          // 1..10
LongStream.range(0L, 1_000_000L)

Intermediate Operations

// Filtering
.filter(Predicate)                    // keep elements matching predicate
.distinct()                           // remove duplicates (uses equals/hashCode)
.limit(n)                             // keep first n elements
.skip(n)                              // discard first n elements

// Transforming
.map(Function)                        // one-to-one transformation
.flatMap(Function<T, Stream<R>>)      // one-to-many, flattens result
.mapToInt / mapToLong / mapToDouble   // to primitive streams

// Ordering
.sorted()                             // natural order
.sorted(Comparator)                   // custom order

// Debugging
.peek(Consumer)                       // side-effect per element; for debugging only

Terminal Operations

// Reduction
.count()
.sum() / .average() / .min() / .max() // on primitive streams
.reduce(identity, BinaryOperator)
.reduce(BinaryOperator)               // returns Optional

// Collection
.collect(Collector)
.toList()                             // Java 16+: unmodifiable list

// Finding
.findFirst()                          // Optional<T>, short-circuits
.findAny()                            // Optional<T>, better for parallel

// Matching (all short-circuit)
.anyMatch(Predicate)
.allMatch(Predicate)
.noneMatch(Predicate)

// Iteration
.forEach(Consumer)
.forEachOrdered(Consumer)             // respects encounter order

Collectors

Collectors is a factory class with pre-built collector implementations:

// Basic
Collectors.toList()
Collectors.toSet()
Collectors.toUnmodifiableList()       // Java 10+
Collectors.toMap(keyFn, valueFn)
Collectors.toMap(keyFn, valueFn, mergeFunction) // handle key collisions

// Grouping and Partitioning
Collectors.groupingBy(Function)
Collectors.groupingBy(Function, Collectors.counting())
Collectors.partitioningBy(Predicate)  // splits into true/false map

// Joining
Collectors.joining()
Collectors.joining(", ")
Collectors.joining(", ", "[", "]")

// Statistics
Collectors.counting()
Collectors.summingInt(ToIntFunction)
Collectors.averagingDouble(ToDoubleFunction)
Collectors.summarizingInt(ToIntFunction) // min, max, sum, avg, count

// Downstream collectors
Collectors.groupingBy(Function, Collectors.toList())
Collectors.groupingBy(Function, Collectors.mapping(Function, Collectors.toSet()))

flatMap — The Most Misunderstood Operation

map produces Stream<Stream<T>> if the mapping function returns a Stream. flatMap merges the inner streams into one:

List<List<String>> nested = List.of(
    List.of("a", "b"),
    List.of("c", "d")
);

// map gives Stream<Stream<String>>
nested.stream().map(Collection::stream);

// flatMap gives Stream<String>: a, b, c, d
nested.stream().flatMap(Collection::stream).collect(Collectors.toList());

Real-world use: splitting sentences into words, flattening order items from orders.

Optional — Stream's Companion

Many stream terminal operations return Optional<T> instead of T or null:

Optional<String> first = stream.findFirst();
first.ifPresent(System.out::println);
String value = first.orElse("default");
String value2 = first.orElseGet(() -> computeDefault());
String value3 = first.orElseThrow(() -> new NoSuchElementException());

// Optional as a stream (Java 9+)
first.stream().forEach(...);

Never call optional.get() without isPresent() — it throws NoSuchElementException and defeats the purpose.

Primitive Streams

Boxing/unboxing is expensive in tight loops. Use primitive streams to avoid it:

IntStream    // int operations
LongStream   // long operations
DoubleStream // double operations

int[] arr = {1, 2, 3, 4, 5};
int sum = Arrays.stream(arr).sum();          // no boxing
OptionalDouble avg = Arrays.stream(arr).average();

// Box when needed
IntStream.range(1, 6).boxed()               // Stream<Integer>
IntStream.range(1, 6).mapToObj(i -> "item" + i) // Stream<String>

Real World Usage

Filtering and Transforming Domain Objects

List<Order> orders = orderRepository.findAll();

Map<String, Double> revenueByCustomer = orders.stream()
        .filter(o -> o.getStatus() == OrderStatus.COMPLETED)
        .collect(Collectors.groupingBy(
                Order::getCustomerId,
                Collectors.summingDouble(Order::getAmount)
        ));

Grouping for Reports

Map<Department, List<Employee>> byDept = employees.stream()
        .collect(Collectors.groupingBy(Employee::getDepartment));

Map<Department, Long> headcount = employees.stream()
        .collect(Collectors.groupingBy(Employee::getDepartment,
                                       Collectors.counting()));

Building CSV/JSON Output

String csv = employees.stream()
        .map(e -> e.getId() + "," + e.getName() + "," + e.getSalary())
        .collect(Collectors.joining("\n"));

Flattening Nested Structures

List<String> allTags = articles.stream()
        .flatMap(article -> article.getTags().stream())
        .distinct()
        .sorted()
        .collect(Collectors.toList());

Computing Statistics

IntSummaryStatistics stats = transactions.stream()
        .mapToInt(Transaction::getAmount)
        .summaryStatistics();

System.out.println("Min: " + stats.getMin());
System.out.println("Max: " + stats.getMax());
System.out.println("Avg: " + stats.getAverage());
System.out.println("Sum: " + stats.getSum());

Parallel Processing — When It Helps

// Good candidate: large dataset, CPU-bound, stateless, no shared state
long count = hugeList.parallelStream()
        .filter(this::expensiveValidation)
        .count();

// Bad candidate: small list, I/O-bound, ordered output required

Interview Questions

Q1: What is a Stream in Java 8? How is it different from a Collection?

A Collection stores data in memory and allows random access. A Stream is a view over data — it describes a sequence of computations without storing elements. Collections are reusable; a stream can only be consumed once. Collections are eagerly populated; streams are lazy. A Collection knows its size; a Stream may be infinite.


Q2: What is lazy evaluation and why does it matter?

Intermediate operations (filter, map, etc.) don't execute when called — they build a pipeline description. Execution happens only when a terminal operation is invoked. This matters for two reasons: (1) Short-circuiting terminals (findFirst, anyMatch) can stop processing after the first match, never visiting the rest of the source. (2) Loop fusion eliminates intermediate collections — filter().map() is a single pass, not two passes with a temporary list between them.


Q3: What is the difference between map and flatMap?

map applies a 1-to-1 function — each input element produces exactly one output element. flatMap applies a 1-to-many function that returns a Stream, then merges (flattens) all resulting streams into one. Use flatMap when each element can expand to zero or more results (e.g., splitting a sentence into words, extracting items from orders).


Q4: When should you use parallelStream()?

Use parallel streams when: the data set is large (hundreds of thousands of elements or more), the operation is CPU-bound and stateless, the order of results does not matter, and there is no shared mutable state. Avoid it for: I/O-bound operations, small collections (thread coordination overhead dominates), ordered pipelines (forEachOrdered negates parallelism), and operations holding locks or synchronizing on shared objects.


Q5: What is the difference between findFirst() and findAny()?

findFirst() always returns the first element in encounter order — it respects the source ordering even in a parallel stream (forcing synchronization). findAny() returns any element, making it more efficient in parallel streams since each thread can return its first match without coordination. In sequential streams, both typically return the same element.


Q6: Can a stream be reused? What happens if you try?

No. A stream is consumed exactly once. Attempting to use it after a terminal operation throws IllegalStateException: stream has already been operated upon or closed. Create a new stream from the source each time.

Stream<String> s = list.stream();
s.forEach(System.out::println);
s.count(); // throws IllegalStateException

Q7: What is the difference between reduce and collect?

reduce is designed for immutable reduction — combining elements into a single value using a pure BinaryOperator (e.g., summing integers, finding max). It is safe for parallel execution because intermediate results are not mutated. collect is designed for mutable reduction — accumulating elements into a mutable container like a List, Map, or StringBuilder. Collectors have a supplier (create container), accumulator (add element), and combiner (merge partial results for parallel) — this three-function design makes mutable collection thread-safe in parallel streams.


Q8: What is a Spliterator?

A Spliterator is the backbone of the Stream API. It combines the roles of Iterator (traverse elements one by one) and Splittable (divide the source into two halves for parallel processing). Every stream source provides a Spliterator. Custom data structures can implement Spliterator to make themselves streamable. Characteristics (SIZED, ORDERED, DISTINCT, SORTED) on the Spliterator allow the stream engine to make optimizations.


Q9: How does Collectors.groupingBy work internally?

groupingBy(classifier) creates a HashMap<K, List<T>>. For each element, it calls classifier.apply(element) to get a key, then appends the element to the list for that key. When combined with a downstream collector (groupingBy(fn, Collectors.counting())), the downstream collector is applied to each group's elements instead of collecting them into a list. In parallel, each thread builds a partial map and the combiner merges maps by merging the values for matching keys.


Q10: What is the difference between forEach and forEachOrdered?

forEach processes elements in an unspecified order — in parallel streams, elements are processed as threads complete, not in source order. forEachOrdered guarantees processing in the stream's encounter order even in parallel, but this eliminates the parallelism benefit for the terminal step. Use forEachOrdered only when order of side effects matters and you understand the performance tradeoff.


Common Mistakes

1. Reusing a Consumed Stream

Stream<String> stream = list.stream();
long count = stream.count();
List<String> result = stream.collect(Collectors.toList()); // IllegalStateException

Create a new stream per operation.

2. Putting a Side Effect in filter or map

// WRONG — map should be pure
stream.map(item -> { db.save(item); return item; })

// CORRECT — use forEach or peek only for debugging
stream.filter(isValid).forEach(db::save);

3. Using peek in Production Logic

peek is designed for debugging. Its execution depends on whether the terminal operation actually demands elements. In short-circuited pipelines, peek may not fire for all elements.

4. Parallel Stream on Ordered, I/O-Bound, or Small Data

// WRONG — small list, overhead dominates
smallList.parallelStream().map(transform).collect(toList());

// WRONG — database call inside parallel stream uses shared connection pool
bigList.parallelStream().map(id -> db.findById(id)).collect(toList());

5. Ignoring Optional — Calling get() Directly

String first = stream.findFirst().get(); // throws if empty
String first = stream.findFirst().orElseThrow(); // explicit, safe (Java 10+)

6. Collecting to toList() then Modifying

Collectors.toList() does not guarantee mutability. Use Collectors.toCollection(ArrayList::new) when you need a mutable list. Java 16's Stream.toList() returns an explicitly unmodifiable list.

7. Stateful Lambda in Parallel Stream

List<String> shared = new ArrayList<>();
stream.parallelStream().forEach(shared::add); // WRONG — ArrayList not thread-safe

// CORRECT
List<String> result = stream.parallelStream().collect(Collectors.toList());

8. Boxing Overhead in Numeric Pipelines

// WRONG — boxes every int to Integer
Stream<Integer> s = list.stream().map(Integer::parseInt);

// CORRECT — stays primitive
IntStream s = list.stream().mapToInt(Integer::parseInt);
int sum = s.sum(); // no boxing at all

Performance & Best Practices

Operation Order Matters

Place filter before map and sorted. Filtering reduces the number of elements that go through heavier operations:

// BAD — maps all elements, then filters
stream.map(expensiveTransform).filter(predicate).collect(...)

// GOOD — filters first, maps only matching elements
stream.filter(predicate).map(expensiveTransform).collect(...)

Avoid sorted() Unless Necessary

sorted() is O(n log n) and buffers the entire stream before continuing. It breaks loop fusion. If you need a sorted result, sort the source collection before streaming, or collect first and sort the list.

Use Primitive Streams for Numeric Work

IntStream, LongStream, and DoubleStream avoid boxing overhead. For summing 1 million integers, IntStream.sum() is significantly faster than Stream<Integer> with reduce(0, Integer::sum).

Parallel Stream Guidelines

  • Data size threshold: generally > 10,000 elements before parallelism pays off.
  • Splitting must be cheap: ArrayList splits in O(1); LinkedList splits in O(n) — terrible for parallel.
  • Operations must be independent and stateless.
  • Use a custom ForkJoinPool to avoid starving the common pool:
ForkJoinPool customPool = new ForkJoinPool(4);
List<Result> results = customPool.submit(() ->
        bigList.parallelStream().map(this::process).collect(Collectors.toList())
).get();

Prefer Method References Over Lambdas for Clarity

.map(String::toUpperCase)        // clearer than .map(s -> s.toUpperCase())
.filter(Objects::nonNull)        // clearer than .filter(s -> s != null)
.collect(Collectors.toList())

Collectors.toUnmodifiableList() for Safety

When the result will be exposed through an API, return an unmodifiable list to prevent callers from mutating internal state:

return stream.collect(Collectors.toUnmodifiableList()); // Java 10+
return stream.toList(); // Java 16+ — shorter, same guarantee

Custom Collectors for Reusable Aggregations

For complex aggregations used in multiple places, implement Collector<T, A, R> once and reuse it:

Collector<Transaction, ?, TransactionSummary> summarizer =
        Collector.of(
            TransactionSummary::new,
            TransactionSummary::accept,
            TransactionSummary::combine,
            Collector.Characteristics.UNORDERED
        );

Java Version Changes

VersionChange
Java 8Stream API introduced: java.util.stream, Stream<T>, IntStream, LongStream, DoubleStream, Collectors, Optional<T>
Java 9Stream.iterate(seed, predicate, next) — bounded iterate; Stream.ofNullable(T) — null-safe single-element stream; Stream.takeWhile(Predicate) — take while true; Stream.dropWhile(Predicate) — drop while true; Optional.stream() — bridge to Stream API
Java 10Collectors.toUnmodifiableList(), toUnmodifiableSet(), toUnmodifiableMap()
Java 12Collectors.teeing(c1, c2, merger) — feeds elements into two collectors and merges both results
Java 16Stream.toList() — concise, returns unmodifiable list; Stream.mapMulti() — imperative alternative to flatMap for performance-sensitive cases
Java 17No Stream API changes; Stream heavily used with sealed classes and pattern matching
Java 21Sequenced collections (SequencedCollection) integrate with stream operations; Stream.gather() (preview) for general-purpose intermediate operations

Notable Java 9 Additions in Detail

// takeWhile — stops at first non-matching element (not the same as filter)
Stream.of(1, 2, 3, 4, 5)
      .takeWhile(n -> n < 4)
      .forEach(System.out::println); // 1, 2, 3

// dropWhile — skips until first non-matching, then takes rest
Stream.of(1, 2, 3, 4, 5)
      .dropWhile(n -> n < 3)
      .forEach(System.out::println); // 3, 4, 5

// ofNullable — avoids null check before streaming
Stream.ofNullable(maybeNull).forEach(System.out::println); // no NPE

// Optional.stream() — composable with flatMap
List<String> values = optionals.stream()
        .flatMap(Optional::stream) // flattens present values only
        .collect(Collectors.toList());

Java 12: Collectors.teeing

// Compute min and max in a single pass
Map.Entry<Optional<Integer>, Optional<Integer>> minMax = stream
        .collect(Collectors.teeing(
            Collectors.minBy(Comparator.naturalOrder()),
            Collectors.maxBy(Comparator.naturalOrder()),
            Map::entry
        ));

Quick Revision Notes

  • A Stream is a pipeline over a source, not a data structure. It stores no elements.
  • Streams are lazy — intermediate operations execute only when a terminal operation is called.
  • Streams are single-use — calling a terminal operation closes the stream; reuse throws IllegalStateException.
  • Stateless operations (filter, map, flatMap) fuse into a single pass. Stateful operations (sorted, distinct) buffer and break fusion.
  • flatMap flattens a Stream<Stream<T>> into Stream<T> — use when each element maps to zero or more results.
  • findFirst() respects order (slower in parallel); findAny() does not (faster in parallel).
  • reduce = immutable reduction; collect = mutable reduction into a container.
  • Use primitive streams (IntStream, LongStream, DoubleStream) for numeric work to avoid boxing.
  • parallelStream() uses ForkJoinPool.commonPool(). Good for: large + CPU-bound + stateless. Bad for: small data, I/O, shared mutable state.
  • Put filter before map and sorted to reduce work on downstream operations.
  • sorted() is O(n log n), buffers everything — avoid unless necessary.
  • Collectors.groupingByHashMap<K, List<T>>; add a downstream collector for aggregation.
  • Collectors.joining(delimiter, prefix, suffix) — best way to build delimited strings.
  • Collectors.teeing (Java 12) — compute two aggregations in one pass.
  • Stream.toList() (Java 16) — concise, returns unmodifiable list.
  • takeWhile / dropWhile (Java 9) — positional filtering, not the same as filter.
  • Never use peek for production logic — its invocation depends on terminal demand.
  • Never put shared mutable state in lambdas used with parallelStream.

Documentation covers Java 8 through Java 21. All code examples compile with javac --release 8 unless annotated with the minimum version.

Done reading this topic? Sign up free to track your progress.
Sign Up to Track