Streams

A stream is a pipeline that pulls elements from a source through a chain of operations to a single terminal result. Nothing runs until the terminal operation asks for elements, and each element travels through the whole chain before the next one starts. This page explains that model, builds pipelines from different sources, shows laziness and its consequences, warns about parallel streams, and measures streams against loops.

Contents

The stream model

The Stream API, added in Java 8 in java.util.stream, describes a computation as a pipeline with three parts:

  • A source: a collection's stream(), an array, IntStream.range, Stream.of, a file's lines, or a generator.
  • Zero or more intermediate operations such as filter, map, sorted and distinct. Each returns a new stream and does no work yet.
  • One terminal operation such as toList, count, sum, forEach or collect, which runs the pipeline and produces a result.

Execution is lazy and element by element: the terminal operation pulls one element from the source, passes it through every stage, and only then pulls the next. Short-circuiting operations such as findFirst, anyMatch and limit can stop early, so a pipeline may examine only part of its source. Stateful operations such as sorted are the exception: they must see every element before passing any on. A stream can be consumed only once.

Stream features by version
FeatureSinceUse
Stream, IntStream, LongStream, DoubleStream, CollectorsJava 8The core API; primitive streams avoid boxing
takeWhile, dropWhile, iterate(seed, hasNext, next), ofNullableJava 9Prefixes of ordered streams, bounded generation
Collectors.teeingJava 12Two results in one pass
Stream.toList(), mapMultiJava 16Short unmodifiable results, flexible one-to-many
Gatherers (Stream.gather)Java 24 (JEP 485; previewed in 22 and 23)Custom intermediate operations (not used here)

Every example was run on JDK 21. Grouping and flattening have their own pages: groupingBy and flatMap.

A pipeline from start to finish

A short list of orders, some paid and some not, is filtered, transformed and summed, the shape of most stream code in business applications.

Java
import java.util.List;

public class Pipeline {

    record Order(String customer, double amount, boolean paid) {
    }

    public static void main(String[] args) {
        List<Order> orders = List.of(
                new Order("ann", 120.0, true), new Order("bob", 35.5, false),
                new Order("cy", 410.0, true), new Order("ann", 60.0, true),
                new Order("dee", 15.0, true));

        // source -> intermediate operations -> one terminal operation
        List<String> bigPayers = orders.stream()           // source
                .filter(Order::paid)                      // keep paid orders
                .filter(o -> o.amount() >= 50)            // keep amounts of 50 or more
                .map(Order::customer)                     // Order -> String
                .distinct()                               // drop repeated names
                .sorted()                                 // alphabetical
                .toList();                                // terminal: run it, collect a List
        System.out.println("paid >= 50: " + bigPayers);

        double paidTotal = orders.stream()
                .filter(Order::paid)
                .mapToDouble(Order::amount)               // a DoubleStream, no boxing
                .sum();
        System.out.println("paid total: " + paidTotal);

        long unpaid = orders.stream().filter(o -> !o.paid()).count();
        System.out.println("unpaid:     " + unpaid);
    }
}

Output:

Output
paid >= 50: [ann, cy]
paid total: 605.0
unpaid:     1
  • Two filter stages keep paid orders of 50 or more, map turns each Order into its customer name, and toList() runs the pipeline.
  • mapToDouble switches to a DoubleStream, whose sum() adds primitive doubles without creating Double objects.
  • count() on the unpaid orders is another terminal operation; each total needed its own pipeline, because a stream cannot be reused.

Laziness and single use

The program prints inside the pipeline so that the order of work becomes visible, and then tries to reuse a stream.

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

public class Laziness {

    public static void main(String[] args) {
        List<String> names = List.of("ada", "bo", "cleo", "dmitri", "eve");

        // Nothing runs until the terminal operation
        Stream<String> pipeline = names.stream()
                .peek(n -> System.out.println("  looking at " + n))
                .filter(n -> n.length() > 3)
                .map(String::toUpperCase);
        System.out.println("pipeline built, nothing printed yet");

        // findFirst stops as soon as it has an answer: "dmitri" and "eve" are never looked at
        System.out.println("first long name: " + pipeline.findFirst().orElse("none"));

        // A stream can be consumed only once
        try {
            pipeline.count();
        } catch (IllegalStateException e) {
            System.out.println("reuse: " + e.getMessage());
        }
    }
}

Output:

Output
pipeline built, nothing printed yet
  looking at ada
  looking at bo
  looking at cleo
