Lambdas and streams
Functional interfaces, effectively final captures, lazy one-shot pipelines, and the collectors that turn a stream back into a map.
After this lesson you can
- Say what a lambda is an instance of, and why captured variables must be effectively final
- Explain laziness and the per-element order in which a stream pipeline runs
- Group and reduce with groupingBy and toMap, including duplicate keys
- Build a multi-key Comparator with comparing and thenComparing
A lambda is an implementation of a functional interface — an
interface with exactly one abstract method. java.util.function has
the common shapes: Function<T, R>, Predicate<T>, Supplier<T>,
Consumer<T>, BiFunction<T, U, R>, UnaryOperator<T>, plus
primitive versions such as IntPredicate that avoid boxing. A method
reference (String::length, System.out::println, ArrayList::new)
is shorthand for a lambda that just calls that method.
A lambda can read local variables from the enclosing method only if they are effectively final — never reassigned. The lambda captures the value, not the variable, and may run later or on another thread, so allowing reassignment would make "which value?" ambiguous. Mutating the object a captured variable points to is allowed (and in a stream is usually a bug).
Pipelines are lazy and run element by element
A stream pipeline is a source, zero or more intermediate operations
(filter, map, sorted, distinct, limit...) and one terminal
operation (collect, toList, forEach, count, findFirst...).
Intermediate operations only describe work; nothing runs until the
terminal operation pulls elements through. Each element then travels
the whole pipeline before the next one starts, and short-circuiting
operations like findFirst or limit stop pulling as soon as they
have their answer.
import java.util.*;import java.util.stream.*; public class Solution { public static List<String> describe() { List<String> log = new ArrayList<>(); Stream<Integer> pipeline = Stream.of(1, 2, 3, 4, 5, 6) .filter(n -> { log.add("filter " + n); return n % 2 == 0; }) .map(n -> { log.add("map " + n); return n * 10; }); log.add("pipeline built"); Optional<Integer> first = pipeline.findFirst(); log.add("result " + first.orElseThrow()); try { pipeline.count(); // a second terminal operation } catch (IllegalStateException e) { log.add("reuse: " + e.getMessage()); } return log; }}A stream can be consumed only once. Calling a second terminal operation
on the same stream object throws IllegalStateException; build a new
stream from the source instead. Stream.toList() (Java 16) returns an
unmodifiable list.
Collecting into maps
Collectors.groupingBy(classifier) builds a Map<K, List<T>>; a
second, downstream collector replaces the list: counting(),
summingInt(...), mapping(...), maxBy(...). groupingBy makes
no promise about the map type or its order (in practice it is a
HashMap); pass a map factory such as TreeMap::new when the order
matters.
Collectors.toMap(keyFn, valueFn) throws IllegalStateException on
the first duplicate key — add a third argument, a merge function, to
say what happens when two elements share one.
import java.util.*;import java.util.stream.*; public class Solution { record Sale(String region, String rep, int amount) {} public static List<String> describe() { List<Sale> sales = List.of( new Sale("north", "ana", 120), new Sale("south", "bo", 80), new Sale("north", "cy", 40), new Sale("south", "bo", 30)); Map<String, Integer> totals = sales.stream().collect( Collectors.groupingBy(Sale::region, TreeMap::new, Collectors.summingInt(Sale::amount))); Map<String, List<String>> reps = sales.stream().collect( Collectors.groupingBy(Sale::region, TreeMap::new, Collectors.mapping(Sale::rep, Collectors.toList()))); List<String> out = new ArrayList<>(); out.add("totals " + totals); out.add("reps " + reps); try { sales.stream().collect(Collectors.toMap(Sale::rep, Sale::amount)); } catch (IllegalStateException e) { out.add("toMap: " + e.getMessage()); } Map<String, Integer> merged = sales.stream().collect( Collectors.toMap(Sale::rep, Sale::amount, Integer::sum, TreeMap::new)); out.add("merged " + merged); return out; }}Comparators
Comparator.comparing(Person::age).thenComparing(Person::name) sorts by
age, then name. .reversed() reverses the whole chain built so far,
so comparing(a).thenComparing(b).reversed() reverses both keys; to
reverse only one key, pass a reversed comparator for that key:
thenComparing(Person::name, Comparator.reverseOrder()).
Common mistakes
- Using
peekormapfor side effects and expecting them to run without a terminal operation — nothing runs at all. - Reusing a stream stored in a variable; the second terminal operation throws.
toMapon data with repeated keys and no merge function.- Adding to an outside
ArrayListfrom insideforEachon a parallel stream — a data race. Collect instead.
What interviewers ask
- What is the difference between intermediate and terminal operations? Intermediate ones are lazy and return a stream; a terminal one triggers the processing and produces a result or side effect.
- Why must captured variables be effectively final? The lambda copies the value and may run later or elsewhere; reassignment would make the captured value ambiguous.
mapversusflatMap?mapturns each element into one element; turns each into a stream and concatenates them.
Exercise · Pro
Each author's longest book
Exercises are free in the first 3 lessons of every course. From here on they are part of Pro and Sprint; the lesson and its runnable examples stay open.
Upgrade