Java 21 Streams and Lambdas: Collectors, Reduction and Optional
Introduction
Java 21 streams and lambdas turn collections into typed pipelines for filtering, grouping and reduction. This 1Z0-830 lesson explains functional interfaces, method references, collectors and Optional, with examples that distinguish API guarantees from implementation details.
You will check types first, follow encounter order where it is defined, and choose reductions that remain valid when work is partitioned.
Learning objectives
After this lesson you will be able to:
- Choose the right functional interface (
Function,Predicate,Supplier,Consumer,UnaryOperator,BinaryOperatorand their primitive andBivariants) for a given lambda. - Write lambda expressions and the four kinds of method references, and apply the rule on captured variables.
- Build stream pipelines from sources, intermediate operations and a terminal operation, and predict when each stage runs.
- Use
reduce,collect,Collectors.groupingBy,partitioningBy,toMap,joining,mapping,countingandteeing. - Use
IntStream,LongStreamandDoubleStream, includingrange,summaryStatisticsandboxed. - Explain what changes (and what does not) with
parallel(). - Use
Optionalcorrectly and avoid its misuses.
1. Functional interfaces
A functional interface is not declared sealed and has one effective abstract function. Compatible inherited abstract declarations can describe the same function; public Object method signatures, defaults, statics and private methods do not add abstract functions. A lambda or a method reference can be assigned to any functional interface whose method has a compatible shape. The annotation @FunctionalInterface is optional but makes the compiler check the rule.
The core interfaces live in java.util.function:
| Interface | Method | Shape | Typical use |
|---|---|---|---|
Function<T,R> |
R apply(T) |
one in, one out | map |
BiFunction<T,U,R> |
R apply(T,U) |
two in, one out | Map.merge-like logic |
UnaryOperator<T> |
T apply(T) |
Function<T,T> |
replaceAll |
BinaryOperator<T> |
T apply(T,T) |
BiFunction<T,T,T> |
reduce |
Predicate<T> |
boolean test(T) |
filter condition | filter, removeIf |
Consumer<T> |
void accept(T) |
side effect | forEach |
Supplier<T> |
T get() |
no input | orElseGet, Stream.generate |
Runnable |
void run() |
no input, no output | threads |
Callable<V> |
V call() throws Exception |
no input, one output | executors |
Primitive variants avoid boxing: IntPredicate, IntFunction<R>, ToIntFunction<T>, IntUnaryOperator, IntBinaryOperator, IntSupplier, ToDoubleFunction<T>, and so on. The naming pattern is regular: To + primitive means the result is primitive (ToIntFunction<T> returns int); a primitive prefix means the input is primitive (IntFunction<R> takes an int).
Several interfaces have useful default methods:
Function<String, Integer> parse = Integer::parseInt;
parse.andThen(x -> x + 1).apply("41"); // 42: parse, then add
parse.compose((String s) -> s.trim()).apply(" 7 "); // 7: trim, then parse
Predicate<String> empty = String::isEmpty;
empty.negate().and(s -> s.length() < 3).test("ab"); // true
Predicate.not(empty).test(""); // falseandThen runs the first function, then the argument; compose runs the argument first. This pair is a classic source of wrong answers.
2. Lambda expressions
A lambda is a compact implementation of a functional interface. Its syntax variants are:
Comparator<String> c1 = (String a, String b) -> a.length() - b.length(); // explicit types
Comparator<String> c2 = (a, b) -> a.length() - b.length(); // inferred types
Function<Integer, Integer> f = x -> x * x; // one parameter: no parentheses needed
Supplier<Double> s = () -> Math.random(); // no parameter: parentheses required
Runnable r = () -> { System.out.println("run"); }; // block bodyRules the exam checks:
- Parameters are either all typed, all untyped, or all
var. You cannot mix(String a, b). - An expression body supplies a value for a value-returning target. A void target can instead accept a statement expression such as a method invocation. A value-returning block must return a compatible value on every normally returning path; a block that always throws is also valid.
- A lambda has no type by itself:
var x = () -> 1;andObject o = () -> 1;do not compile because there is no target functional interface. - Lambda parameters cannot reuse the name of a local variable already in scope:
int n = 1; Function<Integer,Integer> f = n -> n;fails. - Inside a lambda
thisrefers to the enclosing instance, unlike an anonymous class where it refers to the anonymous object.
Captured variables must be effectively final
A lambda may use local variables of the enclosing method only if they are final or effectively final (never reassigned after initialization). Instance and static fields have no such restriction, and a lambda may modify the content of an object reached through a final reference.
int base = 10;
Supplier<Integer> ok = () -> base + 1; // fine: base never changes
// base++; // would make the line above fail to compile
int[] counter = {0};
Runnable r = () -> counter[0]++; // allowed: the reference is not reassigned
r.run(); r.run(); // counter[0] == 2Reassigning the captured local anywhere in its scope, including after the lambda, can invalidate effective finality. Also remember that a lambda can throw a checked exception only if the functional method declares it (Callable.call does; Runnable.run and Function.apply do not).
3. Method references
A method reference is shorthand for a lambda that only calls a method. There are four kinds:
| Kind | Syntax | Equivalent lambda |
|---|---|---|
| Static method | Integer::parseInt |
s -> Integer.parseInt(s) |
| Bound instance (a specific object) | System.out::println |
x -> System.out.println(x) |
| Unbound instance (any object of a type) | String::toUpperCase |
s -> s.toUpperCase() |
| Constructor | ArrayList::new |
() -> new ArrayList<>() |
The bound receiver is evaluated once when the method reference is created; a lambda that reads a field on each invocation can behave differently. A null bound receiver fails when creating the reference.
In the unbound form, with BiFunction<String,Integer,Character> cb = String::charAt; the first argument becomes the receiver, so the call is s.charAt(i). The compiler picks the overload that fits the target interface: Supplier<List<String>> sup = ArrayList::new calls the no-argument constructor, whereas Function<Integer,List<String>> f = ArrayList::new calls ArrayList(int capacity).
4. Anatomy of a stream pipeline
A stream is not a data structure; it is a one-shot pipeline over a source. Every pipeline has three parts:
- A source:
collection.stream(),Arrays.stream(array),Stream.of(...),Stream.iterate(...),Stream.generate(...),IntStream.range(...),Files.lines(path). - Zero or more intermediate operations that return a stream, possibly the same instance:
filter,map,flatMap,distinct,sorted,peek,limit,skip,takeWhile,dropWhile,mapMulti. - Exactly one terminal operation, which starts the work:
forEach,collect,reduce,count,min,max,findFirst,findAny,anyMatch,allMatch,noneMatch,toList,toArray.
Three properties explain most exam questions.
Laziness. Intermediate operations do nothing until a terminal operation runs. A pipeline without a terminal operation never executes, so list.stream().peek(System.out::println); prints nothing.
Element-by-element processing. Elements flow through the whole pipeline one at a time (except at barriers such as sorted, which must see all elements). With short-circuiting terminal operations the pipeline stops early:
Stream.of("a", "b", "c")
.filter(x -> !x.equals("a"))
.findFirst()
.ifPresent(System.out::println);
// bThe first matching element in encounter order is b. The result is guaranteed; the exact number and timing of intermediate callbacks are not. A peek callback is useful for diagnostics, but may be omitted by optimizations and must not carry required business logic.
Single use. A stream can be consumed only once. Reusing or branching a pipeline violates its contract. The implementation can throw IllegalStateException when it detects reuse. Do not infer that reuse is legal if detection is absent:
Stream<Integer> st = Stream.of(1, 2, 3);
st.map(x -> x);
st.forEach(x -> { }); // IllegalStateExceptionBehavioral parameters generally need to be non-interfering and stateless in sequential and parallel pipelines. Close I/O-backed streams such as Files.lines with try-with-resources.
5. Common intermediate operations
Stream.of(1, 2, 3, 4).skip(1).limit(2).toList(); // [2, 3]
Stream.of("a", "b", "a").distinct().count(); // 2
Stream.of(1, 2, 3, 4, 5, 6).takeWhile(x -> x < 4).toList(); // [1, 2, 3]
Stream.of(1, 2, 3, 4, 1).dropWhile(x -> x < 3).toList(); // [3, 4, 1]
Stream.iterate(1, x -> x * 2).limit(6).toList(); // [1, 2, 4, 8, 16, 32]
Stream.iterate(1, x -> x < 50, x -> x * 3).toList(); // [1, 3, 9, 27]
Stream.of("bb", "a", "ccc").sorted(Comparator.comparing(String::length).reversed()).toList(); // [ccc, bb, a]takeWhile stops at the first failure and ignores the rest, while dropWhile discards the leading matches and keeps everything after, including later elements that would match. The two-argument iterate and generate create unbounded streams; predicate-based iterate can terminate. A downstream short-circuit only helps when it can actually obtain the needed result. For example, sorting an unbounded source before limiting cannot finish.
map versus flatMap. map transforms each element into one element. flatMap transforms each element into a stream and concatenates the results, which is how you flatten nested lists:
Stream.of(List.of(1, 2), List.of(3), List.<Integer>of())
.flatMap(List::stream)
.map(x -> x * 10)
.toList(); // [10, 20, 30]mapMulti (Java 16) is an alternative to flatMap that pushes zero or more outputs to a consumer without creating small streams. Stream.toList returns an unmodifiable list. Collectors.toList promises neither mutability nor a concrete type. Request a mutable ArrayList explicitly with Collectors.toCollection(ArrayList::new). See Java 21 collection views and structural copies for the related collection contracts.
6. Terminal operations and reduction
count, findFirst, anyMatch and friends are straightforward, but their edge cases matter:
anyMatchon an empty stream isfalse;allMatchandnoneMatchon an empty stream aretrue.findFirstandfindAnyreturn anOptional;minandmaxtake aComparatoron object streams.forEachorder is not guaranteed on a parallel stream;forEachOrderedkeeps the encounter order.
reduce combines elements into one value. It has three forms:
Stream.of(1, 2, 3, 4).reduce(Integer::sum); // Optional[10]: no identity, so possibly empty
Stream.of(1, 2, 3, 4).reduce(0, Integer::sum); // 10: zero is the identity for addition
Stream.of("a", "bb").reduce(0, (acc, s) -> acc + s.length(), Integer::sum); // 3: accumulator and combinerWithout an identity the result is an Optional because the stream may be empty. With an identity the result is the identity for an empty stream. The three-argument form is needed when the result type differs from the element type; the combiner merges partial results in parallel runs. These contracts apply even to sequential calls. The two-argument reduction needs an associative operator and a neutral identity (0 for addition, 1 for multiplication, an empty string for concatenation). In the three-argument form, the combiner must be associative, its identity neutral, and its operation compatible with accumulation. Add an offset after reducing; using 10 as the identity for addition violates the contract.
7. Collectors
collect(Collector) is the general mutable reduction. The Collectors factory methods you must know:
record Order(String customer, String city, double total, int items) { }
List<Order> orders = List.of(
new Order("Ana", "Paris", 120.0, 3), new Order("Bo", "Lyon", 80.0, 1),
new Order("Cy", "Paris", 200.0, 5), new Order("Di", "Lyon", 40.0, 2),
new Order("Ed", "Nice", 95.5, 4));
// revenue per city, sorted by city
Map<String, Double> revenue = orders.stream().collect(
Collectors.groupingBy(Order::city, () -> new TreeMap<>(), Collectors.summingDouble(Order::total)));
// {Lyon=120.0, Nice=95.5, Paris=320.0}
// two groups only: true and false are always both present
Map<Boolean, Long> bigOrSmall = orders.stream().collect(
Collectors.partitioningBy(o -> o.total() >= 100, Collectors.counting()));
// {false=3, true=2}
// customers per city, joined
Map<String, String> names = orders.stream().collect(
Collectors.groupingBy(Order::city, () -> new TreeMap<>(),
Collectors.mapping(Order::customer, Collectors.joining("/"))));
// {Lyon=Bo/Di, Nice=Ed, Paris=Ana/Cy}Key facts:
- groupingBy(classifier) returns a
Map<K, List<T>>without a promised concrete map type or key order. With a downstream collector the values become whatever that collector produces. The three-argument form takes a map factory, as above. partitioningBy(predicate)always has exactly two keys,falseandtrue, even if a group is empty.groupingByonly creates keys for groups that exist.counting()returnsLong;summingIntreturnsInteger;averagingIntreturnsDouble;summarizingIntreturns anIntSummaryStatistics.joining(delimiter, prefix, suffix)works only onCharSequenceelements.toMap(keyFn, valueFn)throwsIllegalStateExceptionon a duplicate key ("Duplicate key Paris (attempted merging values Ana and Cy)"). Provide a merge function as the third argument:toMap(String::length, Function.identity(), (x, y) -> x + y). It also throwsNullPointerExceptionon null values.teeing(c1, c2, merger)(Java 12) runs two collectors over the same elements and combines their results:Collectors.teeing(Collectors.counting(), Collectors.summingInt(x -> x), (n, sum) -> n + ":" + sum)gives4:10for1..4.collectingAndThen(collector, finisher)post-processes the result, for example wrapping it in an unmodifiable list.
8. Primitive streams
IntStream, LongStream and DoubleStream avoid boxing and add numeric terminal operations.
IntStream.range(0, 5).sum(); // 10 (end exclusive)
IntStream.rangeClosed(1, 5).reduce(1, (a, b) -> a * b); // 120 (end inclusive)
IntStream.of(1, 2).average(); // OptionalDouble[1.5]
IntStream.of().average(); // OptionalDouble.empty
orders.stream().mapToDouble(Order::total).summaryStatistics();
// DoubleSummaryStatistics{count=5, sum=535.500000, min=40.000000, average=107.100000, max=200.000000}Remember: sum on an empty numeric stream is zero. Numeric min and max return OptionalInt, OptionalLong or OptionalDouble according to the stream type; average returns OptionalDouble. Use getAsInt, getAsLong or getAsDouble rather than get. Convert between worlds with boxed(), mapToObj, mapToInt, asLongStream. Stream<Integer>.sum() does not exist; you must go through mapToInt(Integer::intValue).
9. Parallel streams
Parallel pipelines split input into partitions for concurrent evaluation. The API does not promise a particular worker count or pool. A valid reduction preserves the required result semantics; floating-point rounding, unordered operations and side effects need separate care.
forEachmay print in any order;forEachOrderedpreserves encounter order at some cost.- Built-in non-concurrent collectors such as toList and groupingBy use isolated accumulation containers that the stream framework combines. Their returned containers are not thereby thread-safe. Adding to a shared
ArrayListfromforEachis a data race and may lose elements or throw. - A reduction needs the required associative operations and neutral identity. Subtraction violates this contract, so sequential and parallel results are not guaranteed to agree.
- For an ordered stream, findFirst respects encounter order;
findAnymay return any element. - Parallel does not mean faster: small data, boxing,
LinkedListsources and blocking calls often make it slower.
For task lifecycle and shared-state rules beyond the pipeline itself, review Java executor lifecycle and thread safety.
10. Optional
Optional<T> is a container that holds a value or nothing, used as a return type to avoid null.
Optional<String> o = Optional.of("x");
o.map(String::toUpperCase).orElse("none"); // X
Optional.ofNullable((String) null).or(() -> Optional.of("alt")).get(); // alt
Optional.empty().isPresent(); // false
Optional.empty().orElseThrow(); // NoSuchElementException
Optional.of(null); // NullPointerException| Method | Behavior |
|---|---|
of(v) |
NullPointerException if v is null |
ofNullable(v) |
empty if v is null |
orElse(x) |
x is evaluated eagerly, always |
orElseGet(supplier) |
supplier runs only when empty |
orElseThrow() |
NoSuchElementException when empty (Java 10+) |
ifPresent, ifPresentOrElse |
side effect |
map, flatMap, filter, or |
Evaluate the relevant function immediately when presence or absence requires it; no lazy pipeline is stored |
Optional.map converts a null mapping result to empty; flatMap instead requires a non-null Optional result. These callbacks execute immediately when the value is present.
Prefer orElseGet when the default is expensive to build, because orElse(expensive()) always calls expensive(). Optional is primarily intended for return values. Prefer a clear domain model for fields and parameters rather than treating this design guidance as a language prohibition.
Worked example: order report
Statement. Using the orders list above, what does this print?
Map<Boolean, List<String>> m = orders.stream()
.filter(o -> o.items() >= 2)
.sorted(Comparator.comparingDouble(Order::total).reversed())
.collect(Collectors.partitioningBy(o -> o.city().equals("Paris"),
Collectors.mapping(Order::customer, Collectors.toList())));
System.out.println(new TreeMap<>(m));Step 1: filter. Orders with at least 2 items: Ana (3), Cy (5), Di (2), Ed (4). Bo (1) is dropped.
Step 2: sort. Totals are Ana 120.0, Cy 200.0, Di 40.0, Ed 95.5. Descending: Cy, Ana, Ed, Di.
Step 3: partition. The predicate tests the city. Paris orders: Cy, Ana (in sorted order). Others: Ed, Di.
Step 4: mapping. Each order becomes its customer name inside the list. Wrapping the result in TreeMap explicitly sorts Boolean keys as false then true; partitioningBy itself does not promise key iteration order.
Solution. {false=[Ed, Di], true=[Cy, Ana]}.
Why the other choices are worse. {true=[Ana, Cy], false=[Di, Ed]} ignores the descending sort of each partition’s values. A result containing Bo ignores the filter. A groupingBy(o -> o.city().equals("Paris")) would give the same lists here, but groupingBy would omit a key if no Paris order existed, whereas partitioningBy always contains both keys.
Second worked example: ordered selection and valid reduction
Statement. What does this code print?
List<Integer> chosen = Stream.of(5, 3, 8, 1)
.filter(x -> x > 2)
.limit(2)
.toList();
int total = chosen.stream().reduce(0, Integer::sum);
System.out.println(total + " " + chosen);Step 1. Filtering preserves the ordered source’s eligible values: 5, 3 and 8.
Step 2. limit(2) selects the first two eligible values, so chosen is [5, 3]. The result does not depend on logging from peek or on how many source elements are inspected.
Step 3. Zero is a neutral identity and integer addition is associative. The reduction gives 8.
Solution. The output is 8 [5, 3].
Why other choices fail. Summing all eligible elements would give 16 and ignore limit. Starting the reducer at 100 would violate its identity requirement. Subtraction is not an associative reducer, so do not assign it a portable result merely because one sequential implementation appears to give the expected value.
Common pitfalls and exam tips
- Exam tip: before computing output, find the terminal operation. If there is none, nothing runs. Then check for short-circuiting (
limit,findFirst,anyMatch) and for barriers (sorted,distincton ordered streams). - Reusing a stream violates its contract and may be detected with IllegalStateException. Use a supplier that creates a fresh stream for each traversal.
mapwith a method that returns a stream gives aStream<Stream<T>>; you wantedflatMap.Collectors.toMapfails on duplicates;groupingBydoes not.Stream.toList()is unmodifiable;Collectors.toList()is not guaranteed either way, so never rely on it.peekis for debugging; do not use it for logic, because it may not run (count()can skip the pipeline when the size is known in recent JDKs).- Do not modify the source collection while streaming; it can throw
ConcurrentModificationException. - Lambdas can use only effectively final locals. An increment such as
count++inside a lambda on a local variable is a compile error. Optional.orElse(x)evaluatesxeven when a value is present;orElseGetis lazy.IntStream.range(1, 5)is 1 to 4.rangeClosed(1, 5)is 1 to 5.
Mini case study: sales dashboard
Scenario. A shop wants three numbers from orders: the number of distinct cities, the name of the customer with the highest total (or "n/a" if there are no orders), and a comma-separated list of customers from orders above the average total, in alphabetical order. The first draft is:
long cities = orders.stream().map(Order::city).count();
String top = orders.stream().max(Comparator.comparingDouble(Order::total)).get().customer();
double avg = orders.stream().mapToDouble(Order::total).average().orElse(0);
String above = orders.stream().filter(o -> o.total() > avg)
.map(Order::customer).collect(Collectors.joining(","));Questions. Find the three defects and write the corrected code.
Solution.
count()aftermapcounts all five orders, not the distinct cities. Add.distinct(): the answer is3(Paris, Lyon, Nice).max(...).get()throwsNoSuchElementExceptionon an empty list. Use.map(Order::customer).orElse("n/a"). For the sample data the result isCy(total 200.0).- The average is
535.5 / 5 = 107.1, so the orders above it are Ana (120.0) and Cy (200.0), and the requirement says alphabetical order, which the draft only satisfies by luck of the list order. Add.sorted()aftermap: the result isAna,Cy.
long cities = orders.stream().map(Order::city).distinct().count();
String top = orders.stream().max(Comparator.comparingDouble(Order::total))
.map(Order::customer).orElse("n/a");
String above = orders.stream().filter(o -> o.total() > avg)
.map(Order::customer).sorted().collect(Collectors.joining(","));The variable avg is used inside a lambda, so it must stay effectively final: it is assigned once, which is why the draft compiles. Reassigning it later would break the build.
Key terms
- Functional interface: an interface not declared sealed with one effective abstract function; a target type for lambdas.
- Effectively final: a local variable never reassigned after initialization, the only kind a lambda can capture.
- Intermediate operation: a lazy pipeline stage that returns a stream.
- Terminal operation: the stage that triggers execution and consumes the stream.
- Short-circuiting: stopping the pipeline early (
limit,findFirst,anyMatch). - Collector: a mutable reduction strategy used with
collect. - Downstream collector: a collector applied to each group inside
groupingByorpartitioningBy. - Method reference: shorthand
Type::methodfor a lambda that only calls a method. - Optional: a container for a possibly absent result.
Summary checklist
- I can pick the functional interface for a lambda and know the primitive variants.
- I know
andThenruns first-then-argument andcomposeruns argument-first. - I know the four kinds of method reference and which argument is the receiver in the unbound form.
- I only capture effectively final locals in lambdas.
- I know streams are lazy, single-use and processed element by element.
- I can predict the output of
peek,limit,findFirstpipelines. - I know
reducewith and without identity, and why associativity matters. - I can use
groupingBy,partitioningBy,toMap,joining,teeing. - I unwrap
OptionalIntandOptionalDoublecorrectly and knowrangeversusrangeClosed. - I know what changes under
parallel()and which operations stay deterministic. - I know
Optional.of,ofNullable,orElse,orElseGetandorElseThrow.
Official sources
-
Oracle exam page 1Z0-830 (objectives): https://education.oracle.com/java-se-21-developer-professional/pexam_1Z0-830 (opens in a new tab)
-
Java SE 21 API,
java.util.stream: https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/stream/package-summary.html (opens in a new tab) -
Java SE 21 API,
java.util.function: https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/function/package-summary.html (opens in a new tab) -
Java Language Specification SE 21, lambda expressions (15.27): https://docs.oracle.com/javase/specs/jls/se21/html/jls-15.html#jls-15.27 (opens in a new tab)
-
Stream reduction and terminal operations: https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/stream/Stream.html (opens in a new tab)
-
Collector contracts: https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/stream/Collector.html (opens in a new tab)
-
Collectors result guarantees: https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/stream/Collectors.html (opens in a new tab)
-
Optional API: https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/Optional.html (opens in a new tab)
-
Functional interfaces: https://docs.oracle.com/javase/specs/jls/se21/html/jls-9.html#jls-9.8 (opens in a new tab)