first long name: CLEO
reuse: stream has already been operated upon or closed
  • Building the pipeline printed nothing; the work started only when findFirst was called.
  • The stream looked at ada, bo and cleo and stopped as soon as it found a long name. The remaining names were never examined.
  • Calling a second terminal operation on the same stream threw IllegalStateException: "stream has already been operated upon or closed". Store the source or a Supplier<Stream>, not the stream.
  • Laziness means side effects in lambdas, such as logging or counting, happen only for the elements actually processed. Keep lambdas free of side effects where possible.

Sources, generators and reductions

Each line uses a different source or terminal operation, including the bounded generators and prefix operations added in Java 9.

Java
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import java.util.stream.Stream;

public class SourcesAndReduce {

    public static void main(String[] args) {
        // Different sources
        System.out.println("range:    " + IntStream.rangeClosed(1, 5).boxed().toList());
        String joined = Stream.of("x", "y", "z").collect(Collectors.joining("-"));
        System.out.println("of:       " + joined);
        System.out.println("array:    " + Arrays.stream(new int[] {4, 8, 15}).sum());
        System.out.println("iterate:  " + Stream.iterate(1, n -> n * 2).limit(8).toList());
        System.out.println("iterate with condition: "
                + Stream.iterate(1, n -> n < 100, n -> n * 3).toList());
        System.out.println("chars:    " + "hello".chars().filter(c -> c == 'l').count() + " l's");

        // takeWhile / dropWhile work on order, unlike filter
        List<Integer> temps = List.of(12, 14, 19, 23, 18, 25);
        System.out.println("takeWhile < 20: " + temps.stream().takeWhile(t -> t < 20).toList());
        System.out.println("filter    < 20: " + temps.stream().filter(t -> t < 20).toList());

        // reduce combines all elements into one
        int product = IntStream.rangeClosed(1, 5).reduce(1, (a, b) -> a * b);
        System.out.println("5! via reduce: " + product);

        // Summary statistics in one pass
        var stats = temps.stream().mapToInt(Integer::intValue).summaryStatistics();
        System.out.println("stats: " + stats);
    }
}

Output:

Output
range:    [1, 2, 3, 4, 5]
of:       x-y-z
array:    27
iterate:  [1, 2, 4, 8, 16, 32, 64, 128]
iterate with condition: [1, 3, 9, 27, 81]
chars:    2 l's
takeWhile < 20: [12, 14, 19]
filter    < 20: [12, 14, 19, 18]
5! via reduce: 120
stats: IntSummaryStatistics{count=6, sum=111, min=12, average=18.500000, max=25}
  • IntStream.rangeClosed, Stream.of, Arrays.stream and String.chars are common sources.
  • Stream.iterate(seed, next) is infinite and needs limit; the Java 9 form iterate(seed, hasNext, next) stops by itself, like a for loop.
  • takeWhile(x < 20) stops at the first element that fails, so it returned 12, 14 and 19 but not the later 18, which filter kept. takeWhile is about position, filter about every element.
  • reduce(1, (a, b) -> a * b) folds the elements into one value, here 5! = 120. The identity value must be neutral for the operation.
  • summaryStatistics() returns count, sum, minimum, average and maximum from one pass.

Parallel streams: correctness first

parallel() splits the source across the common ForkJoinPool. The program shows the classic mistake of sharing a mutable list, the correct alternative, and what happens to ordering.

Java
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.stream.IntStream;

public class ParallelCaution {

    public static void main(String[] args) {
        // Wrong: a shared ArrayList modified from many threads, tried 10 times
        StringBuilder results = new StringBuilder();
        for (int run = 0; run < 10; run++) {
            List<Integer> unsafe = new ArrayList<>();
            try {
                IntStream.range(0, 100_000).parallel().forEach(unsafe::add);
                results.append(unsafe.size()).append(' ');
            } catch (ArrayIndexOutOfBoundsException e) {
                results.append("AIOOBE ");
            }
        }
        System.out.println("shared ArrayList, 10 runs: " + results.toString().trim());

        // Right: let the stream build the result
        List<Integer> safe = IntStream.range(0, 100_000).parallel().boxed().toList();
        System.out.println("collected size:        " + safe.size());

        // forEach on a parallel stream does not keep order; forEachOrdered does
        List<Integer> seen = Collections.synchronizedList(new ArrayList<>());
        IntStream.range(0, 10).parallel().forEach(seen::add);
        String order = isSorted(seen) ? "sorted" : "not sorted";
        System.out.println("parallel forEach order: " + order + " this run");
    }

    static boolean isSorted(List<Integer> list) {
        for (int i = 1; i < list.size(); i++) {
            if (list.get(i - 1) > list.get(i)) {
                return false;
            }
        }
        return true;
    }
}

