Reduce Method in Java 8 Streams – identity, accumulator and combiner
-
Last Updated: March 30, 2024
-
By: javahandson
-
Series
The reduce method in Java 8 streams answers one simple question: how do you squeeze a whole stream down into a single value? A sum, a maximum, a longest name, a combined report line. In this article we will build that idea from the ground up, walk through all three overloads, and finish with the traps that catch almost every beginner.
Most stream pipelines end by handing you a collection. You filter, you map, you collect, and out comes a list. Sometimes, though, a list is not what you want at all. You want one number. One name. One total.
That is where reduction steps in. A reduction walks the stream, keeps a running result, and folds every element into it. When the stream runs dry, the running result is your answer.
Java 8 gives you three reduce methods for this job. They look similar, and beginners mix them up constantly. Once you see what each one adds, the confusion clears up fast.
reduce call solve the same problemreduce overloads, with the exact signature of eachidentity, accumulator and combinerreduce on IntStream, LongStream and DoubleStreamreduce differs from collect, and when to pick each onereduce gives a different answer on a parallel streamThink about totalling a shopping bill by hand. You start at zero. You add the first item, then the second, then the third. The running total carries forward each time. At the end you read off the last number.
That is a reduction in one sentence. Two things go in, one thing comes out, over and over, until nothing is left.
The Java docs call reduce a terminal operation. Terminal means the pipeline actually runs at that moment and the stream is finished afterwards. You cannot reuse it.
Reductions are everywhere once you look. Sum, product, count, maximum, minimum, string concatenation, boolean AND across a list of flags. Every one of them collapses many values into one.
Let us start with the plainest example there is, and then swap it for the stream version.
Here is the loop every Java developer has written a hundred times.
package com.javahandson.reduce;
import java.util.Arrays;
import java.util.List;
public class NoReduce {
public static void main(String[] args) {
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
int sum = 0;
for (int number : numbers) {
sum = sum + number;
}
System.out.println("Sum of all the numbers : " + sum); // Output: Sum of all the numbers : 15
}
}Look closely at what this loop contains. There is a starting value of 0. There is a rule that joins the running total to the next number. And there is the plumbing that walks the list.
The starting value and the rule are the interesting parts. The plumbing is noise you rewrite every single time.
Now hand the plumbing to the Stream API and keep only the two interesting bits.
package com.javahandson.reduce;
import java.util.Arrays;
import java.util.List;
public class Summing {
public static void main(String[] args) {
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
int sum = numbers.stream()
.reduce(0, (a, b) -> a + b);
System.out.println("Sum of all the numbers : " + sum); // Output: Sum of all the numbers : 15
}
}Two arguments, and that is the whole story:
Notice what disappeared. No counter, no mutable local variable, no explicit loop. You described the calculation instead of spelling out the steps.
The picture below shows the fold happening one element at a time.

