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,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 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
returnwhen the method returns a value. - 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] == 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:
- A source:
collection.stream(),Arrays.stream(array),Stream.of(...),Stream.iterate(...),Stream.generate(...),IntStream.range(...),Files.lines(path). - Zero or more intermediate operations, each returning a new stream:
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")
.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:
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(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 aMap<K, List<T>>(aHashMap, 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,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 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.
forEachmay print in any order;forEachOrderedpreserves encounter order at some cost.collectinto a thread-safe result (toList,groupingBy) is safe because each thread fills its own container. Adding to a sharedArrayListfromforEachis a data race and may lose elements or throw.reduceneeds an associative accumulator:reduce(0, (a, b) -> a - b)gives different answers in parallel.findFirststays deterministic (it respects encounter order);findAnymay return any element.- Parallel does not mean faster: small data, boxing,
LinkedListsources 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,distincton ordered streams). - Reusing a stream variable throws
IllegalStateException. Reusing aSupplier<Stream<T>>is the correct pattern. 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 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
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)