Output (results of the first and last lines vary between runs):

Output
shared ArrayList, 10 runs: 94279 96140 100000 50000 75000 100000 100000 100000 100000 25000
collected size:        100000
parallel forEach order: not sorted this run
  • Adding to a shared ArrayList from a parallel forEach is a data race. The program tried it 10 times: some runs lost elements, some happened to keep all 100,000, and a run can also throw ArrayIndexOutOfBoundsException, shown as AIOOBE. Code that works once proves nothing; ArrayList is not thread-safe.
  • Letting the stream build the result with toList() or a collector is safe: each thread fills its own part and the parts are merged in order.
  • forEach on a parallel stream runs in whatever order the threads finish; forEachOrdered restores encounter order at a cost.

Streams versus loops, sequential versus parallel

The program runs a cheap computation, summing squares, and an expensive one, testing primes by trial division, as a loop, a primitive stream, a parallel stream and a boxed stream. For each version the program keeps the quickest of three runs.

Java
import java.util.function.LongSupplier;
import java.util.stream.IntStream;
import java.util.stream.LongStream;

public class StreamSpeed {

    public static void main(String[] args) {
        System.out.println("cheap work: sum of squares of 0..9,999,999");
        time("for loop", () -> {
            long s = 0;
            for (long i = 0; i < 10_000_000; i++) {
                s += i * i;
            }
            return s;
        });
        time("LongStream", () -> LongStream.range(0, 10_000_000).map(i -> i * i).sum());
        time("LongStream.parallel()", () -> LongStream.range(0, 10_000_000).parallel()
            .map(i -> i * i).sum());
        time("Stream<Long> (boxed)", () -> LongStream.range(0, 10_000_000).boxed()
            .map(i -> i * i).reduce(0L, Long::sum));

        System.out.println("expensive work: count primes below 300,000");
        time("for loop", () -> {
            long c = 0;
            for (int i = 0; i < 300_000; i++) {
                if (isPrime(i)) {
                    c++;
                }
            }
            return c;
        });
        time("IntStream", () -> IntStream.range(0, 300_000).filter(StreamSpeed::isPrime).count());
        time("IntStream.parallel()", () -> IntStream.range(0, 300_000).parallel()
            .filter(StreamSpeed::isPrime).count());
        System.out.println("available processors: " + Runtime.getRuntime().availableProcessors());
    }

    static boolean isPrime(int n) {
        if (n < 2) {
            return false;
        }
        for (int d = 2; (long) d * d <= n; d++) {
            if (n % d == 0) {
                return false;
            }
        }
        return true;
    }

    // Best of 3 runs
    static void time(String name, LongSupplier f) {
        long best = Long.MAX_VALUE;
        long result = 0;
        for (int run = 0; run < 3; run++) {
            long t0 = System.nanoTime();
            result = f.getAsLong();
            best = Math.min(best, System.nanoTime() - t0);
        }
        System.out.printf("  %-24s %7.1f ms  (%d)%n", name, best / 1e6, result);
    }
}

Output (2-CPU Linux machine, JDK 21; rough timings):

Output
cheap work: sum of squares of 0..9,999,999
  for loop                     3.6 ms  (1291890006563070912)
  LongStream                   3.8 ms  (1291890006563070912)
  LongStream.parallel()        2.1 ms  (1291890006563070912)
  Stream<Long> (boxed)        58.2 ms  (1291890006563070912)
expensive work: count primes below 300,000
  for loop                    20.3 ms  (25997)
  IntStream                   20.4 ms  (25997)
  IntStream.parallel()        12.8 ms  (25997)
available processors: 2
  • A primitive LongStream was as fast as the loop: after JIT compilation, a simple stream pipeline becomes a similar loop.
  • The boxed Stream<Long> was more than ten times slower, because every value became a Long object. Use mapToInt, mapToLong and the primitive streams for numbers.
  • With two processors, the parallel versions gained between nothing and about 1.7 times across runs, short of the ideal factor of two; splitting, scheduling and merging cost something, and other work on the machine competes for the same cores. With tiny per-element work and small inputs, parallel streams are often slower than sequential ones.
  • Parallelism pays when the work per element is significant, the source splits well (arrays, ArrayList, ranges), and nothing shared is modified.

Stateful operations: sorted, distinct and limit

Most intermediate operations are stateless: filter and map look at one element and pass it on. A few are stateful and must remember earlier elements, or even wait for all of them. They change how far laziness can help, and some behave differently on parallel or infinite streams. The program logs what happens at each stage.

