W3docs

Java Stream Terminal Operations

Trigger stream evaluation in Java with terminal operations — collect, forEach, reduce, count, min, max, anyMatch.

Java Stream Terminal Operations

A terminal operation is what makes a stream pipeline actually run. Intermediates record the work; a terminal pulls elements through, evaluates the pipeline, and produces a result (or a side effect). Every pipeline ends in exactly one terminal — call it, and the stream is consumed; call another terminal on the same stream and you get IllegalStateException.

Terminals come in three shapes. Aggregators return a single value (count, sum, min, max, reduce). Searchers look for one element and stop (findFirst, findAny, anyMatch, allMatch, noneMatch). Builders materialise the stream into a container (toList, toArray, collect, forEach for side effects). This chapter walks every terminal you'll write outside of collect, which is large enough to need its own chapter next.

forEach / forEachOrdered — side effects

The simplest terminal. Runs a Consumer<T> for each element, returns nothing:

names.stream().forEach(System.out::println);

The order is not guaranteed — on a sequential stream it usually is; on a parallel stream it isn't. If you need source order even in parallel, use forEachOrdered:

names.parallelStream().forEachOrdered(System.out::println);

forEach is for side effects you genuinely want — logging, mutating a sink, calling a non-stream API. It is not the right way to build a collection (that's toList / collect) or accumulate a value (that's reduce). A forEach that mutates an outer list is a code smell even when it works, because it gives up everything that made the pipeline declarative in the first place.

count — how many elements

Returns a long:

long adults = people.stream().filter(p -> p.age() >= 18).count();

count short-circuits on sized sources where the JVM can compute the answer from the source's size (so IntStream.range(0, 1_000_000).count() returns 1000000 without iterating). On a stream with an active filter or flatMap, it has to walk every element.

A common trap: stream.count() on a .peek(...) chain may not run the peek if the JVM can prove the count from the source, because there is no observable behaviour difference. Don't use peek to "see how many were filtered" — use mapToInt(x -> 1).sum() or restructure.

min / max — extreme elements

Both take a Comparator<T> and return Optional<T> (because the stream might be empty):

Optional<Person> oldest  = people.stream().max(Comparator.comparingInt(Person::age));
Optional<String> shortest = words.stream().min(Comparator.comparingInt(String::length));

Primitive specialisations are simpler — IntStream.max() returns OptionalInt, no comparator needed:

OptionalInt highest = nums.stream().mapToInt(Integer::intValue).max();
int hi = highest.orElse(Integer.MIN_VALUE);

min/max are short-circuiting only on bounded sources. On an infinite stream, max never terminates.

findFirst / findAny — get one element

Both return Optional<T>, both short-circuit. The difference is what they promise about which element you get:

Optional<Person> first = people.stream().filter(p -> p.age() >= 30).findFirst();
Optional<Person> any   = people.stream().filter(p -> p.age() >= 30).findAny();
  • findFirst returns the first element in encounter order. On a sequential stream that's the literal first. On a parallel stream it costs more than findAny because the JVM has to coordinate.
  • findAny returns some matching element — the first one any worker finds. In parallel it's cheaper. In sequential, both return the same thing.

