AlgoMaster Logo

Terminal Operations

High Priority25 min readUpdated September 21, 2026
Listen to this chapter
Unlock Audio

Terminal operations are what actually make a stream do something. Intermediate operations build up a pipeline, but until a terminal operation runs, no element is read, no filter is applied, no mapping happens. This lesson covers each terminal operation in depth: what it returns, when it short-circuits, what it does on an empty stream, and the small traps that lead to bugs.

Quick Reference Table

Every terminal operation covered in this lesson at a glance. Keep this table nearby while you read; the rest of the lesson digs into each row.

OperationReturnsShort-Circuits?Description
forEach(Consumer)voidNoRuns an action on each element, no order guarantee in parallel
forEachOrdered(Consumer)voidNoLike forEach but respects encounter order even in parallel
count()longNoCounts elements in the stream
reduce(BinaryOperator)Optional<T>NoCombines elements into one, empty stream returns empty Optional
reduce(identity, BinaryOperator)TNoCombines elements with a starting value, returns identity if empty
reduce(identity, accumulator, combiner)UNoThree-arg form for parallel reductions of a different result type
min(Comparator)Optional<T>NoSmallest element by the given comparator
max(Comparator)Optional<T>NoLargest element by the given comparator
anyMatch(Predicate)booleanYesTrue if any element matches the predicate
allMatch(Predicate)booleanYesTrue if all elements match, true on empty stream
noneMatch(Predicate)booleanYesTrue if no element matches, true on empty stream
findFirst()Optional<T>YesFirst element in encounter order
findAny()Optional<T>YesAny element, faster in parallel
toArray()Object[]NoDumps the stream into an Object[]
toArray(IntFunction)A[]NoDumps the stream into a typed array
toList()List<T>NoJava 16+, unmodifiable list, allows nulls
collect(Collector)depends on CollectorNoFlexible reduction into lists, sets, maps, and more

forEach and forEachOrdered

forEach(Consumer<? super T> action) runs the given action on every element. It returns void, so it's the end of the pipeline. The signature is:

In a sequential stream, both methods process elements in encounter order. The difference shows up only when the stream is parallel. forEach is free to call the action in any order on any thread, which is faster but unordered. forEachOrdered forces the runtime to respect encounter order, which costs synchronization but keeps the output predictable.

The parallel forEach output above is one possible run. The order can change on every execution.

forEachOrdered on a parallel stream often defeats the point of parallelism. The pipeline can still split the work, but emitting in order forces re-synchronization at the end. If you need ordered output, ask yourself whether you need parallel at all.

A subtle point: forEach is for side effects, not for computing values. Mutating a shared list from inside forEach works on a sequential stream but breaks on a parallel one because multiple threads write to the same collection. If you want to build a result, use collect or reduce, not forEach.

What's wrong with this code?

ArrayList isn't thread-safe. With a parallel stream, multiple threads call expensive.add at the same time, which can corrupt the list, drop elements, or throw ArrayIndexOutOfBoundsException.

Fix:

collect is built for parallel work. It uses a thread-safe combining strategy internally.

count

count() returns the number of elements that reach the end of the pipeline as a long. The signature is:

It's the simplest terminal operation, but it interacts in useful ways with intermediate operations. After a filter, count tells you how many items passed.

Since Java 9, the runtime can sometimes skip materializing elements just to count them. For a stream backed by a Collection with no intermediate operations that change the count, count() can return the collection's size directly. Add a filter, and the optimization is off; the stream has to inspect each element to know which ones survive.

reduce: The Three Forms

reduce is the general workhorse for combining elements into a single result. It comes in three forms, each suited to a different shape of problem.

Form 1: reduce(BinaryOperator)

This form combines elements pairwise using the accumulator. It returns Optional<T> because the stream might be empty, in which case there's nothing to return. The result type matches the element type.

The returned Optional is empty for an empty stream. That's the correct signal: "there was nothing to reduce."

Form 2: reduce(identity, BinaryOperator)

The identity is a starting value that also serves as the answer when the stream is empty. For sum, the identity is 0. For product, it's 1. For string concatenation, it's "". The accumulator combines the running result with the next element.

The "identity" name isn't decorative. For the operation to behave correctly, especially in parallel, the identity must satisfy accumulator(identity, x) == x for any element x. Pick 0 for sum and the math works. Pick 1 for sum and you'll be off by one every time the runtime injects the identity into a partial result.

Form 3: reduce(identity, accumulator, combiner)

This is where things get interesting. The three-arg form lets the result type U differ from the element type T. You provide an accumulator that folds a T into a U, and a combiner that merges two U partial results together.

The combiner is the key to running in parallel. When the stream splits into chunks, each chunk produces its own partial U. The combiner stitches them back together. In a sequential stream the combiner is never called; in a parallel stream it's required.

Here the elements are Strings but the result is an int. The accumulator folds a String's length into the running int. In parallel, two threads might compute partial sums for "Headphones" (10) and "NotebookKeyboard" (16 combined), and the combiner adds them with Integer::sum. The diagram below shows the split-and-merge structure.

Each chunk applies the accumulator to fold its element into a partial U. The combiner merges partials. The accumulator and combiner must agree: combiner(u, accumulator(identity, t)) == accumulator(u, t). If that contract breaks, parallel and sequential results disagree. What's wrong with this code?

The combiner ignores b and returns just a. In a sequential run, the combiner never fires, so the result is correct. In parallel, the runtime splits the work, and the broken combiner throws away whichever partial happens to land on the right. You'll get an answer like 2, 3, or 4 instead of 9.

Fix:

