Streams and Lambdas
A lambda expression is shorthand syntax for implementing a functional interface — any interface with exactly one abstract method. Before Java 8, the same thing required an anonymous inner class.
🎯 What you'll learn
- Lambdas: Anonymous Implementations of a Single-Method Interface
- The Core Functional Interfaces
- Method References: a Lambda That Just Calls One Existing Method
- The Stream Pipeline: Source, Intermediate, Terminal
filter,map,reduce
1Lambdas: Anonymous Implementations of a Single-Method Interface
A lambda expression is shorthand syntax for implementing a functional interface — any interface with exactly one abstract method. Before Java 8, the same thing required an anonymous inner class.
// pre-lambda: anonymous inner class implementing Comparator<String>
java.util.Comparator<String> byLength = new java.util.Comparator<String>() {
public int compare(String a, String b) {
return a.length() - b.length();
}
};
// lambda: same functional interface, far less ceremony
java.util.Comparator<String> byLengthLambda = (a, b) -> a.length() - b.length();The compiler infers which functional interface a lambda implements from the assignment/parameter context (target typing) — a lambda has no type of its own in isolation.
2The Core Functional Interfaces
java.util.function supplies the functional interfaces almost every stream operation is built on:
java.util.function.Function<String, Integer> length = s -> s.length(); // T -> R
java.util.function.Predicate<String> isEmpty = s -> s.isEmpty(); // T -> boolean
java.util.function.Consumer<String> printer = s -> System.out.println(s); // T -> void
java.util.function.Supplier<String> greeting = () -> "hello"; // () -> T
java.util.function.BiFunction<Integer, Integer, Integer> add = (a, b) -> a + b; // (T,U) -> R3Method References: a Lambda That Just Calls One Existing Method
When a lambda's entire body is "call this one existing method," a method reference (::) says the same thing more directly:
java.util.List<String> names = java.util.List.of("charlie", "alice", "bob");
names.forEach(s -> System.out.println(s)); // lambda
names.forEach(System.out::println); // equivalent method reference
names.stream().map(s -> s.toUpperCase()); // lambda calling an instance method on the parameter
names.stream().map(String::toUpperCase); // equivalent — "unbound" instance method referenceFour forms exist: Type::staticMethod, instance::instanceMethod, Type::instanceMethod (unbound — the first stream element becomes the receiver), and Type::new (constructor reference).
4The Stream Pipeline: Source, Intermediate, Terminal
A stream pipeline always has exactly this shape: one source, zero or more intermediate operations, one terminal operation.
java.util.List<String> names = java.util.List.of("charlie", "alice", "bob", "dave");
java.util.List<String> result = names.stream() // source
.filter(s -> s.length() > 3) // intermediate
.map(String::toUpperCase) // intermediate
.sorted() // intermediate
.collect(java.util.stream.Collectors.toList()); // terminal
// result: ["ALICE", "CHARLIE", "DAVE"]A Stream is not a data structure — it doesn't store elements. It's a description of a computation over a source, and nothing runs until a terminal operation is invoked.
5filter, map, reduce
java.util.List<Integer> nums = java.util.List.of(1, 2, 3, 4, 5, 6);
java.util.List<Integer> evenSquares = nums.stream()
.filter(n -> n % 2 == 0) // keep elements matching a Predicate
.map(n -> n * n) // transform each element via a Function
.collect(java.util.stream.Collectors.toList());
// [4, 16, 36]
int sum = nums.stream()
.reduce(0, (acc, n) -> acc + n); // fold to a single value: (identity, accumulator)
// 21reduce's first argument is the identity/seed value; the accumulator combines the running result with each element. An overload with no identity returns Optional<T>, since an empty stream has no result to produce.
6Collectors: Turning a Stream Back Into Something Concrete
java.util.List<String> names = java.util.List.of("alice", "bob", "charlie", "amy");
java.util.List<String> asList = names.stream().collect(java.util.stream.Collectors.toList());
String joined = names.stream().collect(java.util.stream.Collectors.joining(", "));
// "alice, bob, charlie, amy"
java.util.Map<Character, java.util.List<String>> byFirstLetter = names.stream()
.collect(java.util.stream.Collectors.groupingBy(s -> s.charAt(0)));
// {a=[alice, amy], b=[bob], c=[charlie]}
long count = names.stream().filter(s -> s.length() > 3).count();groupingBy is the workhorse for anything resembling a SQL GROUP BY — the classifier function (s -> s.charAt(0)) produces the map's keys, and every element mapping to the same key is collected into a List under that key by default.
7Laziness of Intermediate Operations
Intermediate operations (filter, map, sorted, ...) are lazy — building the pipeline does no work. Only calling a terminal operation (collect, count, forEach, reduce, ...) actually pulls elements through the pipeline.
java.util.List<String> names = java.util.List.of("alice", "bob", "charlie");
java.util.stream.Stream<String> pipeline = names.stream()
.filter(s -> {
System.out.println("filtering " + s); // nothing printed yet — no terminal op called
return s.length() > 3;
});
System.out.println("pipeline built, nothing has run yet");
long count = pipeline.count(); // NOW "filtering alice", "filtering bob", "filtering charlie" printLaziness also enables short-circuiting: .filter(...).findFirst() can stop pulling elements the moment a match is found, without processing the rest of the source, because filter and findFirst are fused into a single per-element pass rather than run as separate full sweeps.
8Lambda Variable Capture: Effectively Final
A lambda can reference a local variable from its enclosing scope only if that variable is effectively final — never reassigned after initialization, even if not literally marked final.
int threshold = 3; // never reassigned anywhere below — effectively final
java.util.List<Integer> nums = java.util.List.of(1, 2, 3, 4, 5);
long countAbove = nums.stream().filter(n -> n > threshold).count(); // legal: threshold is effectively final
int total = 0;
nums.forEach(n -> {
// total += n; // COMPILE ERROR: total is reassigned elsewhere, not effectively final
});Under the Hoodinvokedynamic, Not Anonymous Classes▾
Lambdas are not compiled to anonymous inner classes, despite behaving similarly at the source level — this was a deliberate design choice for Java 8. Each lambda body is compiled into a private synthetic method on the enclosing class, and the call site that "creates" the lambda is compiled to an invokedynamic bytecode instruction rather than a new AnonymousClass() allocation.
At first use of a given lambda call site, invokedynamic invokes a bootstrap method (LambdaMetafactory, part of the JDK) which generates the actual implementing class at runtime, using low-level MethodHandles pointing at the synthetic method holding the lambda body, then caches that generated class for all future calls to the same call site. This means: no .class file exists on disk per lambda (unlike an anonymous inner class, which does produce one), lambda class generation is deferred until the JIT/runtime actually needs it, and two syntactically identical lambdas at two different source locations become two independent generated classes, because each invokedynamic call site gets its own bootstrap invocation and its own cached generated class — they are never deduplicated into one.
This is also why capturing a variable in a lambda differs mechanically from an anonymous class capturing one: an anonymous class captures by copying the value into a final field of the generated class at construction time; a lambda's captured variables are instead passed as extra arguments to the generated implementation method by the LambdaMetafactory-generated glue code — functionally equivalent from a language standpoint (still requires effective finality, since the value is captured once, not re-read live), but a different mechanism than the desugaring anonymous classes use.
⚠️ Common Pitfalls
- Calling a stream's terminal operation twice — a
Streamcan only be consumed once; calling.collect(...)and then.count()on the same stream instance throwsIllegalStateException: stream has already been operated upon or closed. Build a fresh stream (e.g. call.stream()again on the source) for each traversal. - Assuming intermediate operations run immediately — side effects inside
.map()/.filter()lambdas won't happen until a terminal operation runs the pipeline; debugging by adding aSystem.out.printlninside.map()with no terminal op afterward prints nothing. - Trying to mutate a captured local variable from inside a lambda — violates the effectively-final rule and fails to compile; use an
AtomicInteger/array-of-one-element workaround or, better, restructure withreduce/collectinstead of external mutable state. - Using
reducefor somethingcollectdoes better —reduceis meant for combining immutable values into a new immutable result; building up a mutable collection (like aList) withreduceworks but is awkward and inefficient compared tocollect, which is designed exactly for that mutable-accumulation case. - Overusing streams for simple loops — a
forloop over 3 elements with a singleifis often clearer than a stream pipeline; streams pay off with multi-step transformations or when parallelism (.parallelStream()) is genuinely needed, not as a blanket style rule.
📋 Quick Reference
list.stream() // source (or Stream.of(...), Arrays.stream(arr))
.filter(x -> ...) // intermediate: keep matching (Predicate)
.map(x -> ...) // intermediate: transform (Function)
.sorted() / .sorted(comparator) // intermediate
.collect(Collectors.toList()) // terminal: materialize back into a List
.collect(Collectors.joining(", ")) // terminal: join Strings
.collect(Collectors.groupingBy(fn)) // terminal: Map<K, List<T>>
.reduce(identity, accumulator) // terminal: fold to one value
.count() / .findFirst() / .anyMatch() // terminal
Type::staticMethod instance::instanceMethod
Type::instanceMethod (unbound) Type::new
Lazy: intermediate ops build a plan; nothing runs until a terminal op
Effectively final: captured locals must never be reassigned after init
A Stream is single-use — build a new one per traversal🎤 Interview Q&A
Q1What are lambdas and functional interfaces?core▾
Lambdas are concise implementations of functional interfaces (one abstract method): Function, Predicate, Supplier, Consumer, Runnable, Comparator. Method references (User::getName) are shorthand.
- Captured variables? Must be effectively final.
Q2How do streams work?core▾
A pipeline of a source, intermediate operations (filter, map, flatMap, sorted) and a terminal operation (collect, reduce, forEach). Intermediate operations are lazy; nothing runs until the terminal operation.
- Can a stream be reused? No, it's consumed once.
Q3What are common collectors?core▾
toList, toSet, toMap (watch duplicate keys), groupingBy (with downstream counting/mapping), partitioningBy, joining, summingInt.
- Group users by country counting?
groupingBy(User::country, counting()).
Q4When are parallel streams a bad idea?senior▾
For small data, I/O-bound work, shared mutable state, or in servers where they use the common ForkJoinPool and compete with other requests. Measure before using.
- Ordering cost? Ordered operations reduce parallel speedup.
Q5What's Optional and how should it be used?core▾
A container for maybe-absent return values, with map, orElse, orElseThrow. Use it as a return type, not for fields or parameters, and never call get() without checking.
- orElse vs orElseGet? orElse evaluates eagerly; orElseGet lazily.
🛠️ Practice exercises
Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.