09 - Lambdas, Streams, and Functional Programming
A complete beginner-to-advanced guide to functional Java, aligned with the Oracle Certified Professional: Java SE 21 Developer (1Z0-830) exam objectives.
Table of Contents
- What Is Functional Programming?
- Lambda Expressions Step-by-Step
- Functional Interfaces
- Built-in Functional Interfaces
- Method References
- The Stream API From Scratch
- Creating Streams
- Intermediate Operations
- Terminal Operations
- Stream Execution Flow
- Collectors in Detail
- Optional
- Primitive Streams
- Certification Traps
- Common Mistakes
- Interview Questions
- Quick Revision Notes
- One-Page Cheat Sheet
1. What Is Functional Programming?
Functional programming (FP) treats computation as the evaluation of functions — passing behavior around as data, favoring immutability, and avoiding side effects.
| Concept | Meaning |
|---|---|
| First-class functions | Functions can be stored, passed, and returned (via lambdas). |
| Immutability | Prefer not modifying state. |
| Declarative | Say what to do, not how (loops → streams). |
| Pure functions | Same input → same output, no side effects. |
// Imperative (how):
int sum = 0;
for (int n : numbers) { if (n % 2 == 0) sum += n; }
// Functional (what):
int sum2 = numbers.stream().filter(n -> n % 2 == 0).mapToInt(i -> i).sum();
2. Lambda Expressions Step-by-Step
A lambda is a short block of code that takes parameters and returns a value — an anonymous implementation of a functional interface.
From Anonymous Class to Lambda
// Step 1: anonymous class
Runnable r1 = new Runnable() {
public void run() { System.out.println("Hello"); }
};
// Step 2: lambda (same thing, concise)
Runnable r2 = () -> System.out.println("Hello");
Syntax Breakdown
(parameters) -> { body }
| | |
inputs arrow code
| Form | Example |
|---|---|
| No params | () -> 42 |
| One param (no parens needed) | x -> x * 2 |
| Multiple params | (a, b) -> a + b |
| Explicit types | (int a, int b) -> a + b |
| Block body | (a, b) -> { int s = a + b; return s; } |
With var (Java 11+) | (var a, var b) -> a + b |
Rules & Traps
| Rule | Detail |
|---|---|
| Single expression | No {} and no return needed (implicit return). |
| Block body | Needs {} and explicit return (if returning). |
| Effectively final | Lambdas can use local variables only if they're (effectively) final. |
No new scope for this | this refers to the enclosing instance, not the lambda. |
int factor = 3; // effectively final
Function<Integer, Integer> f = x -> x * factor;
// factor = 5; // would break it — can't reassign
3. Functional Interfaces
A functional interface has exactly one abstract method (SAM — Single Abstract Method). A lambda provides that method's implementation.
@FunctionalInterface
interface Calculator {
int operate(int a, int b); // the single abstract method
// default/static methods are allowed and don't count
default void info() { System.out.println("A calculator"); }
}
Calculator add = (a, b) -> a + b;
System.out.println(add.operate(2, 3)); // 5
Rules
| Rule | Detail |
|---|---|
| Exactly one abstract method | More than one = not functional. |
@FunctionalInterface | Optional annotation; compiler enforces SAM. |
| Default/static/private methods | Allowed; don't count toward the SAM. |
Object methods | Methods like equals/toString don't count. |
Trap: A functional interface can declare methods that override
Objectmethods (likeequals) — they don't break the "single abstract method" rule.
4. Built-in Functional Interfaces
The java.util.function package provides ready-made interfaces.
| Interface | Abstract Method | Takes | Returns | Use |
|---|---|---|---|---|
Supplier<T> | get() | nothing | T | Produce a value |
Consumer<T> | accept(T) | T | void | Consume a value |
Function<T,R> | apply(T) | T | R | Transform |
Predicate<T> | test(T) | T | boolean | Test a condition |
UnaryOperator<T> | apply(T) | T | T | Same-type transform |
BinaryOperator<T> | apply(T,T) | T,T | T | Combine two |
BiFunction<T,U,R> | apply(T,U) | T,U | R | Two-arg transform |
BiConsumer<T,U> | accept(T,U) | T,U | void | Two-arg consume |
BiPredicate<T,U> | test(T,U) | T,U | boolean | Two-arg test |
Examples
Supplier<String> supplier = () -> "Hello";
System.out.println(supplier.get()); // Hello
Consumer<String> printer = s -> System.out.println(s);
printer.accept("World"); // World
Function<Integer, Integer> square = x -> x * x;
System.out.println(square.apply(5)); // 25
Predicate<Integer> isEven = n -> n % 2 == 0;
System.out.println(isEven.test(4)); // true
BiFunction<Integer, Integer, Integer> add = (a, b) -> a + b;
System.out.println(add.apply(2, 3)); // 5
Composing Functions
Function<Integer,Integer> times2 = x -> x * 2;
Function<Integer,Integer> plus3 = x -> x + 3;
System.out.println(times2.andThen(plus3).apply(5)); // (5*2)+3 = 13
System.out.println(times2.compose(plus3).apply(5)); // (5+3)*2 = 16
Predicate<Integer> positive = n -> n > 0;
Predicate<Integer> even = n -> n % 2 == 0;
System.out.println(positive.and(even).test(4)); // true
System.out.println(positive.or(even).test(-2)); // true
System.out.println(positive.negate().test(-1)); // true
Trap:
andThenruns this first, then the argument;composeruns the argument first, then this.
5. Method References
A method reference is shorthand for a lambda that just calls an existing method.
| Type | Syntax | Lambda Equivalent |
|---|---|---|
| Static | ClassName::staticMethod | x -> ClassName.staticMethod(x) |
| Instance (specific object) | obj::method | x -> obj.method(x) |
| Instance (arbitrary object) | ClassName::method | (obj, args) -> obj.method(args) |
| Constructor | ClassName::new | args -> new ClassName(args) |
// Static
Function<String, Integer> parse = Integer::parseInt;
System.out.println(parse.apply("42")); // 42
// Instance of a specific object
String prefix = "Hello ";
Function<String, String> greet = prefix::concat;
System.out.println(greet.apply("World")); // Hello World
// Instance of an arbitrary object of a type
Function<String, Integer> len = String::length;
System.out.println(len.apply("Java")); // 4
// Constructor
Supplier<ArrayList<String>> listMaker = ArrayList::new;
List<String> list = listMaker.get();
// Common in streams:
List<String> names = List.of("bob", "alice");
names.stream().map(String::toUpperCase).forEach(System.out::println);
6. The Stream API From Scratch
A Stream is a sequence of elements supporting functional-style operations. It does not store data — it processes data from a source (collection, array, etc.).
Anatomy of a Stream Pipeline
SOURCE -> INTERMEDIATE ops (lazy) -> TERMINAL op (eager)
list filter, map, sorted... collect, forEach, count
List<String> names = List.of("Alice", "Bob", "Charlie", "Dave");
long count = names.stream() // source
.filter(n -> n.length() > 3) // intermediate
.map(String::toUpperCase) // intermediate
.count(); // terminal
System.out.println(count); // 3
Key Properties
| Property | Detail |
|---|---|
| No storage | Streams don't hold data; they process it. |
| Lazy | Intermediate ops run only when a terminal op is invoked. |
| Single-use | A stream can be consumed once; reuse throws IllegalStateException. |
| Non-mutating | The source is not modified. |
| Possibly parallel | .parallelStream() for concurrency. |
Stream<Integer> s = Stream.of(1, 2, 3);
s.forEach(System.out::println);
// s.forEach(System.out::println); // IllegalStateException: stream already used
7. Creating Streams
// From a collection
List<Integer> list = List.of(1, 2, 3);
Stream<Integer> s1 = list.stream();
// From values
Stream<String> s2 = Stream.of("a", "b", "c");
// From an array
int[] arr = {1, 2, 3};
IntStream s3 = Arrays.stream(arr);
// Infinite streams (need limit!)
Stream<Integer> s4 = Stream.iterate(1, n -> n + 1); // 1,2,3,...
Stream<Double> s5 = Stream.generate(Math::random);
// Range (IntStream)
IntStream s6 = IntStream.range(1, 5); // 1,2,3,4 (end-exclusive)
IntStream s7 = IntStream.rangeClosed(1, 5); // 1,2,3,4,5 (end-inclusive)
// Empty
Stream<String> s8 = Stream.empty();
Trap:
Stream.iterate/generateare infinite — always add.limit(n)or a 3-argiteratewith a predicate.
8. Intermediate Operations
Intermediate operations are lazy and return a new stream. They run only when a terminal op executes.
filter — keep matching elements
Stream.of(1, 2, 3, 4, 5)
.filter(n -> n % 2 == 0)
.forEach(System.out::print); // 24
map — transform each element
Stream.of("a", "b", "c")
.map(String::toUpperCase)
.forEach(System.out::print); // ABC
flatMap — flatten nested structures
List<List<Integer>> nested = List.of(List.of(1, 2), List.of(3, 4));
List<Integer> flat = nested.stream()
.flatMap(List::stream) // flatten List<List> -> stream of ints
.collect(Collectors.toList());
System.out.println(flat); // [1, 2, 3, 4]
distinct — remove duplicates (uses equals)
Stream.of(1, 2, 2, 3, 3, 3)
.distinct()
.forEach(System.out::print); // 123
sorted — order elements
Stream.of(3, 1, 2)
.sorted() // natural order
.forEach(System.out::print); // 123
Stream.of("bb", "a", "ccc")
.sorted(Comparator.comparingInt(String::length))
.forEach(System.out::println); // a, bb, ccc
limit & skip
Stream.iterate(1, n -> n + 1)
.limit(5) // first 5
.skip(2) // drop first 2
.forEach(System.out::print); // 345
peek — debug without consuming
Stream.of(1, 2, 3)
.peek(n -> System.out.println("peeking: " + n)) // side-effect/debug
.map(n -> n * 2)
.forEach(System.out::println);
| Operation | Purpose |
|---|---|
filter(Predicate) | Keep matching elements |
map(Function) | Transform each element |
flatMap(Function) | Flatten nested streams |
distinct() | Remove duplicates |
sorted() | Sort elements |
limit(n) / skip(n) | Take / drop elements |
peek(Consumer) | Inspect elements (debugging) |
Trap:
peekis for debugging only; relying on it for logic is discouraged and it may be skipped under optimization.
9. Terminal Operations
Terminal operations trigger execution and produce a result (or side effect). After a terminal op, the stream is consumed.
forEach
Stream.of(1, 2, 3).forEach(System.out::println);
collect — gather into a collection
List<Integer> list = Stream.of(1, 2, 3).collect(Collectors.toList());
List<Integer> list2 = Stream.of(1, 2, 3).toList(); // Java 16+ (immutable)
reduce — combine into a single value
int sum = Stream.of(1, 2, 3, 4).reduce(0, Integer::sum); // 10
Optional<Integer> product = Stream.of(1, 2, 3, 4)
.reduce((a, b) -> a * b); // Optional[24]
count, anyMatch, allMatch, noneMatch
long c = Stream.of(1, 2, 3).count(); // 3
boolean any = Stream.of(1, 2, 3).anyMatch(n -> n > 2); // true
boolean all = Stream.of(1, 2, 3).allMatch(n -> n > 0); // true
boolean none = Stream.of(1, 2, 3).noneMatch(n -> n > 5); // true
findFirst, findAny, min, max
Optional<Integer> first = Stream.of(1, 2, 3).findFirst(); // Optional[1]
Optional<Integer> max = Stream.of(1, 2, 3).max(Integer::compare); // Optional[3]
| Operation | Returns |
|---|---|
forEach(Consumer) | void |
collect(Collector) | Collection/value |
toList() | Immutable List (Java 16+) |
reduce(...) | Combined value / Optional |
count() | long |
anyMatch/allMatch/noneMatch | boolean |
findFirst/findAny | Optional |
min/max | Optional |
Short-circuiting terminal ops (
anyMatch,findFirst,limit) stop early — they don't process the whole stream.
10. Stream Execution Flow
Streams are lazy: nothing runs until a terminal operation. Then elements flow through the pipeline one at a time (not stage-by-stage on the whole collection).
Element-by-Element (Vertical) Processing
Stream.of("a", "bb", "ccc")
.filter(s -> { System.out.println("filter: " + s); return s.length() > 1; })
.map(s -> { System.out.println("map: " + s); return s.toUpperCase(); })
.forEach(s -> System.out.println("forEach: " + s));
Output (note the interleaving):
filter: a
filter: bb
map: bb
forEach: BB
filter: ccc
map: ccc
forEach: CCC
"a" -> filter (fail) -> dropped
"bb" -> filter (pass) -> map -> forEach
"ccc"-> filter (pass) -> map -> forEach
Each element traverses the entire pipeline before the next element starts. This enables short-circuiting and efficiency.
Lazy Evaluation Proof
Stream.of(1, 2, 3)
.filter(n -> { System.out.println("checking " + n); return n > 1; });
// NOTHING prints — no terminal operation!
11. Collectors in Detail
Collectors are recipes for the collect() terminal op — gathering stream elements into collections, maps, or summaries.
To Collections
List<Integer> list = stream.collect(Collectors.toList());
Set<Integer> set = stream.collect(Collectors.toSet());
List<Integer> arr = stream.collect(Collectors.toCollection(ArrayList::new));
joining (Strings)
String result = Stream.of("a", "b", "c")
.collect(Collectors.joining(", ", "[", "]")); // [a, b, c]
groupingBy
List<String> words = List.of("apple", "banana", "avocado", "cherry");
Map<Character, List<String>> byFirst = words.stream()
.collect(Collectors.groupingBy(w -> w.charAt(0)));
// {a=[apple, avocado], b=[banana], c=[cherry]}
groupingBy with downstream
Map<Character, Long> countByFirst = words.stream()
.collect(Collectors.groupingBy(w -> w.charAt(0), Collectors.counting()));
// {a=2, b=1, c=1}
partitioningBy (boolean split)
Map<Boolean, List<Integer>> parts = Stream.of(1, 2, 3, 4, 5)
.collect(Collectors.partitioningBy(n -> n % 2 == 0));
// {false=[1, 3, 5], true=[2, 4]}
toMap
Map<String, Integer> lengths = Stream.of("a", "bb", "ccc")
.collect(Collectors.toMap(s -> s, String::length));
// {a=1, bb=2, ccc=3}
Summaries
Double avg = Stream.of(1, 2, 3).collect(Collectors.averagingInt(i -> i)); // 2.0
Integer sum = Stream.of(1, 2, 3).collect(Collectors.summingInt(i -> i)); // 6
Long n = Stream.of(1, 2, 3).collect(Collectors.counting()); // 3
| Collector | Result |
|---|---|
toList() / toSet() | Collection |
toMap(k, v) | Map (duplicate keys → exception unless merge fn) |
joining(...) | Concatenated String |
groupingBy(fn) | Map<K, List<T>> |
groupingBy(fn, downstream) | Map<K, R> |
partitioningBy(predicate) | Map<Boolean, List<T>> |
counting/summingInt/averagingInt | Numeric summary |
Trap:
toMapthrowsIllegalStateExceptionon duplicate keys unless you supply a merge function:toMap(k, v, (a, b) -> a).
12. Optional
Optional<T> is a container that may or may not hold a value — designed to avoid NullPointerException and make "no result" explicit.
Optional<String> present = Optional.of("Hello");
Optional<String> empty = Optional.empty();
Optional<String> nullable = Optional.ofNullable(maybeNull); // null-safe
Reading Values
Optional<String> opt = Optional.of("Java");
opt.isPresent(); // true
opt.isEmpty(); // false (Java 11+)
opt.get(); // "Java" (throws if empty — avoid!)
opt.orElse("default"); // value or "default"
opt.orElseGet(() -> compute()); // value or lazily computed default
opt.orElseThrow(); // value or NoSuchElementException
opt.ifPresent(System.out::println);
Transforming
Optional<String> name = Optional.of("alice");
int len = name.map(String::length).orElse(0); // 5
Optional<String> upper = name.filter(s -> s.length() > 3)
.map(String::toUpperCase); // Optional[ALICE]
Common Pitfalls (Traps)
| Pitfall | Better Approach |
|---|---|
Calling get() without checking | Use orElse/orElseGet/ifPresent. |
Optional.of(null) | Throws NPE — use ofNullable. |
Using Optional for fields/params | Intended for return types. |
orElse(expensive()) always runs | Use orElseGet(() -> expensive()) (lazy). |
// orElse vs orElseGet:
String a = opt.orElse(buildDefault()); // buildDefault() ALWAYS called
String b = opt.orElseGet(() -> buildDefault()); // called only if empty
Key trap:
orElse(x)always evaluatesxeven if the Optional has a value;orElseGet(supplier)evaluates only when empty.
13. Primitive Streams
To avoid boxing overhead, use IntStream, LongStream, and DoubleStream.
IntStream.rangeClosed(1, 5).sum(); // 15
IntStream.of(1, 2, 3).average().getAsDouble(); // 2.0
IntStream.rangeClosed(1, 5).boxed() // IntStream -> Stream<Integer>
.collect(Collectors.toList());
// Converting
Stream<Integer> boxed = IntStream.range(0, 3).boxed();
IntStream unboxed = Stream.of(1, 2, 3).mapToInt(Integer::intValue);
// Statistics
IntSummaryStatistics stats = IntStream.of(1, 2, 3, 4).summaryStatistics();
System.out.println(stats.getMax()); // 4
System.out.println(stats.getAverage()); // 2.5
| Method | Purpose |
|---|---|
mapToInt/mapToObj | Convert between object & primitive streams |
boxed() | Primitive stream → Stream<Wrapper> |
sum/average/max/min | Built-in numeric terminals |
summaryStatistics() | Count, sum, min, max, average at once |
14. Certification Traps
| # | Trap |
|---|---|
| 1 | A stream can be used once; reuse → IllegalStateException. |
| 2 | Intermediate ops are lazy — nothing runs without a terminal op. |
| 3 | Stream.iterate/generate are infinite — need limit(). |
| 4 | peek is for debugging; may be skipped and shouldn't drive logic. |
| 5 | findFirst/anyMatch/limit short-circuit. |
| 6 | toMap with duplicate keys throws unless a merge function is given. |
| 7 | orElse(x) always evaluates x; use orElseGet for laziness. |
| 8 | Optional.of(null) throws NPE; use ofNullable. |
| 9 | Optional.get() on empty throws NoSuchElementException. |
| 10 | Lambdas capture only effectively final local variables. |
| 11 | andThen = this then arg; compose = arg then this. |
| 12 | A functional interface has exactly one abstract method (Object methods excluded). |
| 13 | reduce with identity returns a value; without identity returns Optional. |
| 14 | toList() (Java 16+) is immutable; Collectors.toList() is unspecified mutability. |
| 15 | Elements flow through the pipeline one at a time, not stage-by-stage. |
15. Common Mistakes
| Mistake | Fix |
|---|---|
| Reusing a consumed stream | Create a new stream each time. |
| Forgetting a terminal op (nothing happens) | Add collect/forEach/count, etc. |
Infinite stream without limit | Add .limit(n). |
Optional.get() everywhere | Use orElse/ifPresent/map. |
orElse(expensiveCall()) | Use orElseGet(() -> ...). |
| Mutating shared state in a lambda | Keep operations stateless/pure. |
toMap on data with duplicate keys | Provide a merge function. |
| Using streams for trivial loops | A plain loop can be clearer. |
16. Interview Questions
Q1. What is a lambda expression? A concise anonymous function that implements a functional interface's single abstract method.
Q2. What is a functional interface? An interface with exactly one abstract method; it may also have default/static methods.
Q3. Name the core built-in functional interfaces.
Supplier (produces), Consumer (consumes), Function (transforms), Predicate (tests), plus UnaryOperator/BinaryOperator and the Bi* variants.
Q4. What are the types of method references?
Static (Class::m), instance of a specific object (obj::m), instance of an arbitrary object (Class::m), and constructor (Class::new).
Q5. What is a Stream? A sequence of elements supporting functional-style, lazy, single-use operations that don't modify the source.
Q6. Difference between intermediate and terminal operations?
Intermediate ops are lazy and return a stream (e.g., map, filter); terminal ops trigger execution and produce a result (e.g., collect, count).
Q7. Difference between map and flatMap?
map transforms each element 1:1; flatMap flattens nested streams into a single stream.
Q8. Difference between findFirst and findAny?
findFirst returns the first element (respecting order); findAny may return any element, which is faster in parallel streams.
Q9. Why use Optional?
To represent the possible absence of a value explicitly and avoid NullPointerException.
Q10. Difference between orElse and orElseGet?
orElse always evaluates its argument; orElseGet evaluates the supplier only when the Optional is empty.
Q11. What does reduce do?
It combines stream elements into a single value using an accumulator (optionally with an identity).
Q12. Why can't a lambda use a non-final local variable? Captured local variables must be effectively final to avoid concurrency/lifecycle issues; the lambda captures a copy of the value.
17. Quick Revision Notes
- Lambda = anonymous implementation of a functional interface's single method.
- Functional interface = exactly one abstract method (
@FunctionalInterface). - Core interfaces:
Supplier(get),Consumer(accept),Function(apply),Predicate(test). andThen= this→arg;compose= arg→this.- Method refs:
Class::static,obj::method,Class::method,Class::new. - Streams: lazy, single-use, non-mutating; reuse →
IllegalStateException. - Intermediate (lazy):
filter,map,flatMap,distinct,sorted,limit,skip,peek. - Terminal (eager):
forEach,collect,toList,reduce,count,*Match,find*,min/max. - Infinite streams (
iterate/generate) needlimit. - Elements flow one-at-a-time through the pipeline; short-circuiting stops early.
- Collectors:
toList/toSet/toMap,joining,groupingBy,partitioningBy, summaries. toMapduplicate keys → need merge fn.- Optional:
of/ofNullable/empty; preferorElseGet/ifPresent/map; avoidget. orElsealways evaluates;orElseGetis lazy.- Primitive streams (
IntStream...) avoid boxing;boxed()/mapToIntconvert.
18. One-Page Cheat Sheet
============ LAMBDAS, STREAMS & FUNCTIONAL PROGRAMMING CHEAT SHEET ============
LAMBDA SYNTAX
() -> expr | x -> x*2 | (a,b) -> a+b | (a,b) -> { return a+b; }
captures effectively-final locals ; this = enclosing instance
FUNCTIONAL INTERFACE (@FunctionalInterface = exactly ONE abstract method)
Supplier<T> T get()
Consumer<T> void accept(T)
Function<T,R> R apply(T) andThen(this->arg) compose(arg->this)
Predicate<T> boolean test(T) and/or/negate
UnaryOperator<T> T apply(T)
BinaryOperator<T>T apply(T,T)
Bi* variants: BiFunction/BiConsumer/BiPredicate
METHOD REFERENCES
Class::staticM | obj::instanceM | Class::instanceM | Class::new
STREAM PIPELINE
source.stream() -> intermediate(lazy)... -> terminal(eager)
single-use (reuse -> IllegalStateException) ; non-mutating
CREATE
list.stream() | Stream.of(...) | Arrays.stream(arr)
Stream.iterate(seed,fn)/generate(sup) -> INFINITE, need limit()
IntStream.range(a,b) [excl] / rangeClosed(a,b) [incl]
INTERMEDIATE (lazy)
filter(Pred) map(Fn) flatMap(Fn) distinct() sorted()
limit(n) skip(n) peek(Consumer=debug only)
TERMINAL (eager)
forEach collect toList(16+,immutable) reduce(id,acc)
count anyMatch allMatch noneMatch findFirst findAny min max
short-circuit: anyMatch/findFirst/limit
EXECUTION
lazy: nothing runs without terminal
elements flow ONE AT A TIME through whole pipeline (vertical)
COLLECTORS
toList/toSet/toMap(k,v[,merge]) joining(d,pre,suf)
groupingBy(fn[,downstream]) partitioningBy(pred)
counting summingInt averagingInt
toMap duplicate key -> need merge fn
OPTIONAL
of(x) ofNullable(x) empty()
isPresent isEmpty get(avoid) orElse(x always eval)
orElseGet(sup lazy) orElseThrow ifPresent map filter
of(null) -> NPE ; get() on empty -> NoSuchElementException
PRIMITIVE STREAMS
IntStream/LongStream/DoubleStream ; sum/average/max/min
boxed() -> Stream<Wrapper> ; mapToInt/mapToObj ; summaryStatistics()
==============================================================================
End of 09 - Lambdas, Streams, and Functional Programming.