Follow the steps for [1, 2, 3, 4, 5] with an identity of 0:
a = 0 and b = 1. The result 1 becomes the new running value.a = 1 and b = 2, so the running value moves to 3.a = 3 and b = 3 push it to 6.a = 6 and b = 4 give 10.a = 10 and b = 5, and 15 comes back to you.The identity always arrives as the very first a. Every element of the stream takes a turn as b. Keep that pattern in your head and the rest of this article follows easily.
The Stream interface declares reduce three times. Each version adds one capability the previous one lacked.
T reduce(T identity, BinaryOperator<T> accumulator)
This is the version you just saw. Give it a starting value and a rule, and it hands back a plain T.
The return type matters. Because you supplied a starting value, there is always something to return. An empty stream simply gives you the identity back.
List<Integer> empty = new ArrayList<>(); int sum = empty.stream().reduce(0, (a, b) -> a + b); System.out.println(sum); // Output: 0 // Multiplication needs 1 as the identity, not 0 List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5); int product = numbers.stream().reduce(1, (a, b) -> a * b); System.out.println(product); // Output: 120
Swap the identity to match the operation. Zero for addition, one for multiplication, an empty string for concatenation. Get this wrong and your answer quietly shifts.
Optional<T> reduce(BinaryOperator<T> accumulator)
Drop the identity and one thing changes: an empty stream now has no answer at all. Java refuses to invent one, so it wraps the result in an Optional.
package com.javahandson.reduce;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
public class OptionalReduce {
public static void main(String[] args) {
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
Optional<Integer> sum = numbers.stream().reduce((a, b) -> a + b);
System.out.println(sum); // Output: Optional[15]
sum.ifPresent(value -> System.out.println("Sum : " + value)); // Output: Sum : 15
}
}The first element becomes the seed here. Java takes it as-is, then folds the rest on top. No made-up starting value creeps into the maths.
An empty stream hands you Optional.empty, and you decide what that means.
List<Integer> numbers = Arrays.asList();
Optional<Integer> sum = numbers.stream().reduce((a, b) -> a + b);
System.out.println(sum); // Output: Optional.empty
// Fall back to a default
System.out.println(sum.orElse(0)); // Output: 0
// Or refuse to continue
int result = sum.orElseThrow(() -> new IllegalStateException("No values to sum."));
// Output: Exception in thread "main" java.lang.IllegalStateException: No values to sum.So which one do you pick? Use the identity version when zero elements has an obvious answer. Use the Optional version when it does not. The average of nothing is meaningless, and so is the maximum of nothing.
<U> U reduce(U identity,
BiFunction<U, ? super T, U> accumulator,
BinaryOperator<U> combiner)The third overload looks scary, and its generics do it no favours. It buys you one genuine new power: the result type U no longer has to match the element type T.
Say you have a stream of String names and you want the total number of characters. Elements are strings, the answer is an integer, and the first two overloads cannot bridge that gap.
package com.javahandson.reduce;
import java.util.Arrays;
import java.util.List;
public class CombinerDemo {
public static void main(String[] args) {
List<String> names = Arrays.asList("Amit", "Sara", "Vikram", "Neha");
int totalLetters = names.stream()
.reduce(0, // identity: an int
(total, name) -> total + name.length(), // accumulator: int + String
(left, right) -> left + right); // combiner: int + int
System.out.println("Total letters : " + totalLetters); // Output: Total letters : 18
}
}Read the three arguments as three separate jobs:
UT into a result of type UU results into oneWhy does a combiner exist at all? A parallel stream splits the data into chunks and reduces each chunk on its own thread. Each chunk produces a partial result, and something has to stitch those partials back together. That job belongs to the combiner.
| Overload | Returns | Empty stream gives | Can change type? | Typical use |
|---|---|---|---|---|
reduce(identity, accumulator) |
T |
the identity | No | Sum, product, boolean AND |
reduce(accumulator) |
Optional<T> |
Optional.empty |
No | Max, min, longest value |
reduce(identity, accumulator, combiner) |
U |
the identity | Yes | Element type differs from result type |
Ninety per cent of real code uses the first two. Reach for the third only when the types genuinely differ, and even then check whether a collector reads better.
Three words carry all the meaning in reduce. Let us pin each one down properly.
An identity is not just any starting value. It has to be the neutral value for your operation. Feed it to the accumulator alongside any element, and that element must come back untouched.
Write it as a rule and it reads like this: accumulator.apply(identity, x) must equal x for every possible x.
| Operation | Correct identity | Why |
|---|---|---|
| Addition | 0 |
0 + x equals x |
| Multiplication | 1 |
1 * x equals x |
| String concatenation | "" |
"" + x equals x |
| Maximum | Integer.MIN_VALUE |
nothing is smaller |
| Minimum | Integer.MAX_VALUE |
nothing is larger |
| Boolean AND | true |
true && x equals x |
Break this rule and a sequential stream still gives a wrong-but-stable answer. A parallel stream gives a wrong answer that changes between runs, which is far nastier to debug.
Associative sounds like exam vocabulary, but the idea is plain. Grouping the operands differently must not change the answer.
Addition passes the test. (1 + 2) + 3 and 1 + (2 + 3) both give 6. Multiplication passes too.
Subtraction fails badly. (10 - 3) - 2 gives 5, while 10 - (3 - 2) gives 9. Division fails for the same reason.
Why should you care? A parallel stream chooses its own grouping. It splits wherever the fork-join framework decides to split, and no two runs have to agree. An associative accumulator makes the grouping irrelevant.
One more requirement rounds this out: the accumulator must be stateless. It should read its two arguments and nothing else. Reaching outside for a counter or a list turns a clean reduction into a race condition.
Here is a detail that surprises people. On a sequential stream, Java may never call your combiner at all.
List<String> names = Arrays.asList("Amit", "Sara", "Vikram");
// Sequential: the combiner is not needed, so it never runs
int letters = names.stream()
.reduce(0,
(total, name) -> total + name.length(),
(left, right) -> {
System.out.println("combiner called");
return left + right;
});
System.out.println(letters); // Output: 14
// Nothing prints from the combiner on a sequential streamSwitch stream() to parallelStream() and the message appears. There is only one partial result in the sequential case, so there is nothing to merge.
This is exactly why a broken combiner can hide for months. Your tests run sequentially, everything passes, and the bug only wakes up the day someone adds .parallel().
Both the accumulator and the combiner take a BinaryOperator, so it helps to know what that is.
BinaryOperator<T> lives in java.util.function. It takes two arguments of type T and returns a T. That is the whole interface. It is really just a BiFunction where all three types collapse into one.
package com.javahandson.reduce;
import java.util.function.BinaryOperator;
public class BinaryOperatorDemo {
public static void main(String[] args) {
BinaryOperator<Integer> add = (a, b) -> a + b;
System.out.println(add.apply(5, 10)); // Output: 15
// Method references work anywhere a BinaryOperator fits
BinaryOperator<Integer> max = Integer::max;
System.out.println(max.apply(5, 10)); // Output: 10
}
}When you pass a lambda to reduce, you are implementing apply. Java calls it for you, once per element. You never call it yourself.
If functional interfaces are still fuzzy, our article on predefined functional interfaces walks through the whole family.
Theory is done. These are the reductions you will actually write at work.
List<Integer> numbers = Arrays.asList(2, 4, 6, 8);
int sum = numbers.stream().reduce(0, Integer::sum);
System.out.println("Sum : " + sum); // Output: Sum : 20
int product = numbers.stream().reduce(1, (a, b) -> a * b);
System.out.println("Product : " + product); // Output: Product : 384Integer::sum reads better than (a, b) -> a + b and does exactly the same work. Our guide to method references in Java 8 covers the four forms in detail.
Max and min have no sensible answer for an empty stream, so the Optional overload fits perfectly.
package com.javahandson.reduce;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
public class MaxMin {
public static void main(String[] args) {
List<Integer> numbers = Arrays.asList(4, 2, 5, 1, 3);
Optional<Integer> max = numbers.stream().reduce((a, b) -> a > b ? a : b);
max.ifPresent(n -> System.out.println("Max : " + n)); // Output: Max : 5
// The same thing, but easier to read
Optional<Integer> maxRef = numbers.stream().reduce(Integer::max);
System.out.println(maxRef.orElse(-1)); // Output: 5
Optional<Integer> min = numbers.stream().reduce(Integer::min);
System.out.println(min.orElse(-1)); // Output: 1
}
}Streams already ship max(Comparator) and min(Comparator), and those read better still. Use reduce here when your comparison logic does not fit a comparator neatly.
Reductions are not restricted to numbers. Any rule that turns two values into one will do.
List<String> words = Arrays.asList("stream", "reduce", "accumulator", "identity");
Optional<String> longest = words.stream()
.reduce((a, b) -> a.length() >= b.length() ? a : b);
System.out.println(longest.orElse("none")); // Output: accumulatorNotice the >= rather than >. That tiny choice decides which word wins a tie: the earlier one here, the later one if you drop the equals sign.
You can join strings with reduce, and plenty of tutorials show it.
List<String> words = Arrays.asList("Java", "8", "reduce");
// Works, but creates a brand-new String on every single step
String joined = words.stream().reduce("", (a, b) -> a + " " + b);
System.out.println(joined.trim()); // Output: Java 8 reduce
// Better: one buffer, no throwaway objects
String better = words.stream().collect(Collectors.joining(" "));
System.out.println(better); // Output: Java 8 reduceStrings in Java are immutable. Every a + " " + b allocates a fresh string and copies both halves into it. For ten words nobody notices. For a hundred thousand rows the cost grows with the square of the input, and your log-formatting job crawls.
Prefer Collectors.joining for text. It keeps one buffer and appends into it.
Sometimes the thing you want back is a domain object. Merging shopping carts is a nice example.
package com.javahandson.reduce;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
class Cart {
final String owner;
final int items;
Cart(String owner, int items) {
this.owner = owner;
this.items = items;
}
// Returns a NEW cart. Nothing is mutated, so this is safe in parallel.
Cart merge(Cart other) {
return new Cart(this.owner + "+" + other.owner, this.items + other.items);
}
}
public class MergeCarts {
public static void main(String[] args) {
List<Cart> carts = Arrays.asList(
new Cart("Amit", 3),
new Cart("Sara", 5),
new Cart("Neha", 2));
Optional<Cart> merged = carts.stream().reduce(Cart::merge);
merged.ifPresent(c -> System.out.println(c.owner + " : " + c.items));
// Output: Amit+Sara+Neha : 10
}
}The key detail sits inside merge. It builds a new Cart instead of editing either input. That single habit is what keeps a reduction correct when threads get involved.
IntStream, LongStream and DoubleStream each declare their own reduce. The shape matches what you already know, minus the boxing.
int reduce(int identity, IntBinaryOperator op) OptionalInt reduce(IntBinaryOperator op)
Same two shapes as before. The wrapper changes from Optional<Integer> to OptionalInt, and no Integer objects get created along the way.
package com.javahandson.reduce;
import java.util.OptionalInt;
import java.util.stream.IntStream;
public class PrimitiveReduce {
public static void main(String[] args) {
int sum = IntStream.rangeClosed(1, 5).reduce(0, Integer::sum);
System.out.println("Sum : " + sum); // Output: Sum : 15
OptionalInt max = IntStream.of(4, 2, 5, 1).reduce(Integer::max);
System.out.println(max.getAsInt()); // Output: 5
// 5! computed as a reduction
int factorial = IntStream.rangeClosed(1, 5).reduce(1, (a, b) -> a * b);
System.out.println("5! = " + factorial); // Output: 5! = 120
}
}There is no three-argument overload here. A primitive stream never changes its element type mid-flight, so a combiner would have nothing extra to do.
Before you write reduce on a primitive stream, check whether the answer already has a name.
sum() returns an int, long or double directlymax() and min() return OptionalInt, OptionalLong or OptionalDoubleaverage() always returns an OptionalDoublecount() returns a long and needs no reduction at allsummaryStatistics() hands you count, sum, min, max and average in one passIntSummaryStatistics stats = IntStream.of(4, 2, 5, 1).summaryStatistics(); System.out.println(stats.getSum()); // Output: 12 System.out.println(stats.getMax()); // Output: 5 System.out.println(stats.getAverage()); // Output: 3.0
Handwritten reductions are for the cases the library did not anticipate. When a shortcut exists, the shortcut wins on readability every time.
This comparison comes up in almost every stream interview, and the answer is short once you see the difference.
reduce performs an immutable reduction. Each step creates a fresh result and throws the old one away. Adding two integers produces a third integer, and nobody edits anything.
collect performs a mutable reduction. It creates one container up front, then pours elements into it. Adding to a list changes that same list in place.
Watch what happens when you force reduce to build a list.
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9);
// Painful, and it copies the whole list on every element
List<Integer> evens = numbers.stream()
.filter(n -> n % 2 == 0)
.reduce(new ArrayList<Integer>(),
(list, n) -> {
List<Integer> copy = new ArrayList<>(list);
copy.add(n);
return copy;
},
(a, b) -> {
List<Integer> copy = new ArrayList<>(a);
copy.addAll(b);
return copy;
});
System.out.println(evens); // Output: [2, 4, 6, 8]That code is correct, and it is awful. Every element triggers a full copy of the list built so far, so nine elements cost you nine allocations and a growing pile of garbage.
// One line, one container, no copying
List<Integer> evens = numbers.stream()
.filter(n -> n % 2 == 0)
.collect(Collectors.toList());
System.out.println(evens); // Output: [2, 4, 6, 8]Same answer, a fraction of the work, and it stays safe on a parallel stream because each thread fills its own container first.
| Aspect | reduce |
collect |
|---|---|---|
| Kind of reduction | Immutable | Mutable |
| Result each step | A brand-new value | The same container, updated |
| Best for | Single values: sum, max, merged object | Containers: List, Set, Map, String |
| Cost of building a list | Copies on every element | Appends in place |
| Parallel behaviour | Merges partial values | Merges partial containers |
| Typical call | reduce(0, Integer::sum) |
collect(Collectors.toList()) |
Ask yourself one question: is the answer a single value or a container?
reduceList, Set, Map or joined String → reach for collectcollect, never reduceThe Collectors class covers the container side in full, and grouping in Java 8 shows what downstream collectors can do.
A well-written reduce parallelises beautifully. A careless one produces answers that change from run to run. Here is what separates the two.
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5); // 1 is NOT the identity for addition int sequential = numbers.stream().reduce(1, Integer::sum); System.out.println(sequential); // Output: 16 (15 + one stray 1) int parallel = numbers.parallelStream().reduce(1, Integer::sum); System.out.println(parallel); // Output: 19 or 20, and it can vary
Sequentially the bad identity slips in once, so you are off by exactly one. That is wrong, but at least it is predictable.
In parallel, each chunk starts from the identity. Five chunks mean five stray ones. The fork-join framework picks the chunk count based on the data size and the machine, so the error itself moves around.
List<Integer> numbers = Arrays.asList(10, 3, 2, 1); int sequential = numbers.stream().reduce(0, (a, b) -> a - b); System.out.println(sequential); // Output: -16 int parallel = numbers.parallelStream().reduce(0, (a, b) -> a - b); System.out.println(parallel); // Output: 6, and it depends on how the data splits
The sequential run computes ((((0-10)-3)-2)-1). A parallel run might compute (0-10)-3 and (0-2)-1 separately, then subtract one partial from the other. Different grouping, different answer.
Test your accumulator with pen and paper before you parallelise. Regroup the operands by hand and check the result stays the same.
The classic disaster looks harmless in a code review.
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9);
List<Integer> evens = new ArrayList<>(); // shared, mutable, unsynchronised
numbers.parallelStream()
.filter(n -> n % 2 == 0)
.forEach(n -> evens.add(n)); // several threads write at once
System.out.println(evens);
// Output: [6, 4, 2, 8] most runs
// Output: [6, 2, 8] occasionally, because ArrayList is not thread-safeNothing throws. Nothing logs a warning. One day an element simply goes missing, and you spend an afternoon staring at a stack trace that never appears.
Mutability on its own is fine. Shared mutability across threads is the problem. Let reduce or collect merge the partial results and the whole class of bug disappears. Our article on parallel streams in Java 8 goes deeper into when parallel execution actually pays off.
Seven traps, each one seen in real code reviews.
The one-argument reduce returns an Optional, not a value. Printing it directly gives you Optional[15] in your log file, which looks odd to everyone reading it.
// Prints the wrapper, not the number System.out.println(numbers.stream().reduce(Integer::sum)); // Output: Optional[15] // Unwrap it properly System.out.println(numbers.stream().reduce(Integer::sum).orElse(0)); // Output: 15
Avoid get() without a check. It throws NoSuchElementException on an empty stream, which is exactly the case the Optional existed to warn you about.
Zero for multiplication is the classic. Every product collapses to zero and the bug looks like bad data rather than bad code.
int wrong = numbers.stream().reduce(0, (a, b) -> a * b); System.out.println(wrong); // Output: 0 int right = numbers.stream().reduce(1, (a, b) -> a * b); System.out.println(right); // Output: 120
We covered the cost in section 5.4. To repeat the short version: use Collectors.joining for text and keep reduce for values.
int[] callCount = {0};
// The lambda touches something outside itself. Never do this.
int sum = numbers.parallelStream().reduce(0, (a, b) -> {
callCount[0]++;
return a + b;
});Your accumulator should look at its two arguments and nothing else. External counters, caches and loggers all break under parallel execution.
Java already named the common reductions. Use the name.
| Handwritten reduce | Better |
|---|---|
reduce(0, (a, b) -> a + 1) |
count() |
reduce(Integer::max) |
max(Comparator.naturalOrder()) |
reduce("", (a, b) -> a + b) |
collect(Collectors.joining()) |
mapToInt(x -> x).reduce(0, Integer::sum) |
mapToInt(x -> x).sum() |
A null element reaches your accumulator like any other value, and the first method call on it throws. Filter early.
List<String> words = Arrays.asList("Java", null, "reduce");
// Throws NullPointerException on the null element
// words.stream().reduce((a, b) -> a + b);
String safe = words.stream()
.filter(Objects::nonNull)
.reduce("", (a, b) -> a + b);
System.out.println(safe); // Output: Javareducereduce is terminal, so the stream closes behind it. Calling a second operation on the same stream object throws IllegalStateException.
Stream<Integer> stream = numbers.stream(); int sum = stream.reduce(0, Integer::sum); // int product = stream.reduce(1, (a, b) -> a * b); // Throws: java.lang.IllegalStateException: stream has already been operated upon or closed // Build a fresh stream instead int product = numbers.stream().reduce(1, (a, b) -> a * b);
Let us pull the pieces together into one program you could paste into an IDE right now.
Four orders from an online store. Each one carries a customer, an item, a quantity and a unit price.
class Order {
final String customer;
final String item;
final int quantity;
final double unitPrice;
Order(String customer, String item, int quantity, double unitPrice) {
this.customer = customer;
this.item = item;
this.quantity = quantity;
this.unitPrice = unitPrice;
}
double total() {
return quantity * unitPrice;
}
}Four questions, four reductions, each one using a different overload.
package com.javahandson.reduce;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
public class OrderReport {
public static void main(String[] args) {
List<Order> orders = Arrays.asList(
new Order("Amit", "Keyboard", 2, 40.0),
new Order("Sara", "Monitor", 1, 180.0),
new Order("Neha", "Mouse", 3, 15.0),
new Order("Vikram", "Laptop", 1, 900.0));
// 1. Total revenue - identity overload, 0.0 is neutral for addition
double revenue = orders.stream()
.map(Order::total)
.reduce(0.0, Double::sum);
// 2. Biggest single order - Optional overload, no answer if empty
Optional<Order> biggest = orders.stream()
.reduce((a, b) -> a.total() >= b.total() ? a : b);
// 3. Total units sold - three-argument overload, Order in, int out
int units = orders.stream()
.reduce(0,
(running, order) -> running + order.quantity,
Integer::sum);
// 4. Item list - collect, because the answer is a container
String items = orders.stream()
.map(order -> order.item)
.collect(Collectors.joining(", "));
System.out.println("Revenue : " + revenue);
System.out.println("Units sold : " + units);
System.out.println("Items : " + items);
biggest.ifPresent(o ->
System.out.println("Biggest : " + o.customer + " (" + o.total() + ")"));
}
}Output: Revenue : 1205.0 Units sold : 7 Items : Keyboard, Monitor, Mouse, Laptop Biggest : Vikram (900.0)
double first, then folds with 0.0 as the identity. Empty order book, zero revenue — a sensible answer.Order objects in play. With no orders there is no biggest, so Optional is honest about it.Order objects, the result is an int, and the combiner merges partial counts.reduce. The answer is joined text, which is collect territory.Change the list to orders.parallelStream() and every number stays the same. That is the payoff for associative accumulators and honest identities.
A: It is a terminal operation that folds all the elements of a stream into a single result. You give it a rule for combining two values, and it applies that rule repeatedly until one value remains.
A: The first takes an identity and an accumulator and returns T. Version two takes only an accumulator and returns Optional<T>. The last one takes an identity, an accumulator and a combiner, and it can return a type U that differs from the element type.
A: Without an identity there is no answer for an empty stream. Java wraps the result in an Optional rather than returning null, so you have to decide what an empty stream should mean.
A: It is the neutral starting value for your operation. Combining it with any element must give that element back unchanged. Zero works for addition, one works for multiplication, and an empty string works for concatenation.
A: Only when there are partial results to merge, which in practice means parallel streams. A sequential stream produces one partial result, so Java skips the combiner entirely.
A: reduce performs an immutable reduction and creates a new result at every step, which suits single values. collect performs a mutable reduction and fills one container in place, which suits lists, sets, maps and joined strings.
A: It is terminal. The pipeline executes as soon as you call reduce, and the stream closes afterwards. Any further operation on that same stream throws IllegalStateException.
A: Two causes explain almost every case. Either your identity is not truly neutral, so each chunk injects a stray value, or your accumulator is not associative and the answer depends on how the data splits.
A: Yes, but only through the three-argument overload. The accumulator folds an element of type T into a result of type U, and the combiner merges two U values.
A: It works, but it allocates a new String at every step because Strings are immutable. Collectors.joining appends into a single buffer, so prefer that for anything larger than a handful of values.
A: It must be associative, so regrouping the operands never changes the answer. It must also be stateless, meaning it reads only its two arguments and never touches a variable outside the lambda.
Let us wrap up what we covered. The reduce method in Java 8 streams turns many values into one, and it does so with a starting value and a rule for combining two things.
Three overloads cover every case. The identity version always returns a value. Drop that identity and you get an Optional back, because an empty stream has no answer. The three-argument version lets the result type differ from the element type and supplies a combiner for parallel work.
Two properties keep a reduction honest: a truly neutral identity and an associative, stateless accumulator. Ignore either one and a parallel stream will happily give you a different answer on every run.
We also drew the line against collect. Single values belong to reduce, containers belong to collect, and forcing reduce to build a list copies the whole thing on every element.
Open your IDE and try the order report. Add an order, flip the stream to parallel, then deliberately break the identity and watch the total drift. Nothing teaches these rules faster than seeing them fail.