Java Streams API

The Streams API, introduced in Java 8, lets you process sequences of data — elements from a collection, an array, a file, or a range of numbers — using a declarative, functional style instead of manual loops. Rather than telling the computer how to iterate step by step, you describe what transformation and aggregation you want, and the stream pipeline figures out the iteration for you. Streams are the backbone of modern, readable Java code for filtering, transforming, and summarizing data, and they pair naturally with lambda expressions and method references.

Overview / How Streams Work

A Stream is not a data structure. It doesn’t store elements the way a List or an array does. Instead, a stream is a pipeline that pulls elements from a source (a collection, an array, a generator, a file) and pushes them through a chain of computational steps. There are three parts to every stream pipeline:

  • Source — where the elements come from, e.g. list.stream(), Arrays.stream(array), Stream.of(...), or IntStream.range(...).
  • Intermediate operations — zero or more operations like filter, map, or sorted that each return a new stream. These are lazy: calling them does not process any elements yet, it just adds a stage to the pipeline description.
  • Terminal operation — exactly one operation like collect, forEach, reduce, or count that actually triggers execution and produces a result (or a side effect). Until a terminal operation is called, nothing happens at all — no filtering, no mapping, nothing.

Internally, when you call the terminal operation, the JVM builds a chain of Sink objects (one per intermediate step) and pushes each source element through that chain one at a time, top to bottom. This means a single element can go through filter, then map, then be consumed by the terminal operation, before the next element even starts — streams generally process element-by-element through the whole pipeline rather than doing one full pass per stage. This is what makes operations like findFirst() or limit(n) able to short-circuit: they can stop pulling from the source as soon as they have enough elements, without touching the rest.

Streams come in a generic flavor, Stream<T>, and three primitive specializations — IntStream, LongStream, and DoubleStream — which avoid the overhead of boxing every int into an Integer object. Once a terminal operation has run, the stream is considered consumed and cannot be reused; attempting to reuse it throws an IllegalStateException (more on this in Common Mistakes).

Syntax

The general shape of a stream pipeline is:

source.stream()
    .intermediateOperation1(...)
    .intermediateOperation2(...)
    ...
    .terminalOperation(...);
Part Examples Notes
Source collection.stream(), Arrays.stream(arr), Stream.of(a, b, c), IntStream.range(0, 10) Produces the initial stream
Intermediate filter, map, flatMap, sorted, distinct, limit, skip, peek Lazy — returns a new Stream, can be chained
Terminal collect, forEach, reduce, count, sum (primitive streams), anyMatch/allMatch/noneMatch, findFirst/findAny, toArray Eager — consumes the stream and produces a result or side effect

Every pipeline needs exactly one source and exactly one terminal operation; the number of intermediate operations can be zero or more.

Examples

Example 1: Filtering and Transforming Strings

import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

public class Main {
    public static void main(String[] args) {
        List<String> names = Arrays.asList("Alice", "Bob", "Charlie", "Dave", "Eve");

        List<String> result = names.stream()
                .filter(name -> name.length() > 3)
                .map(String::toUpperCase)
                .collect(Collectors.toList());

        System.out.println(result);
    }
}

Output:

[ALICE, CHARLIE, DAVE]

The pipeline pulls each name from the list, keeps only names longer than three characters (filter), converts the survivors to uppercase (map with a method reference), and finally gathers everything into a new List with collect. Bob and Eve are dropped because their length is not greater than 3. Note that names itself is never modified — streams always produce new results, never mutate their source.

Example 2: Numeric Aggregation with IntStream

import java.util.stream.IntStream;

public class Main {
    public static void main(String[] args) {
        int[] scores = {85, 92, 78, 68};

        int sum = IntStream.of(scores).sum();
        double average = IntStream.of(scores).average().orElse(0);
        int max = IntStream.of(scores).max().orElse(0);
        long countAbove80 = IntStream.of(scores).filter(s -> s >= 80).count();

        System.out.println("Sum: " + sum);
        System.out.println("Average: " + average);
        System.out.println("Max: " + max);
        System.out.println("Count >= 80: " + countAbove80);
    }
}

Output:

Sum: 323
Average: 80.75
Max: 92
Count >= 80: 2

