Skip to content

Java 21 Streams, Lambdas and Collectors

Step 11 of 20

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, BinaryOperator and their primitive and Bi variants) 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, counting and teeing.
  • Use IntStream, LongStream and DoubleStream, including range, summaryStatistics and boxed.
  • Explain what changes (and what does not) with parallel().
  • Use Optional correctly 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:

Java
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("");                                // false

andThen 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:

Java
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 body

Rules 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; and Object 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 this refers 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.

Java
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] == 2

Reassigning 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:

  1. A source: collection.stream(), Arrays.stream(array), Stream.of(...), Stream.iterate(...), Stream.generate(...), IntStream.range(...), Files.lines(path).
  2. Zero or more intermediate operations that return a stream, possibly the same instance: filter, map, flatMap, distinct, sorted, peek, limit, skip, takeWhile, dropWhile, mapMulti.
  3. 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:

Java
Stream.of("a", "b", "c")
      .filter(x -> !x.equals("a"))
      .findFirst()
      .ifPresent(System.out::println);
// b

The 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:

Java
Stream<Integer> st = Stream.of(1, 2, 3);
st.map(x -> x);
st.forEach(x -> { });          // IllegalStateException

Behavioral 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

Java
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:

Java
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:

  • anyMatch on an empty stream is false; allMatch and noneMatch on an empty stream are true.
  • findFirst and findAny return an Optional; min and max take a Comparator on object streams.
  • forEach order is not guaranteed on a parallel stream; forEachOrdered keeps the encounter order.

reduce combines elements into one value. It has three forms:

Java
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 combiner

Without 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:

Java
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, false and true, even if a group is empty. groupingBy only creates keys for groups that exist.
  • counting() returns Long; summingInt returns Integer; averagingInt returns Double; summarizingInt returns an IntSummaryStatistics.
  • joining(delimiter, prefix, suffix) works only on CharSequence elements.
  • toMap(keyFn, valueFn) throws IllegalStateException on 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 throws NullPointerException on 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) gives 4:10 for 1..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.

Java
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.

  • forEach may print in any order; forEachOrdered preserves 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 ArrayList from forEach is 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; findAny may return any element.
  • Parallel does not mean faster: small data, boxing, LinkedList sources 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.

Java
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?

Java
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?

Java
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, distinct on 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.
  • map with a method that returns a stream gives a Stream<Stream<T>>; you wanted flatMap.
  • Collectors.toMap fails on duplicates; groupingBy does not.
  • Stream.toList() is unmodifiable; Collectors.toList() is not guaranteed either way, so never rely on it.
  • peek is 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) evaluates x even when a value is present; orElseGet is 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:

Java
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.

  1. count() after map counts all five orders, not the distinct cities. Add .distinct(): the answer is 3 (Paris, Lyon, Nice).
  2. max(...).get() throws NoSuchElementException on an empty list. Use .map(Order::customer).orElse("n/a"). For the sample data the result is Cy (total 200.0).
  3. 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() after map: the result is Ana,Cy.
Java
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 groupingBy or partitioningBy.
  • Method reference: shorthand Type::method for 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 andThen runs first-then-argument and compose runs 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, findFirst pipelines.
  • I know reduce with and without identity, and why associativity matters.
  • I can use groupingBy, partitioningBy, toMap, joining, teeing.
  • I unwrap OptionalInt and OptionalDouble correctly and know range versus rangeClosed.
  • I know what changes under parallel() and which operations stay deterministic.
  • I know Optional.of, ofNullable, orElse, orElseGet and orElseThrow.

Official sources