The combiner must actually combine. Integer::sum adds both partials, matching what the accumulator does element-by-element.

min and max

Both return Optional<T> because a stream might be empty. The comparator decides what "smallest" and "largest" mean. For ascending price, that's Comparator.comparingDouble(Product::getPrice).

min and max walk the entire stream. They aren't short-circuiting because the smallest could be the last element. If you need a single value and don't care which way the comparator points, write the comparator once and reuse it for both calls.

Avoid sorting a stream just to take the first or last element. stream.sorted().findFirst() is O(n log n) and materializes the whole stream. stream.min(cmp) is O(n) with no sort.

anyMatch, allMatch, noneMatch

These three terminal operations all return boolean and all short-circuit. Their signatures:

  • anyMatch returns true as soon as it finds one element that matches. If nothing matches, it returns false.
  • allMatch returns false as soon as it finds one that doesn't match. If everything matches, it returns true.
  • noneMatch returns false as soon as it finds one that matches. If nothing matches, it returns true.

Short-circuiting means the pipeline stops pulling new elements the moment the answer is known. For a stream of a million items where the first matches, anyMatch reads exactly one.

The Empty Stream Trap (Vacuous Truth)

The catch that catches everyone at least once. On an empty stream:

  • anyMatch returns false. There's no element that matches.
  • allMatch returns true. There's no element that fails. This is "vacuous truth."
  • noneMatch returns true. There's no element that matches.

allMatch returning true on an empty stream looks wrong at first. The logic is: "for every element in the stream, the predicate holds." If there are no elements, the statement holds trivially. This matches how forall works in math and logic.

What's wrong with this code?

The code prints "Ready to checkout!" for an empty cart, because allMatch returns true on an empty stream. That's the opposite of what the author wanted.

Fix:

Add an explicit emptiness check before relying on allMatch. The same trap applies to noneMatch. Whenever a predicate-based check feels like it should "fail" on empty, write the empty check yourself.

findFirst and findAny

Both return Optional<T> because the stream might be empty. Both short-circuit and stop the pipeline once an element is found.

The difference is encounter order. findFirst returns the first element in encounter order. findAny returns whichever element the runtime can produce most cheaply, which may not be the first.

In this small example they agree, but in a parallel stream over a large source findAny can return any matching element. For an unordered stream (created with unordered() or from a HashSet), findFirst behaves the same as findAny because there's no encounter order to respect.

In a parallel pipeline, findAny can be much faster than findFirst because it doesn't wait for the "leftmost" thread to report in. Use findFirst only when you need the first element in encounter order. If any match is fine, prefer findAny.

toArray

The no-arg form returns an Object[]. Even if your stream is Stream<String>, the raw toArray() gives you Object[], not String[]. If you want a typed array, use the generator form.

The String[]::new is a method reference to the array constructor. It's an IntFunction<String[]>: given a size, it returns a fresh String[]. The runtime calls it with the final element count.

What's wrong with this code?

This compiles, but it throws ClassCastException at runtime. toArray() returns an actual Object[] instance, not a String[] boxed as Object[]. You can't cast between them; the array's runtime type is Object[], period.

Fix:

The generator form constructs a real String[] with the right runtime type.

toList (Java 16+)

Added in Java 16, Stream.toList() is the shortest path from a stream to a List. It does three things differently from collect(Collectors.toList()):

  1. It returns an unmodifiable list. Trying to add, remove, or set throws UnsupportedOperationException.
  2. It allows null elements.
  3. It doesn't need the Collectors import.

collect(Collectors.toList()) returns a mutable ArrayList, which is sometimes what you want and sometimes error-prone. If callers expect to mutate, the unmodifiable list from Stream.toList() surprises them. If callers shouldn't mutate, the mutable Collectors.toList() lets bugs slip in. Pick the one that matches your contract.

Stream.toList() may return a more memory-efficient implementation than ArrayList. It's also faster in practice because it can size the result exactly when the stream's count is known.

collect: A Brief Introduction

collect is the most flexible terminal operation. It takes a Collector, which is a recipe for accumulating elements into some result container: a list, a set, a map, a string, a custom summary object. A small taste of how it's used.

Collectors.toList() is one collector among many. The Collectors class also offers toSet, toMap, joining, groupingBy, partitioningBy, and teeing, and you can compose them for things like "group products by category and sum their prices." For now, remember that collect is the call to make whenever the answer is a container, and the Collectors class has a factory method for almost every container shape you can imagine.

Terminal Operations on Primitive Streams

IntStream, LongStream, and DoubleStream add a few terminal operations that don't exist on the object Stream. These avoid boxing each element back into Integer, Long, or Double just to do arithmetic on it.

OperationReturnsDescription
sum()int, long, or doubleSum of all elements
average()OptionalDoubleArithmetic mean, empty if the stream is empty
min()OptionalInt, OptionalLong, OptionalDoubleSmallest element
max()same as min()Largest element
summaryStatistics()IntSummaryStatistics etc.Count, sum, min, max, and average in one pass

summaryStatistics() is the operation to use when you need several aggregates from one pass. It's a single traversal of the data, no matter how many of count, sum, min, max, and average you read.

stream.mapToInt(...).sum() beats stream.reduce(0, Integer::sum) for adding ints. The primitive stream skips autoboxing, so a million additions become a million int adds instead of a million Integer creations.

Putting It Together: A Cart Summary

A small program that uses several terminal operations on a shopping cart:

Each terminal operation answers a different question about the same data. Pick the one that matches the question. Don't use forEach and a mutable variable when there's a specific terminal that already does what you want.

Quiz

Terminal Operations Quiz

10 quizzes