Each call to IntStream.of(scores) creates a fresh stream over the array, because each one is consumed by its own terminal operation (sum, average, max, count). average() returns an OptionalDouble because averaging an empty stream is undefined, so orElse(0) supplies a fallback. Using IntStream instead of Stream<Integer> avoids boxing every element, which matters for performance on large numeric data.

Example 3: Grouping and Sorting Objects

import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.stream.Collectors;

public class Main {
    static class Employee {
        String name;
        String department;
        double salary;

        Employee(String name, String department, double salary) {
            this.name = name;
            this.department = department;
            this.salary = salary;
        }
    }

    public static void main(String[] args) {
        List<Employee> employees = Arrays.asList(
                new Employee("Alice", "Engineering", 95000),
                new Employee("Bob", "Sales", 62000),
                new Employee("Charlie", "Engineering", 105000),
                new Employee("Dave", "Sales", 71000),
                new Employee("Eve", "Marketing", 58000)
        );

        Map<String, List<String>> byDept = employees.stream()
                .collect(Collectors.groupingBy(
                        e -> e.department,
                        TreeMap::new,
                        Collectors.mapping(e -> e.name, Collectors.toList())
                ));

        System.out.println(byDept);

        double engineeringPayroll = employees.stream()
                .filter(e -> e.department.equals("Engineering"))
                .mapToDouble(e -> e.salary)
                .sum();

        System.out.println("Engineering payroll: " + engineeringPayroll);

        List<String> topEarners = employees.stream()
                .sorted(Comparator.comparingDouble((Employee e) -> e.salary).reversed())
                .limit(3)
                .map(e -> e.name)
                .collect(Collectors.toList());

        System.out.println("Top earners: " + topEarners);
    }
}

Output:

{Engineering=[Alice, Charlie], Marketing=[Eve], Sales=[Bob, Dave]}
Engineering payroll: 200000.0
Top earners: [Charlie, Alice, Dave]

This example combines several stream idioms used constantly in real code. Collectors.groupingBy with a downstream Collectors.mapping collector groups employees by department and extracts just their names into lists; passing TreeMap::new as the map factory keeps the departments in alphabetical order instead of the unpredictable order of a default HashMap. mapToDouble converts the filtered stream into a DoubleStream so sum() is available. Comparator.comparingDouble(...).reversed() sorts by salary from highest to lowest, and limit(3) keeps only the top three earners.

Under the Hood: How a Pipeline Actually Runs

When you write list.stream().filter(p1).map(f).collect(c), here is roughly what happens:

  • Calling .stream() creates a ReferencePipeline head that just wraps the source and its Spliterator (an object that knows how to traverse and, if needed, split the source).
  • Each intermediate call (filter, map) does not touch any elements. It creates a new pipeline stage object that remembers the operation and points back to the previous stage. No iteration happens yet — this is why intermediate operations are described as lazy.
  • Calling the terminal operation (collect) walks the chain of stages backward to build a chain of Sink objects — essentially a chain of callback functions, one per stage, each wired to call the next.
  • The terminal operation then asks the source’s Spliterator to iterate, and for each element, it is pushed through the entire sink chain: filtered, mapped, and accumulated, before the next element is even fetched. This is why peek calls interleave across elements rather than running in separate full passes.
  • Operations like findFirst, anyMatch, and limit are short-circuiting: the sink chain can signal “stop” partway through, and the source stops producing elements immediately, saving work on large or infinite streams (like those from Stream.iterate).
  • Once the terminal operation finishes, the pipeline’s source stage is marked consumed. Any further operation called on that same stream instance throws IllegalStateException.
  • For a parallel stream (created with parallelStream() or .parallel()), the Spliterator recursively splits the source into chunks, and the JVM’s common ForkJoinPool processes those chunks concurrently, combining partial results with a combiner function (used by reduce and collect). Parallelism only pays off for large data sets with enough independent work to offset thread-coordination overhead.

Common Mistakes

Mistake 1: Reusing a Stream After a Terminal Operation

A Stream can only be consumed once. Trying to call another operation on it after a terminal operation has already run throws IllegalStateException, because the pipeline’s source stage has been marked as consumed.