Java
import java.util.ArrayList;
import java.util.List;
import java.util.stream.IntStream;
import java.util.stream.Stream;

public class StatefulOps {

    public static void main(String[] args) {
        // 1. Stateless stages pass each element straight on; sorted must see all of them first
        List<String> log = new ArrayList<>();
        Stream.of(3, 1, 2)
            .peek(n -> log.add("in " + n))
            .map(n -> n * 10)
            .peek(n -> log.add("mapped " + n))
            .forEach(n -> log.add("out " + n));
        System.out.println("without sorted: " + log);
        log.clear();
        Stream.of(3, 1, 2)
            .peek(n -> log.add("in " + n))
            .sorted()
            .forEach(n -> log.add("out " + n));
        System.out.println("with sorted:    " + log);

        // 2. sorted before findFirst still reads everything; min reads everything once, no sort
        int[] seen = {0};
        int smallest = IntStream.range(0, 1_000_000).map(i -> 999_999 - i)
            .peek(i -> seen[0]++).sorted().findFirst().orElseThrow();
        System.out.println("sorted().findFirst() = " + smallest + " after reading " + seen[0]);
        seen[0] = 0;
        int viaMin = IntStream.range(0, 1_000_000).map(i -> 999_999 - i)
            .peek(i -> seen[0]++).min().orElseThrow();
        System.out.println("min() = " + viaMin + " after reading " + seen[0] + ", without sorting");

        // 3. On an infinite stream, a stateful stage before limit never finishes; after it is fine
        List<Integer> firstEvens = Stream.iterate(1, n -> n + 1)
            .filter(n -> n % 2 == 0).limit(5).sorted().toList();
        System.out.println("limit then sorted on an infinite stream: " + firstEvens);

        // 4. distinct keeps the first occurrence in encounter order
        System.out.println("distinct: " + Stream.of("b", "a", "b", "c", "a").distinct().toList());

        // 5. limit on an ordered parallel stream must keep the first n; unordered() relaxes it
        List<Integer> ordered = IntStream.range(0, 1_000).boxed().parallel().limit(5).toList();
        List<Integer> any = IntStream.range(0, 1_000).boxed().parallel().unordered().limit(5)
            .toList();
        System.out.println("parallel limit(5): " + ordered + ", after unordered(): " + any.size()
            + " elements, not necessarily the first five");
    }
}
Output
without sorted: [in 3, mapped 30, out 30, in 1, mapped 10, out 10, in 2, mapped 20, out 20]
with sorted:    [in 3, in 1, in 2, out 1, out 2, out 3]
sorted().findFirst() = 0 after reading 1000000
min() = 0 after reading 1000000, without sorting
limit then sorted on an infinite stream: [2, 4, 6, 8, 10]
distinct: [b, a, c]
parallel limit(5): [0, 1, 2, 3, 4], after unordered(): 5 elements, not necessarily the first five
  • Without sorted, each element went all the way through before the next started: in 3, mapped 30, out 30, then 1, then 2. With sorted, all three came in before anything came out, because the smallest element might be the last one read.
  • sorted().findFirst() read all 1,000,000 elements and sorted them to return 0. findFirst normally stops early, but not after a stage that needs every element. min() also read all of them, which a minimum must do, but skipped the O(n log n) sort.
  • On an infinite stream, limit(5) placed before sorted produced [2, 4, 6, 8, 10]. With sorted first, the program would never finish, since sorted would wait for the end of a stream that has none.
  • distinct kept the first occurrence of each value in encounter order, [b, a, c]. It remembers every value it has seen in a set, so on a large stream with few repeats it holds nearly the whole stream in memory.
  • A parallel limit(5) on an ordered source still returned [0, 1, 2, 3, 4], which forces the threads to coordinate. After unordered() it may return any five elements, and the documentation notes that relaxing the order can make limit much cheaper in parallel.

reduce versus collect

Both terminal operations combine the elements into one result, but they suit different results. reduce combines immutable values, such as numbers, into a new value at each step. collect fills a mutable container such as a list, a map or a StringBuilder. Choosing the wrong one gives wrong answers in parallel, sometimes without any exception.

Java
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import java.util.stream.Stream;

public class ReduceVsCollect {

