21. Java Optionals and Streams
Table of Contents
- 21.1 Optionals Optional OptionalInt OptionalLong OptionalDouble
- 21.2 What Is a Stream And What It Is Not
- 21.3 Stream Pipeline Architecture
- 21.4 Lazy Evaluation and Short-Circuiting
- 21.5 Stateless vs Stateful Operations
- 21.6 Stream Ordering and Determinism
- 21.7 Parallel Streams
- 21.8 Reduction Operations
- 21.9 Common Streams Pitfalls
- 21.10 Primitive Streams
- 21.11 Collectors collect Collector and the Collectors Factory Methods
21.1 Optionals (Optional, OptionalInt, OptionalLong, OptionalDouble)
Optional<T> is a container object that may or may not hold a non-null value.
It is designed to make “absence of a value” explicit and to reduce NullPointerException risk by forcing callers to handle the empty case.
Note
Optionalis intended primarily for return types.- It is generally discouraged for fields, method parameters, and serialization boundaries (unless a specific API contract requires it).
21.1.1 Creating Optionals
There are three core factory methods.
Optional.of(value)→ value must be non-null; otherwiseNullPointerExceptionis thrownOptional.ofNullable(value)→ returns empty if value is nullOptional.empty()→ an explicitly empty Optional
Optional<String> a = Optional.of("x");
Optional<String> b = Optional.ofNullable(null); // Optional.empty
Optional<String> c = Optional.empty();
21.1.2 Reading values safely
Optionals provide multiple ways to access the wrapped value.
isPresent()/isEmpty()→ test presenceget()→ returns the value or throwsNoSuchElementExceptionif empty (discouraged)orElse(defaultValue)→ returns value or default (default evaluated immediately)orElseGet(supplier)→ returns value or supplier result (supplier evaluated lazily)orElseThrow()→ returns value or throwsNoSuchElementExceptionorElseThrow(exceptionSupplier)→ returns value or throws custom exception
Optional<String> opt = Optional.of("java");
String v1 = opt.orElse("default");
String v2 = opt.orElseGet(() -> "computed");
String v3 = opt.orElseThrow(); // ok because opt is present
Note
- A common trap:
orElse(...)evaluates its argument even if the Optional is present. - Use
orElseGet(...)when the default is expensive to compute.
21.1.3 Transforming Optionals
Optionals support functional transformations similar to streams, but with “0 or 1 element” semantics.
map(fn)→ transforms the value if presentflatMap(fn)→ transforms to an Optional without nestingfilter(predicate)→ keeps value only if predicate is true
Optional<String> name = Optional.of("Alice");
Optional<Integer> len =
name.map(String::length); // Optional[5]
Optional<String> filtered =
name.filter(n -> n.startsWith("A")); // Optional[Alice]
System.out.println(len.orElse(0));
System.out.println(filtered.orElseGet(() -> "11"));
Output:
5
Alice
Note
mapwraps the result in an Optional.- If your mapping function already returns an Optional, use
flatMapto avoidOptional<Optional<T>>nesting.
21.1.4 Optionals and Streams
A very common pipeline pattern is to map to an Optional and then remove empties.
Since Java 9, Optional provides stream() to convert “present → one element” and “empty → zero elements”.
Stream<String> words = Stream.of("a", "bb", "ccc");
words.map(w -> w.length() > 1 ? Optional.of(w.length()) : Optional.<Integer>empty()).flatMap(Optional::stream) // rimuove gli elementi vuoti
.forEach(System.out::println);
Output:
2
3
Note
Before Java 9, this pattern required filter(Optional::isPresent) plus map(Optional::get).
21.1.5 Primitive Optionals
Primitive streams use primitive optionals to avoid boxing: OptionalInt, OptionalLong, OptionalDouble.
They mirror the main Optional API with primitive getters such as getAsInt().
OptionalInt.getAsInt()/OptionalLong.getAsLong()/OptionalDouble.getAsDouble()orElse(...)/orElseGet(...)/orElseThrow(...)
OptionalInt m = IntStream.of(3, 1, 2).min(); // OptionalInt[1]
int value = m.orElse(0); // 1
21.1.6 Common pitfalls
- Do not call
get()without checking presence; preferorElseThrowor transformations - Avoid returning
nullinstead ofOptional.empty(); an Optional reference itself should not be null - Remember:
average()on primitive streams always returnsOptionalDouble(even forIntStreamandLongStream) - Use
orElseGetwhen computing the default is expensive
21.2 What Is a Stream (And What It Is Not)
A Java Stream represents a sequence of elements supporting functional-style operations.
Streams are designed for data processing, not data storage.
Key characteristics:
- A stream does not store data
- A stream is lazy — nothing happens until a terminal operation is invoked
- A stream can be consumed only once
- Streams encourage side-effect-free operations
Note
Streams are conceptually similar to database queries: they describe what to compute, not how to iterate.
21.3 Stream Pipeline Architecture
Every stream pipeline consists of three distinct phases:
- 1️ Source
- 2️ Zero or more Intermediate Operations
- 3️ Exactly one Terminal Operation
21.3.1 Stream Sources
Common stream sources include:
- Collections:
collection.stream() - Arrays:
Arrays.stream(array) - I/O channels and files
- Infinite streams:
Stream.iterate,Stream.generate
List<String> names = List.of("Ana", "Bob", "Carla");
Stream<String> s = names.stream();
21.3.2 Intermediate Operations
Intermediate operations:
- Return a new stream
- Are lazy
- Do not trigger execution
21.3.2.1 Table of Common intermediate operations:
| Method | Common input Params | Return value | Desctiption |
|---|---|---|---|
filter |
Predicate | Stream<T> |
filter the stream according to a predicate match |
map |
Function | Stream<R> |
transform a stream through a one to one mapping input/output |
flatMap |
Function | Stream<R> |
flatten nested streams into a single stream |
sorted |
(none) or Comparator | Stream<T> |
sort by natural order or by the provided Comparator |
distinct |
(none) | Stream<T> |
remove duplicate elements |
limit / skip |
long | Stream<T> |
limit size or skip elements |
peek |
Consumer | Stream<T> |
run side-effect action for each element (debugging) |
- Example:
List<String> names = List.of("Ana", "Bob", "Carla", "Mario");
names.stream().filter(n -> n.length() > 3).map(String::toUpperCase).forEach(System.out::println);
Output:
CARLA
MARIO
Note
Intermediate operations only describe the computation. No element is processed yet.
21.3.3 Terminal Operations
Terminal operations:
- Trigger execution
- Consume the stream
- Produce a result or side effect
21.3.3.1 Table of terminal operations:
| Method | Return value | behaviour for infinite streams |
|---|---|---|
forEach |
void | does not terminate |
collect |
varies | does not terminate |
reduce |
varies | does not terminate |
findFirst / findAny |
Optional<T> |
terminates |
anyMatch / allMatch / noneMatch |
boolean | may terminate early (short-circuit) |
min / max |
Optional<T> |
does not terminate |
count |
long | does not terminate |
21.4 Lazy Evaluation and Short-Circuiting
var newNames = new ArrayList<String>();
newNames.add("Bob");
newNames.add("Dan");
// Streams are lazily evaluated: this does not traverse the data yet,
// it only creates a pipeline description bound to the source.
var stream = newNames.stream();
newNames.add("Erin");
// Terminal operation triggers evaluation. The stream sees the updated source,
// so the count includes "Erin".
stream.count(); // 3
Note
A stream is bound to its source (newNames), and the pipeline is not executed until a terminal operation is invoked.
For this reason, if you modify the collection before the terminal operation, the terminal operation “sees” the new elements (here, Erin).
In general, however, modifying the source while a stream pipeline is in use is bad practice and can lead to non-deterministic behavior (or ConcurrentModificationException with some sources/operations).
The practical rule is: build the source, then create and execute the stream without mutating it.
Streams process elements one at a time, flowing “vertically” through the pipeline rather than stage-by-stage.
Below we modify the example to use a short-circuiting terminal operation: findFirst().
Stream.of("a", "bb", "ccc")
.filter(s -> {
System.out.println("filter " + s);
return s.length() > 1;
})
.map(s -> {
System.out.println("map " + s);
return s.toUpperCase();
})
.findFirst()
.ifPresent(System.out::println);
Execution order:
Note
Only the minimum number of elements required by the terminal operation are processed.
filter a
filter bb
map bb
BB
findFirst() is satisfied as soon as it finds the first element that successfully passes through the pipeline (here "bb"), therefore:
"ccc"is never processed (neitherfilternormap);- lazy evaluation avoids unnecessary work compared to a terminal operation that consumes all elements (such as
forEachorcount).
Important
allMatch, noneMatch, anyMatch, findFirst, and findAny are short-circuiting terminal operations.
This means that the given predicate is not necessarily evaluated for every element of the stream.
The operation may stop as soon as the final result can already be determined.
For example, with allMatch, if the predicate returns false for the first element, the overall result of the operation is already known to be false. Since allMatch requires every element to satisfy the predicate, finding a single element that does not match is sufficient to determine the result.
Therefore, once such an element is encountered, the remaining elements of the stream do not need to be tested, and the stream processing stops immediately.
21.5 Stateless vs Stateful Operations
21.5.1 Stateless Operations
Operations like map and filter process each element independently.
21.5.2 Stateful Operations
Operations like distinct, sorted, and limit require maintaining internal state.
Note
Stateful operations can severely impact parallel stream performance.
21.6 Stream Ordering and Determinism
Streams may be:
- Ordered (e.g.,
List.stream()) - Unordered (e.g.,
HashSet.stream())
Some operations respect encounter order:
forEachOrderedfindFirst
Note
In parallel streams, forEach does not guarantee order.
21.7 Parallel Streams
Parallel streams divide work across threads using the ForkJoinPool.commonPool().
int sum =
IntStream.range(1, 1_000_000)
.parallel()
.sum();
Rules for safe parallel streams:
- No side effects
- No mutable shared state
- Associative operations only
Note
Parallel streams can be slower for small workloads.
21.8 Reduction Operations
21.8.1 reduce(): combining a stream into a single object
There are three method signatures for this operation:
- public
Optional<T>reduce(BinaryOperator<T> accumulator); - public
Treduce(T identity,BinaryOperator<T> accumulator); - public
<U> Ureduce(U identity,BiFunction<U, ? super T, U> accumulator,BinaryOperator<U> combiner)
int sum = Stream.of(1, 2, 3)
.reduce(0, Integer::sum);
Reduction requires:
- Identity: Initial value for each partial reduction; must be a neutral element; Example: 0 for sum, 1 for multiplication, empty collection for collecting;
- Accumulator: Incorporates one stream element into a partial result;
- (Optional) Combiner: Merges two partial results; Used only when the stream is parallel; Ignored for sequential streams
Note
The accumulator must be associative and stateless.
21.8.1.1 Correct mental model
- Accumulator: result + element
- Combiner: result + result
Example 1: Correct use (sum of lengths)
int totalLength =
Stream.of("a", "bb", "ccc")
.parallel()
.reduce(
0, // identity
(sum, s) -> sum + s.length(), // accumulator
(left, right) -> left + right // combiner
);
What happens in parallel
Suppose the stream is split:
- Thread 1: "a", "bb" → 0 + 1 + 2 = 3
- Thread 2: "ccc" → 0 + 3 = 3
Then the combiner merges the partial results:
3 + 3 = 6
Example 2: Combiner ignored in sequential streams
int result =
Stream.of("a", "bb", "ccc")
.reduce(
0,
(sum, s) -> sum + s.length(),
(x, y) -> {
throw new RuntimeException("Never called");
}
);
Example 3: Incorrect combiner
int result =
Stream.of(1, 2, 3, 4)
.parallel()
.reduce(
0,
(a, b) -> a - b, // accumulator
(x, y) -> x - y // combiner
);
Why this is wrong
Subtraction is not associative.
Possible execution:
- Thread 1: 0 - 1 - 2 = -3
- Thread 2: 0 - 3 - 4 = -7
Combiner:
-3 - (-7) = 4
Sequential result would be:
(((0 - 1) - 2) - 3) - 4 = -10
Warning
❌ Parallel and sequential results differ → illegal reduction
21.8.2 collect()
collect is a mutable reduction optimized for grouping and aggregation.
It is the Stream API’s standard tool for “mutable reduction”: you accumulate elements into a mutable container (like a List, Set, Map, StringBuilder, custom result object), and then optionally merge partial containers when running in parallel.
The general form is:
public <R> R **collect**(Supplier<R> supplier, BiConsumer<R, ? super T> accumulator, BiConsumer<R, R> combiner);
And a common version used is:
public <R, A> R **collect**(Collector<? super T, A, R> collector)
where Collectors.* provides prebuilt collectors (grouping, mapping, joining, counting, etc.).
Meaning:
- supplier: creates a new empty result container (e.g. new
ArrayList<>()) - accumulator: adds one element into that container (e.g.
list::add) - combiner: merges two containers (e.g.
list1.addAll(list2))
21.8.3 Why collect() is different from reduce()
- Intent: mutation vs immutability
reduce()is designed for immutable-style reduction: combine values into a new value (e.g. sum, min, max).collect()is designed for mutable containers: build up a List, Map, StringBuilder, etc.
- Correctness in parallel
reduce()requires the operation to be:- associative
- stateless
- compatible with identity/combiner rules
collect()is built to support parallelism safely by:- creating one container per thread (supplier)
- accumulating locally (accumulator)
- merging at the end (combiner)
-
Performance
collect()can be optimized because the stream runtime knows you are building containers:- it can avoid unnecessary copying
- it can pre-size or use specialized implementations (depending on collector)
- it’s the idiomatic and expected approach
- using reduce() to build a collection often creates extra objects or forces unsafe mutation.
-
Example: “collect into a List” the right way
List<String> longNames =
names.stream()
.filter(s -> s.length() > 3)
.collect(Collectors.toList());
- Example: groupingBy with explanation
Map<Integer, List<String>> byLength =
names.stream()
.collect(Collectors.groupingBy(String::length));
What happens conceptually:
- The collector creates an empty
Map<Integer, List<String>> - For each name:
- compute the key (String::length)
- put it in the correct bucket list
- In parallel:
- each thread builds its own partial maps
- the combiner merges maps by merging lists per key
21.9 Common Streams Pitfalls
- Reusing a consumed stream →
IllegalStateException - Modifying external variables inside lambdas
- Assuming execution order in parallel streams
- Using
peekfor logic instead of debugging
21.10 Primitive Streams
Java provides three specialized stream types to avoid boxing overhead and to enable numeric-focused operations:
IntStreamforintLongStreamforlongDoubleStreamfordouble
Primitive streams are still streams (lazy pipelines, intermediate + terminal operations, single-use), but they are not generic and they use primitive-specialized functional interfaces (e.g., IntPredicate, LongUnaryOperator, DoubleConsumer).
Note
Use primitive streams when the data is naturally numeric or when performance matters: they avoid boxing/unboxing overhead and provide additional numeric terminal operations.
21.10.1 Why primitive streams matter
- Performance: avoid allocating wrapper objects and repeated boxing/unboxing in large pipelines
- Convenience: built-in numeric reductions such as
sum(),average(),summaryStatistics() - Common traps: understanding when results are primitives vs
OptionalInt/OptionalLong/OptionalDouble
21.10.2 Common creation methods
The following are the most frequently used ways to create primitive streams. Many certification questions start by identifying the stream type created by a factory method.
| Sources |
|---|
| IntStream.of(int...) |
| IntStream.range(int startInclusive, int endExclusive) |
| IntStream.rangeClosed(int startInclusive, int endInclusive) |
| IntStream.iterate(int seed, IntUnaryOperator f) // infinite unless limited |
| IntStream.iterate(int seed, IntPredicate hasNext, IntUnaryOperator f) |
| IntStream.generate(IntSupplier s) // infinite unless limited |
| LongStream.of(long...) |
| LongStream.range(long startInclusive, long endExclusive) |
| LongStream.rangeClosed(long startInclusive, long endInclusive) |
| LongStream.iterate(long seed, LongUnaryOperator f) |
| LongStream.iterate(long seed, LongPredicate hasNext, LongUnaryOperator f) |
| LongStream.generate(LongSupplier s) |
| DoubleStream.of(double...) |
| DoubleStream.iterate(double seed, DoubleUnaryOperator f) |
| DoubleStream.iterate(double seed, DoublePredicate hasNext, DoubleUnaryOperator f) |
| DoubleStream.generate(DoubleSupplier s) |
Important
- Only
IntStreamandLongStreamproviderange()andrangeClosed(). - There is no
DoubleStream.rangebecause counting with doubles has rounding issues.
21.10.3 Primitive-specialized mapping methods
Primitive streams provide primitive-only mapping operations that avoid boxing:
IntStream.map(IntUnaryOperator)→IntStreamIntStream.mapToLong(IntToLongFunction)→LongStream-
IntStream.mapToDouble(IntToDoubleFunction)→DoubleStream -
LongStream.map(LongUnaryOperator)→LongStream LongStream.mapToInt(LongToIntFunction)→IntStream-
LongStream.mapToDouble(LongToDoubleFunction)→DoubleStream -
DoubleStream.map(DoubleUnaryOperator)→DoubleStream DoubleStream.mapToInt(DoubleToIntFunction)→IntStreamDoubleStream.mapToLong(DoubleToLongFunction)→LongStream
21.10.4 Mapping table among Stream<T> and primitive streams
This table summarizes the main conversions among object streams and primitive streams.
The “From” column tells you which methods are available and the resulting target stream type.
| From (source) | To (target) | Primary method(s) |
|---|---|---|
Stream<T> |
Stream<R> |
map(Function<? super T, ? extends R>) |
Stream<T> |
Stream<R> (flatten) |
flatMap(Function<? super T, ? extends Stream<? extends R>>) |
Stream<T> |
IntStream |
mapToInt(ToIntFunction<? super T>) |
Stream<T> |
LongStream |
mapToLong(ToLongFunction<? super T>) |
Stream<T> |
DoubleStream |
mapToDouble(ToDoubleFunction<? super T>) |
Stream<T> |
IntStream (flatten) |
flatMapToInt(Function<? super T, ? extends IntStream>) |
Stream<T> |
LongStream (flatten) |
flatMapToLong(Function<? super T, ? extends LongStream>) |
Stream<T> |
DoubleStream (flatten) |
flatMapToDouble(Function<? super T, ? extends DoubleStream>) |
IntStream |
Stream<Integer> |
boxed() |
LongStream |
Stream<Long> |
boxed() |
DoubleStream |
Stream<Double> |
boxed() |
IntStream |
Stream<U> |
mapToObj(IntFunction<? extends U>) |
LongStream |
Stream<U> |
mapToObj(LongFunction<? extends U>) |
DoubleStream |
Stream<U> |
mapToObj(DoubleFunction<? extends U>) |
IntStream |
LongStream |
asLongStream() |
IntStream |
DoubleStream |
asDoubleStream() |
LongStream |
DoubleStream |
asDoubleStream() |
Important
- There is no
unboxed()operation. - To go from wrappers to primitives you must start from
Stream<T>and usemapToInt/mapToLong/mapToDouble.
21.10.5 Terminal operations and their result types
Primitive streams have several terminal operations that are either unique or have primitive-specific return types.
| Terminal operation | IntStream returns | LongStream returns | DoubleStream returns |
|---|---|---|---|
count() |
long | long | long |
sum() |
int | long | double |
min() / max() |
OptionalInt | OptionalLong | OptionalDouble |
average() |
OptionalDouble | OptionalDouble | OptionalDouble |
findFirst() / findAny() |
OptionalInt | OptionalLong | OptionalDouble |
reduce(op) |
OptionalInt | OptionalLong | OptionalDouble |
reduce(identity, op) |
int | long | double |
summaryStatistics() |
IntSummaryStatistics | LongSummaryStatistics | DoubleSummaryStatistics |
Warning
- Even for
IntStreamandLongStream,average()returnsOptionalDouble(notOptionalIntorOptionalLong).
- Example 1:
Stream<String>→IntStream→ primitive terminal operations.
List<String> words = List.of("a", "bb", "ccc");
int totalLength = words.stream()
.mapToInt(String::length) // IntStream
.sum(); // int
// totalLength = 1 + 2 + 3 = 6
- Example 2:
IntStream→ boxedStream<Integer>(boxing introduced).
Stream<Integer> boxed = IntStream.rangeClosed(1, 3) // 1,2,3
.boxed(); // Stream<Integer>
- Example 3: primitive stream → object stream via
mapToObj.
Stream<String> labels = IntStream.range(1, 4) // 1,2,3
.mapToObj(i -> "N=" + i); // Stream<String>
21.10.6 Common pitfalls and gotchas
- Do not confuse
Stream<Integer>withIntStream: their mapping methods and functional interfaces differ IntStream.sum()returnsintbutIntStream.count()returnslongaverage()always returnsOptionalDoublefor all primitive stream types- Using
boxed()reintroduces boxing; only do it if the downstream API requires objects (e.g., collecting toList<Integer>) - Be careful with narrowing conversions:
LongStream.mapToIntandDoubleStream.mapToIntmay truncate values
21.11 Collectors (collect(), Collector, and the Collectors Factory Methods)
A Collector describes how to accumulate stream elements into a final result.
The collect(...) terminal operation executes this recipe.
The Collectors utility class provides ready-made collectors for common aggregation tasks.
21.11.1 collect() vs Collector
There are two main ways to collect:
collect(Collector)→ the common form usingCollectors.*collect(supplier, accumulator, combiner)→ explicit mutable reduction (lower-level)
List<String> list =
Stream.of("a", "b")
.collect(Collectors.toList());
StringBuilder sb =
Stream.of("a", "b")
.collect(StringBuilder::new, StringBuilder::append, StringBuilder::append);
Note
Use collect(supplier, accumulator, combiner) when you need a custom mutable container and do not want to implement a full Collector.
21.11.2 Core collectors (quick reference)
These are the most frequently used collectors and the ones most likely to appear in exam questions.
toList()→List<T>(no guarantees about mutability/implementation)toSet()→Set<T>toCollection(supplier)→ specific collection type (e.g.,TreeSet)joining(delim, prefix, suffix)→StringfromCharSequenceelementscounting()→LongcountsummingInt/summingLong/summingDouble→ numeric sumsaveragingInt/averagingLong/averagingDouble→ numeric averagesminBy(comparator)/maxBy(comparator)→Optional<T>mapping(mapper, downstream)→ transform then collect with downstreamfiltering(predicate, downstream)→ filter inside collector (Java 9+)
21.11.3 Grouping collectors
groupingBy classifies elements into buckets keyed by a classifier function.
It produces a Map<K, V> where V depends on the downstream collector.
Map<Integer, List<String>> byLen =
Stream.of("a", "bb", "ccc", "dd")
.collect(Collectors.groupingBy(String::length));
System.out.println("byLen: " + byLen.toString());
Output:
byLen: {1=[a], 2=[bb, dd], 3=[ccc]}
With a downstream collector you control what each bucket contains:
Map<Integer, Long> countByLen =
Stream.of("a", "bb", "ccc", "dd")
.collect(Collectors.groupingBy(String::length, Collectors.counting()));
System.out.println("countByLen: " + countByLen.toString());
Map<Integer, Set<String>> setByLen =
Stream.of("a", "bb", "ccc", "dd")
.collect(Collectors.groupingBy(String::length, Collectors.toSet()));
System.out.println("setByLen: " + setByLen.toString());
Output:
countByLen: {1=1, 2=2, 3=1}
setByLen: {1=[a], 2=[bb, dd], 3=[ccc]}
Warning
Pay attention to the resulting map value type. Example: groupingBy(..., counting()) yields Map<K, Long> (not int).
21.11.4 partitioningBy
partitioningBy splits the stream into exactly two groups using a boolean predicate. It always returns a map with keys true and false.
Map<Boolean, List<String>> parts =
Stream.of("a", "bb", "ccc")
.collect(Collectors.partitioningBy(s -> s.length() > 1));
System.out.println("parts: " + parts.toString());
Output:
parts: {false=[a], true=[bb, ccc]}
Note
partitioningBy always creates two buckets, while groupingBy can create many. Both support downstream collectors.
21.11.5 toMap and merge rules
toMap throws an exception on duplicate keys unless you provide a merge function.
Map<Integer, String> m1 =
Stream.of("aa", "bb")
.collect(Collectors.toMap(String::length, s -> s)); // ❌ Exception in thread "main" java.lang.IllegalStateException: Duplicate key 2 (attempted merging values aa and bb)
Map<Integer, String> m2 =
Stream.of("aa", "bb", "cc")
.collect(Collectors.toMap(String::length, s -> s, (oldV, newV) -> oldV + "," + newV)); // key=2 merges values
Output:
m2: {2=aa,bb,cc}
21.11.6 collectingAndThen
collectingAndThen(downstream, finisher) lets you apply a final transformation after collecting (e.g., make the list unmodifiable).
List<String> unmodifiable =
Stream.of("a", "b", "c")
.collect(Collectors.collectingAndThen(Collectors.toList(), List::copyOf));
21.11.7 How collectors relate to parallel streams
Collectors are designed to work with parallel streams by using supplier/accumulator/combiner internally. In parallel, each worker builds a partial result container and then merges containers.
- The accumulator mutates a per-thread container (no shared mutable state)
- The combiner merges containers (required for parallel execution)
- Some collectors are “concurrent” or have characteristics that affect performance and ordering
Note
prefer collect(Collectors.toList()) over using reduce to build collections. reduce is for immutable-style reductions; collect is for mutable containers.