import java.util.Arrays;
import java.util.List;
import java.util.stream.Stream;

public class Main {
    public static void main(String[] args) {
        List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
        Stream<Integer> stream = numbers.stream();

        long count = stream.count();
        System.out.println("Count: " + count);

        double average = stream.mapToInt(Integer::intValue).average().orElse(0);
        System.out.println("Average: " + average);
    }
}

Output:

Count: 5
Exception in thread "main" java.lang.IllegalStateException: stream has already been operated upon or closed

The fix is to build a fresh stream for every terminal operation you need — call numbers.stream() again instead of reusing the same reference, or restructure the logic so you only need a single pass (for example, using IntSummaryStatistics to get count, sum, min, max, and average from one pass).

Mistake 2: Forgetting the Terminal Operation

Because intermediate operations are lazy, writing a stream expression without a terminal operation compiles fine but silently does nothing at all — not even the filter predicate gets evaluated.

import java.util.Arrays;
import java.util.List;

public class Main {
    public static void main(String[] args) {
        List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6);

        numbers.stream().filter(n -> n % 2 == 0);

        System.out.println(numbers);
    }
}

Output:

[1, 2, 3, 4, 5, 6]

The list prints completely unchanged. New Java developers often expect filter to modify numbers in place, but streams are read-only views over their source and never mutate it — you must collect the result into a new structure. The corrected version adds a terminal collect operation:

import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

public class Main {
    public static void main(String[] args) {
        List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6);

        List<Integer> evens = numbers.stream()
                .filter(n -> n % 2 == 0)
                .collect(Collectors.toList());

        System.out.println(evens);
    }
}

Output:

[2, 4, 6]

Best Practices

  • Prefer method references (String::toUpperCase) over equivalent lambdas when they are just as clear — they are shorter and just as fast.
  • Keep lambdas passed to filter/map/etc. stateless — don’t mutate variables outside the lambda from inside it; this breaks with parallel streams and makes code hard to reason about.
  • Use the primitive stream types (IntStream, LongStream, DoubleStream) for numeric-heavy work to avoid boxing overhead.
  • Don’t reach for parallelStream() by default — it has real overhead (splitting, thread coordination) and is only worth it for large collections with CPU-heavy per-element work; measure before switching.
  • Assign a stream to a variable only if you intend to consume it exactly once; otherwise build the pipeline inline.
  • When a stream wraps an external resource (e.g. Files.lines(path)), use it inside a try-with-resources block so the underlying file handle is closed.
  • Use Collectors.toUnmodifiableList() (or .toList() on Java 16+) when the result should not be modified afterward.
  • Favor a short, named collector chain over a single giant one-liner — readability matters more than compressing everything into one statement.

Practice Exercises

  • Exercise 1: Given List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8), use a stream pipeline to compute the sum of the squares of only the even numbers. Expected result: 120 (2² + 4² + 6² + 8² = 4 + 16 + 36 + 64).
  • Exercise 2: Given List<String> words = Arrays.asList("cat", "dog", "lion", "ant", "bear", "owl"), use Collectors.groupingBy to produce a Map<Integer, List<String>> that groups the words by their length.
  • Exercise 3: Given double[] temps = {72.5, 68.0, 75.3, 71.2, 69.8}, use a DoubleStream and DoubleSummaryStatistics (via the summaryStatistics() terminal operation) to print the minimum, maximum, and average temperature in a single pass.

Summary

  • A Stream is a lazy pipeline over a data source, not a data structure — it stores nothing and can only be consumed once.
  • A pipeline has a source, zero or more lazy intermediate operations, and exactly one eager terminal operation that triggers execution.
  • Elements flow through the whole pipeline one at a time via a chain of internal Sink callbacks, which is what enables short-circuiting operations like limit and findFirst.
  • Reusing a stream after a terminal operation throws IllegalStateException; build a new stream for each pass instead.
  • Primitive streams (IntStream, LongStream, DoubleStream) avoid boxing overhead for numeric work.
  • Streams never mutate their source — always collect results into a new structure with collect.
  • Parallel streams use the common ForkJoinPool and only help for large workloads with real per-element cost.