    public static void main(String[] args) {
        List<String> words = List.of("stream", "map", "filter", "reduce", "collect");

        // 1. reduce with an identity and an associative operator works in parallel too
        int letters = words.parallelStream().map(String::length).reduce(0, Integer::sum);
        System.out.println("total letters (reduce): " + letters);

        // 2. Three-argument reduce: accumulate into a different type, combine partial results
        int viaThreeArg = words.parallelStream()
            .reduce(0, (sum, w) -> sum + w.length(), Integer::sum);
        System.out.println("total letters (3-arg reduce): " + viaThreeArg);

        // 3. A non-associative operator gives different answers sequentially and in parallel
        int seq = IntStream.rangeClosed(1, 8).reduce(0, (a, b) -> a - b);
        int par = IntStream.rangeClosed(1, 8).parallel().reduce(0, (a, b) -> a - b);
        System.out.println("subtraction: sequential " + seq + ", parallel " + par);

        // 4. A wrong identity is counted once per parallel chunk
        int seqPlus10 = IntStream.rangeClosed(1, 8).reduce(10, Integer::sum);
        int parPlus10 = IntStream.rangeClosed(1, 8).parallel().reduce(10, Integer::sum);
        System.out.println("identity 10: sequential " + seqPlus10 + ", parallel " + parPlus10);

        // 5. reduce is for values; mutable containers belong in collect
        int bad = 0;
        for (int run = 0; run < 20; run++) {
            List<Integer> shared = new ArrayList<>();      // one identity shared by every thread
            try {
                List<Integer> r = IntStream.range(0, 1_000).boxed().parallel()
                    .reduce(shared, (list, n) -> {
                        list.add(n);
                        return list;
                    }, (x, y) -> {
                        if (x != y) {
                            x.addAll(y);
                        }
                        return x;
                    });
                if (!r.equals(IntStream.range(0, 1_000).boxed().toList())) {
                    bad++;
                }
            } catch (RuntimeException e) {
                bad++;
            }
        }
        System.out.println("reduce into one shared ArrayList, parallel: wrong or crashed in "
            + bad + " of 20 runs");
        ArrayList<Integer> collected = IntStream.range(0, 1_000).boxed().parallel()
            .collect(ArrayList::new, ArrayList::add, ArrayList::addAll);
        System.out.println("collect(ArrayList::new, add, addAll), parallel: size "
            + collected.size() + ", first " + collected.subList(0, 3));

        // 6. String building: joining is a collect, not a reduce of strings
        String joined = words.stream().collect(Collectors.joining(", ", "[", "]"));
        System.out.println("joining: " + joined);
        String slow = Stream.of("a", "b", "c").reduce("", String::concat);
        System.out.println("reduce(\"\", concat): " + slow + " (copies the string at every step)");
    }
}
Output
total letters (reduce): 28
total letters (3-arg reduce): 28
subtraction: sequential -36, parallel 0
identity 10: sequential 46, parallel 76
reduce into one shared ArrayList, parallel: wrong or crashed in 20 of 20 runs
collect(ArrayList::new, add, addAll), parallel: size 1000, first [0, 1, 2]
joining: [stream, map, filter, reduce, collect]
reduce("", concat): abc (copies the string at every step)
  • Summing word lengths with reduce(0, Integer::sum) gave 28 in parallel, the same as sequentially. Addition is associative and 0 is a true identity, the two conditions the reduce documentation requires.
  • The three-argument form folds each word into an int running total and uses Integer::sum to merge the totals of different threads. It avoids a separate map step, at the cost of a less readable call.
  • Subtraction is not associative, so the same reduction gave -36 sequentially and 0 in parallel. The parallel stream split the numbers, reduced each part and then subtracted one part's result from another's.
  • With 10 as the "identity" for addition, the sequential sum was 46, but the parallel sum was 76: the 10 was added once for every chunk. An identity must leave every value unchanged.
  • Reducing into one shared ArrayList was wrong or crashed in all 20 parallel runs. All threads added to the same list at once, which breaks it, and even without a crash the order was scrambled. collect(ArrayList::new, ArrayList::add, ArrayList::addAll) gave each thread a fresh list and merged them in order, ending with all 1,000 elements starting [0, 1, 2].
  • Collectors.joining appends to one StringBuilder. reduce("", String::concat) gave the same abc but creates a new string for every element, which costs time in proportion to the square of the length on long streams.

When to use streams

Streams or loops
SituationPrefer
Filter, transform and collect a collectionA stream
Numeric totals and statisticsA primitive stream: mapToInt(...).sum()
Find the first matchfilter(...).findFirst() or a loop with break
Logic with several exits, checked exceptions or index arithmeticA loop
Modify the source collectionremoveIf, replaceAll, or a loop, not a stream
Heavy work per element, many coresTry parallel(), then measure
Results from a parallel streamA collector, never a shared mutable list
The same data processed twiceTwo streams from the source

For filtering and searching lists specifically, see filtering a list and finding an element.