Use findAny when which match you get genuinely does not matter (it's a single existence check that needs the value, not just a boolean). Use findFirst when you mean "the first one."

anyMatch / allMatch / noneMatch — existence quantifiers

Take a Predicate<T> and return boolean. All three short-circuit:

boolean hasAdult  = people.stream().anyMatch(p -> p.age() >= 18);
boolean allAdult  = people.stream().allMatch(p -> p.age() >= 18);
boolean noChildren = people.stream().noneMatch(p -> p.age() < 13);
  • anyMatch stops as soon as one passes.
  • allMatch stops as soon as one fails.
  • noneMatch is !anyMatch(p) — stops at the first pass and returns false.

Empty-stream semantics (the rule that trips everyone once): anyMatch on empty is false. allMatch and noneMatch on empty are both true — vacuously, because there are no counter-examples. That can be exactly what you want or exactly what you don't, depending on the question. If "empty" is a possibility worth handling, check isEmpty (or count() == 0) first.

reduce — fold to a single value

The most general aggregator. Three overloads, each for a slightly different shape:

Two-arg reduce(identity, accumulator) — fold with a starting value, returns T (no Optional, because identity is the answer for an empty stream):

int sum = nums.stream().reduce(0, Integer::sum);
String all = words.stream().reduce(\"\", String::concat);

One-arg reduce(accumulator) — no identity; returns Optional<T> for the empty-stream case:

Optional<Integer> sum = nums.stream().reduce(Integer::sum);
Optional<String> longest = words.stream()
    .reduce((a, b) -> a.length() >= b.length() ? a : b);

Three-arg reduce(identity, accumulator, combiner) — used when the accumulator produces a different type from the elements (and required in parallel). The combiner merges two partial results:

int totalLength = words.stream()
    .reduce(0,
            (acc, w) -> acc + w.length(),     // BiFunction<Integer, String, Integer>
            Integer::sum);                     // BinaryOperator<Integer>

Three rules for reduce that prevent the pipeline from going subtly wrong:

  1. The accumulator must be associative: f(f(a, b), c) == f(a, f(b, c)). Sums and string concat satisfy this; subtraction does not.
  2. Identity must be a true identity: f(id, x) == x for all x. 0 for +, 1 for *, \"\" for concat.
  3. The accumulator and combiner must be stateless and side-effect-free.

Violate any of these and a sequential pipeline still gives the right answer most of the time — a parallel one will surprise you. (This is the same contract Collectors.reducing and parallel reduce lean on.)

sum / average — primitive aggregators

Only on primitive streams. sum returns the primitive; average returns an OptionalDouble:

int total      = IntStream.rangeClosed(1, 100).sum();
OptionalDouble avg = nums.stream().mapToInt(Integer::intValue).average();
double mean = avg.orElse(0.0);

For richer numeric summaries — count, sum, min, max, average in one pass — see IntSummaryStatistics:

IntSummaryStatistics stats = nums.stream().mapToInt(Integer::intValue).summaryStatistics();
System.out.println(stats);   // {count=N, sum=..., min=..., average=..., max=...}

That's one pass, one allocation, and far cheaper than computing each separately.

toArray and toList — materialise

Two short-cut "give me everything" terminals:

Object[] anyArr = stream.toArray();                     // Object[]
String[] strArr = stream.toArray(String[]::new);        // typed via constructor ref
List<String> immutable = stream.toList();               // Java 16+, unmodifiable

stream.toList() (Java 16+) is the modern way to materialise a stream into a List and is the right choice 95% of the time. It is unmodifiable and may contain nulls; if you need a mutable list, a specific implementation, or a Set/Map, fall back to collect(Collectors.toCollection(ArrayList::new)) or its friends in the next chapter.

toArray(T[]::new) is the only way to get a typed array out of an object stream — the IntFunction<T[]> form gives the runtime the array's component type.

iterator and spliterator — escape hatches

A stream can be turned into an Iterator<T> or Spliterator<T> for hand-off to code that expects one:

for (Iterator<String> it = stream.iterator(); it.hasNext(); ) {
    use(it.next());
}

These are both terminals — they consume the stream. They exist for interop, not for "I want a for loop"; if you want a loop, use one without making a stream first.

Short-circuiting vs. consuming — the safety table

TerminalShort-circuits on infinite source?
findFirst / findAnyyes
anyMatch / allMatch / noneMatchyes
limit(n) (intermediate) then anythingyes
forEach / forEachOrderedno — consumes everything
countno — consumes everything
min / maxno — consumes everything
reduceno — consumes everything
sum / average / summaryStatisticsno — consumes everything
toList / toArray / collectno — consumes everything

The pattern is clear: any terminal that needs to consider every element to produce its answer is not short-circuiting, and pairing it with an infinite source without a limit upstream hangs the JVM. Searchers and quantifiers are the only "safe-on-infinite" terminals.

A worked example: every shape of terminal on one pipeline

The program below builds a tiny stream, calls every terminal we've covered, and shows the empty-stream answers for the three matchers and for min / findFirst / reduce.

java— editable, runs on the server

What to take from the run:

  • The "search" terminals — findFirst, findAny, anyMatch, allMatch, noneMatch — and the "consume-all" terminals — count, min/max, reduce, sum, toList — split the chapter cleanly. The search terminals short-circuit; the consume-all ones don't. Pair the second group with an infinite source only behind a limit.
  • allMatch on an empty stream returned true. So did noneMatch. That's vacuous truth — it's the standard answer, and it's the most common reason production code "incorrectly passes" an empty-input edge case. If empty is meaningful, check for it first.
  • The three reduce overloads cover three patterns. Two-arg with a true identity returns T. One-arg returns Optional<T> because there is no identity. Three-arg lets the accumulator type differ from the element type — and is the form that's actually safe in parallel, because the combiner tells the JVM how to merge partial results.
  • summaryStatistics() did in one pass what calling min, max, sum, average, and count separately would have done in five. On any non-trivial numeric stream, prefer it.
  • toList() returned an unmodifiable list. That's the Java 16+ default and almost always what you want; the next chapter shows the Collectors.toCollection(...) form when you need a mutable one, a specific implementation, or a Set / Map.

What's next

collect is the one terminal we deferred — and the gateway to half the API. The next chapter, Java Stream Collectors, walks the Collectors toolbox: toList/toSet/toMap, groupingBy, partitioningBy, joining, counting, summingInt, averagingDouble, mapping, reducing, and the downstream pattern that composes them.

Practice

Practice

A stream contains zero elements. Which of these returns `true`?