Skip to content

Streams and Lambda Expressions

Step 11 of 20

Streams and Lambda Expressions

Introduction

This lesson covers the sixth objective of the Oracle Certified Professional Java SE 21 Developer exam (1Z0-830): Working with Streams and Lambda expressions. Oracle does not publish a weighting per objective, but streams are everywhere in the exam: they are tested directly (what does this pipeline print?) and they appear inside questions about collections, concurrency and I/O. Many candidates lose points not on difficult theory but on small facts: when a pipeline actually runs, what a collector returns, or which functional interface a method reference matches.

By the end of the lesson you will read a stream pipeline the way the compiler and the runtime do: first checking types, then simulating the lazy evaluation element by element.

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 has exactly one abstract method (default, static and Object methods such as equals do not count). 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("");                                // 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:

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 returns its value automatically; a block body needs an explicit return when the method returns a value.
  • 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.

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

The compiler error always appears at the lambda that captures the variable, even if the reassignment is later in the method. 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 unbound form is the confusing one: 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, each returning a new stream: 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:

Stream.of("a", "b", "c")
      .peek(x -> System.out.print("p" + x + " "))
      .filter(x -> !x.equals("a"))
      .findFirst()
      .ifPresent(System.out::println);
// pa pb b

a is peeked and filtered out; b is peeked, passes the filter and findFirst stops the pipeline, so c is never touched. A common wrong answer is pa pb pc b.

Single use. A stream can be consumed only once. Reusing one throws IllegalStateException ("stream has already been operated upon or closed"), even if the first use was only an intermediate call:

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

Streams also should not modify their source during the pipeline, and the lambdas inside should be non-interfering and, for parallel use, stateless.

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. Stream.iterate and Stream.generate are infinite: without limit, a predicate-based iterate, or a short-circuit terminal operation, the pipeline never ends.

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, unlike collect(Collectors.toList()), whose list type is unspecified but modifiable in practice.

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:

Stream.of(1, 2, 3, 4).reduce(Integer::sum);          // Optional[10]: no identity, so possibly empty
Stream.of(1, 2, 3, 4).reduce(10, Integer::sum);      // 20: identity 10 + 1 + 2 + 3 + 4
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. For correct parallel results the accumulator must be associative and the identity must be neutral (0 for addition, 1 for multiplication, "" for concatenation).

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>> (a HashMap, order unspecified). 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.

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 stream is 0, but average(), min() and max() return OptionalInt/OptionalDouble, which must be unwrapped with getAsInt()/getAsDouble() (not 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

stream.parallel() or collection.parallelStream() splits the work over the common ForkJoinPool. The result of a correct pipeline is the same as sequentially; only the order of side effects changes.

  • forEach may print in any order; forEachOrdered preserves encounter order at some cost.
  • collect into a thread-safe result (toList, groupingBy) is safe because each thread fills its own container. Adding to a shared ArrayList from forEach is a data race and may lose elements or throw.
  • reduce needs an associative accumulator: reduce(0, (a, b) -> a - b) gives different answers in parallel.
  • findFirst stays deterministic (it 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.

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 transformations, still lazy about emptiness

Prefer orElseGet when the default is expensive to build, because orElse(expensive()) always calls expensive(). Do not use Optional for fields, method parameters or collections; it is meant for return values.

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(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, and Map<Boolean, ...> prints false before true.

Solution. {false=[Ed, Di], true=[Cy, Ana]}.

Why the other choices are worse. {true=[Ana, Cy], false=[Di, Ed]} ignores the sort and the printing order of a partition map. 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 prints both keys.

Second worked example: lazy evaluation and reduce

Statement. What does this code print?

List<Integer> seen = new ArrayList<>();
int total = Stream.of(5, 3, 8, 1)
    .peek(seen::add)
    .filter(x -> x > 2)
    .limit(2)
    .reduce(100, (a, b) -> a - b);
System.out.println(total + " " + seen);

Step 1. Elements flow one by one. 5 is peeked (seen = [5]), passes the filter, and is the first of two allowed by limit.

Step 2. 3 is peeked (seen = [5, 3]), passes the filter and is the second element. limit(2) now has its two elements and short-circuits: 8 and 1 are never produced.

Step 3. Sequential reduce with identity 100: 100 - 5 = 95, then 95 - 3 = 92.

Solution. 92 [5, 3].

Why other choices fail. [5, 3, 8, 1] assumes all elements pass through peek before limit applies. A result of 83 would mean all four elements were reduced, ignoring that limit(2) stops the pipeline. Note also that this subtraction reducer is not associative, so on a parallel stream the answer would not be guaranteed: this is why the exam likes to pair reduce with parallel().

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 variable throws IllegalStateException. Reusing a Supplier<Stream<T>> is the correct pattern.
  • 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:

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.
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 with exactly one abstract method, the target type of lambdas.
  • Effectively final: a local variable never reassigned after initialization, the only kind a lambda can capture.
  • Intermediate operation: a lazy stream stage that returns a new 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