Terminal Operations
Terminal operations are Stream API methods that trigger the actual traversal and processing of a stream pipeline, producing a non-stream result — a value, a collection, a side effect, or nothing (void). A stream can have exactly one terminal operation; once invoked, the stream is consumed and cannot be reused (calling any further operation on it throws IllegalStateException). Terminal operations fall into categories: reduction (reduce, collect), matching (anyMatch, allMatch, noneMatch), finding (findFirst, findAny), counting (count), iteration with side effects (forEach, forEachOrdered), and array/collection materialization (toArray, toList). Some terminal operations are short-circuiting (anyMatch, findFirst) and can terminate before examining the entire stream; others (forEach, count in most cases, collect) must examine every element. This entry covers every terminal operation in depth, the reduce() method and its three overloads with the combiner function's role in parallel execution, the Collectors framework as the primary mechanism for complex terminal reduction, short-circuiting semantics and their interaction with infinite streams, the single-use constraint and its rationale, and the choice between equivalent terminal operations for performance and clarity.
reduce() — The Fundamental Reduction Operation
// ── One-arg reduce() — Optional<T> result ────────────────────────────
List<Integer> numbers = List.of(1, 2, 3, 4, 5);
Optional<Integer> sum = numbers.stream().reduce((a, b) -> a + b);
System.out.println(sum); // Optional[15]
Optional<Integer> max = numbers.stream().reduce(Integer::max);
System.out.println(max); // Optional[5]
// Empty stream — returns Optional.empty():
Optional<Integer> emptySum = Stream.<Integer>empty().reduce((a, b) -> a + b);
System.out.println(emptySum); // Optional.empty
// ── Two-arg reduce() — identity avoids Optional ───────────────────────
int sumWithIdentity = numbers.stream().reduce(0, Integer::sum);
System.out.println(sumWithIdentity); // 15 (no Optional wrapper)
// Empty stream — returns identity:
int emptySumId = Stream.<Integer>empty().reduce(0, Integer::sum);
System.out.println(emptySumId); // 0
String concatenated = Stream.of("a", "b", "c")
.reduce("", String::concat);
System.out.println(concatenated); // "abc"
double product = Stream.of(2.0, 3.0, 4.0)
.reduce(1.0, (a, b) -> a * b); // identity = 1.0 (multiplicative identity)
System.out.println(product); // 24.0
// ── Three-arg reduce() — for type change and parallel combining ──────
List<String> words = List.of("hello", "world", "java");
// Accumulate total character count (U=Integer, T=String — different types):
int totalChars = words.stream().reduce(
0, // identity: U
(partialSum, word) -> partialSum + word.length(), // accumulator: (U,T) → U
Integer::sum // combiner: (U,U) → U — for parallel merging
);
System.out.println(totalChars); // 14 (5+5+4)
// In SEQUENTIAL streams, combiner is NEVER called:
int sequentialResult = words.stream() // not parallel
.reduce(0,
(sum, w) -> { System.out.println("accumulator: " + w); return sum + w.length(); },
(a, b) -> { System.out.println("combiner called!"); return a + b; } // never printed
);
// Output only shows "accumulator:" lines — combiner is never invoked sequentially
// In PARALLEL streams, combiner merges partial results from different threads:
int parallelResult = words.parallelStream()
.reduce(0,
(sum, w) -> sum + w.length(),
Integer::sum // called to merge partial sums from different threads
);
System.out.println(parallelResult); // 14 — same result, computed via parallel chunks + merge
// ── Non-associative operations — broken under parallelism ────────────
List<Integer> seq = List.of(10, 2, 3, 4);
// Sequential: deterministic left-to-right: ((10-2)-3)-4 = 1
int sequentialSubtract = seq.stream().reduce(0, (a, b) -> a - b);
// Wait: identity=0, so: (((0-10)-2)-3)-4 = -19
// Parallel: chunks computed independently, then combined — non-deterministic order:
int parallelSubtract = seq.parallelStream().reduce(0, (a, b) -> a - b);
// May NOT equal sequentialSubtract — subtraction is not associative
System.out.println("Sequential: " + sequentialSubtract); // -19 (deterministic)
System.out.println("Parallel: " + parallelSubtract); // unpredictable, may differ
// Correct approach for non-associative operations: don't use reduce() naively;
// restructure as an associative operation, or avoid parallel execution.
// ── Practical reduce() patterns ────────────────────────────────────────
// Find longest string:
List<String> strs = List.of("a", "abc", "ab", "abcd", "abcde");
Optional<String> longest = strs.stream()
.reduce((s1, s2) -> s1.length() >= s2.length() ? s1 : s2);
System.out.println(longest); // Optional[abcde]
// Build a combined object (e.g., merging statistics):
record Stats(int count, int sum, int max) {
static Stats combine(Stats a, Stats b) {
return new Stats(a.count() + b.count(), a.sum() + b.sum(), Math.max(a.max(), b.max()));
}
}
Stats result = numbers.stream()
.reduce(new Stats(0, 0, Integer.MIN_VALUE),
(stats, n) -> new Stats(stats.count() + 1, stats.sum() + n, Math.max(stats.max(), n)),
Stats::combine);
System.out.println(result); // Stats[count=5, sum=15, max=5]collect() and the Collectors Framework
// ── Basic collectors ──────────────────────────────────────────────────
List<String> words = List.of("apple", "banana", "cherry", "date", "elderberry");
List<String> list = words.stream().collect(Collectors.toList());
Set<String> set = words.stream().collect(Collectors.toSet());
List<String> immutableList = words.stream().collect(Collectors.toUnmodifiableList());
// Java 16+ shortcut — equivalent to toUnmodifiableList() in effect:
List<String> direct = words.stream().toList(); // simpler syntax
// ── toMap — with and without merge function ───────────────────────────
Map<String, Integer> wordLengths = words.stream()
.collect(Collectors.toMap(Function.identity(), String::length));
System.out.println(wordLengths); // {apple=5, banana=6, ...}
// Duplicate keys without merge function — throws IllegalStateException:
List<String> dupKeySource = List.of("apple", "apricot", "banana");
try {
Map<Character, String> byFirstLetter = dupKeySource.stream()
.collect(Collectors.toMap(s -> s.charAt(0), s -> s)); // 'a' appears twice
} catch (IllegalStateException e) {
System.out.println("Duplicate key: " + e.getMessage());
}
// With merge function — resolves duplicates:
Map<Character, String> merged = dupKeySource.stream()
.collect(Collectors.toMap(
s -> s.charAt(0), s -> s,
(existing, replacement) -> existing + "," + replacement // merge function
));
System.out.println(merged); // {a=apple,apricot, b=banana}
// ── joining ──────────────────────────────────────────────────────────
String simple = words.stream().collect(Collectors.joining());
String withDelim = words.stream().collect(Collectors.joining(", "));
String withAll = words.stream().collect(Collectors.joining(", ", "[", "]"));
System.out.println(withAll); // [apple, banana, cherry, date, elderberry]
// ── groupingBy — basic and with downstream collector ──────────────────
Map<Integer, List<String>> byLength = words.stream()
.collect(Collectors.groupingBy(String::length));
System.out.println(byLength); // {4=[date], 5=[apple], 6=[banana, cherry], 10=[elderberry]}
// groupingBy with counting downstream:
Map<Integer, Long> countByLength = words.stream()
.collect(Collectors.groupingBy(String::length, Collectors.counting()));
System.out.println(countByLength); // {4=1, 5=1, 6=2, 10=1}
// groupingBy with joining downstream:
Map<Integer, String> joinedByLength = words.stream()
.collect(Collectors.groupingBy(String::length, Collectors.joining(", ")));
System.out.println(joinedByLength); // {6=banana, cherry, ...}
// Nested groupingBy:
record Person(String name, String dept, int age) {}
List<Person> people = List.of(
new Person("Alice", "Eng", 30), new Person("Bob", "Eng", 25),
new Person("Carol", "Sales", 35), new Person("Dave", "Sales", 28)
);
Map<String, Map<Boolean, List<Person>>> byDeptThenAge = people.stream()
.collect(Collectors.groupingBy(Person::dept,
Collectors.partitioningBy(p -> p.age() >= 30)));
System.out.println(byDeptThenAge);
// {Eng={false=[Bob], true=[Alice]}, Sales={false=[Dave], true=[Carol]}}
// ── partitioningBy — always exactly two groups ─────────────────────────
Map<Boolean, List<String>> partitioned = words.stream()
.collect(Collectors.partitioningBy(w -> w.length() > 5));
System.out.println(partitioned);
// {false=[apple, date], true=[banana, cherry, elderberry]}
// ── summarizing collectors — multiple stats in one pass ────────────────
IntSummaryStatistics stats = words.stream()
.collect(Collectors.summarizingInt(String::length));
System.out.println("Count: " + stats.getCount()); // 5
System.out.println("Sum: " + stats.getSum()); // 31
System.out.println("Min: " + stats.getMin()); // 4
System.out.println("Max: " + stats.getMax()); // 10
System.out.println("Avg: " + stats.getAverage()); // 6.2
// ── teeing — two collectors, one pass (Java 12+) ───────────────────────
record MinMax(int min, int max) {}
MinMax minMax = words.stream()
.map(String::length)
.collect(Collectors.teeing(
Collectors.minBy(Comparator.naturalOrder()),
Collectors.maxBy(Comparator.naturalOrder()),
(min, max) -> new MinMax(min.orElse(0), max.orElse(0))
));
System.out.println(minMax); // MinMax[min=4, max=10]
// ── Custom Collector via Collector.of() ─────────────────────────────────
Collector<String, StringBuilder, String> customJoiner = Collector.of(
StringBuilder::new, // supplier
(sb, s) -> sb.append(s).append("|"), // accumulator
StringBuilder::append, // combiner
sb -> sb.length() > 0 ? sb.substring(0, sb.length()-1) : "" // finisher
);
String custom = words.stream().collect(customJoiner);
System.out.println(custom); // apple|banana|cherry|date|elderberryMatching, Finding, forEach, and Single-Use Constraints
// ── anyMatch, allMatch, noneMatch — short-circuiting quantifiers ──────
List<Integer> numbers = List.of(2, 4, 6, 8, 10, 11, 12);
boolean hasOdd = numbers.stream().anyMatch(n -> n % 2 != 0);
boolean allEven = numbers.stream().allMatch(n -> n % 2 == 0);
boolean noneNegative = numbers.stream().noneMatch(n -> n < 0);
System.out.println(hasOdd); // true (11 is odd)
System.out.println(allEven); // false (11 breaks it)
System.out.println(noneNegative); // true
// Vacuous truth for empty streams:
List<Integer> empty = List.of();
System.out.println(empty.stream().allMatch(n -> n > 1000)); // true — vacuously
System.out.println(empty.stream().anyMatch(n -> n > 1000)); // false — vacuously
System.out.println(empty.stream().noneMatch(n -> n > 1000)); // true — vacuously
// Short-circuit demonstration — stops at first match:
boolean found = numbers.stream()
.peek(n -> System.out.println("checking: " + n))
.anyMatch(n -> n == 6);
// Output: checking: 2, checking: 4, checking: 6 (stops here — found!)
// ── findFirst vs findAny ────────────────────────────────────────────
List<String> words = List.of("banana", "apple", "cherry", "date");
Optional<String> first = words.stream()
.filter(w -> w.length() == 6)
.findFirst(); // respects encounter order
System.out.println(first); // Optional[banana] — first match in order
Optional<String> any = words.parallelStream()
.filter(w -> w.length() == 6)
.findAny(); // no ordering guarantee, may differ across runs/threads
System.out.println(any); // Optional[banana] or Optional[cherry] — either is valid
// findAny() is typically faster in parallel for existence checks:
boolean exists = words.parallelStream()
.filter(w -> w.startsWith("c"))
.findAny()
.isPresent();
System.out.println(exists); // true
// ── forEach vs forEachOrdered ──────────────────────────────────────────
List<Integer> seq = List.of(1, 2, 3, 4, 5);
// Sequential — order is naturally preserved either way:
seq.stream().forEach(System.out::println); // 1,2,3,4,5 in order
seq.stream().forEachOrdered(System.out::println); // 1,2,3,4,5 in order (same)
// Parallel — forEach() does NOT guarantee order:
seq.parallelStream().forEach(System.out::println);
// May print in any order: 3,1,5,2,4 or any permutation
// Parallel — forEachOrdered() FORCES order (sacrifices parallelism benefit):
seq.parallelStream().forEachOrdered(System.out::println);
// Always prints: 1,2,3,4,5 — but loses most parallel speedup
// ── count() — sometimes optimized without full traversal ──────────────
List<Integer> million = IntStream.rangeClosed(1, 1_000_000).boxed().collect(Collectors.toList());
// No filter — JVM may shortcut via Collection.size():
long countNoFilter = million.stream()
.map(n -> n * 2) // size-preserving — count() may skip actual mapping
.count();
System.out.println(countNoFilter); // 1000000 (computed instantly via size, no map() execution)
// With filter — must actually traverse and count matches:
long countWithFilter = million.stream()
.filter(n -> n % 7 == 0) // changes count unpredictably — must traverse
.count();
System.out.println(countWithFilter);
// ── toArray ──────────────────────────────────────────────────────────
Object[] objArray = words.stream().toArray(); // Object[]
String[] strArray = words.stream().toArray(String[]::new); // properly typed String[]
System.out.println(Arrays.toString(strArray)); // [banana, apple, cherry, date]
// ── Single-use constraint — IllegalStateException on reuse ───────────
Stream<String> stream = words.stream();
long count = stream.count(); // terminal operation — consumes the stream
try {
stream.forEach(System.out::println); // ERROR: stream already operated upon
} catch (IllegalStateException e) {
System.out.println("Cannot reuse stream: " + e.getMessage());
}
// CORRECT: create a new stream for each traversal:
long count1 = words.stream().count(); // new stream
List<String> filtered = words.stream() // another new stream
.filter(w -> w.length() > 4)
.collect(Collectors.toList());
// A short-circuiting terminal op also "uses up" the stream, even if not fully traversed:
Stream<Integer> partial = Stream.of(1, 2, 3, 4, 5);
Optional<Integer> firstMatch = partial.filter(n -> n > 2).findFirst(); // consumes stream
try {
partial.count(); // ERROR — even though findFirst() didn't read everything
} catch (IllegalStateException e) {
System.out.println("Stream already consumed by findFirst()");
}