Streams & Lambdas¶
Lambdas are anonymous functions enabling functional programming in Java. Streams provide a declarative pipeline for processing collections: source → intermediate ops (filter, map) → terminal op (collect, forEach). Streams are lazy (intermediate ops don't execute until a terminal op is called) and can be parallelized.
Lambda Expressions¶
Lambdas are shorthand for implementing functional interfaces (interfaces with exactly one abstract method). Syntax: (params) -> expression or (params) -> { statements }. Lambdas enable functional-style programming and are used heavily with Streams, Comparators, and event handlers. Method references (String::length) are even shorter for single-method calls.
Deep Dive: Lambda Syntax Evolution
// Old way (anonymous class)
Comparator<String> comp = new Comparator<String>() {
@Override
public int compare(String s1, String s2) {
return s1.length() - s2.length();
}
};
// Lambda
Comparator<String> comp = (s1, s2) -> s1.length() - s2.length();
// Method reference (even shorter)
Comparator<String> comp = Comparator.comparingInt(String::length);
Functional Interfaces¶
A functional interface has exactly one abstract method (annotated @FunctionalInterface). Java's java.util.function package provides built-in ones: Predicate (condition), Function (transform), Consumer (process), Supplier (generate). These are the building blocks for lambda-based APIs.
Deep Dive: Built-in Functional Interfaces
| Interface | Method | Use Case |
|---|---|---|
Predicate<T> |
boolean test(T t) |
Filter/condition |
Function<T, R> |
R apply(T t) |
Transform T to R |
Consumer<T> |
void accept(T t) |
Process/consume T |
Supplier<T> |
T get() |
Generate/supply T |
UnaryOperator<T> |
T apply(T t) |
Transform T to T |
BiFunction<T, U, R> |
R apply(T t, U u) |
Combine T and U to R |
Method References¶
Method references are shorthand for lambdas that call a single method. Four types: static (Integer::parseInt), instance on object (str::toUpperCase), instance on type (String::toUpperCase), and constructor (ArrayList::new).
Deep Dive: Four Types with Examples
// 1. Static: ClassName::staticMethod
Function<String, Integer> parseInt = Integer::parseInt;
// Equivalent to: s -> Integer.parseInt(s)
// 2. Instance on object: instance::method
String str = "hello";
Supplier<String> toUpper = str::toUpperCase;
// Equivalent to: () -> str.toUpperCase()
// 3. Instance on type: ClassName::instanceMethod
Function<String, String> upper = String::toUpperCase;
// Equivalent to: s -> s.toUpperCase()
// 4. Constructor: ClassName::new
Supplier<List<String>> listMaker = ArrayList::new;
// Equivalent to: () -> new ArrayList<>()
Stream Pipeline¶
A stream pipeline has three parts: source (collection, array), intermediate operations (lazy — filter, map, flatMap, sorted, distinct), and terminal operation (triggers execution — collect, forEach, reduce, count). Nothing executes until the terminal op is called. Streams can only be consumed once.
Deep Dive: Operations Reference
Intermediate operations (lazy, return a new stream):
filter(Predicate)— keep matching elementsmap(Function)— transform elementsflatMap(Function)— flatten nested structuresdistinct()— remove duplicatessorted()/sorted(Comparator)— sortlimit(n)/skip(n)— take/skip n elementspeek(Consumer)— debug without affecting stream
Terminal operations (eager, produce result):
collect(Collector)— gather into collectionforEach(Consumer)— iteratereduce(BinaryOperator)— combine elementscount(),min(),max()anyMatch(),allMatch(),noneMatch()findFirst(),findAny()
Collectors¶
Collectors gather stream elements into data structures. Key ones: toList(), toSet(), toMap(), groupingBy(), partitioningBy(), joining(). For toMap(), always provide a merge function for duplicate keys or it throws IllegalStateException.
Deep Dive: Collector Examples
List<String> names = Arrays.asList("Alice", "Bob", "Charlie");
// toList, toSet
List<String> list = names.stream().collect(Collectors.toList());
Set<String> set = names.stream().collect(Collectors.toSet());
// joining
String joined = names.stream().collect(Collectors.joining(", "));
// "Alice, Bob, Charlie"
// groupingBy
Map<Integer, List<String>> byLength = names.stream()
.collect(Collectors.groupingBy(String::length));
// {3=[Bob], 5=[Alice], 7=[Charlie]}
// partitioningBy (boolean grouping)
Map<Boolean, List<String>> partitioned = names.stream()
.collect(Collectors.partitioningBy(n -> n.length() > 4));
// {false=[Bob], true=[Alice, Charlie]}
// toMap (with merge function for duplicate keys)
Map<String, Integer> nameToLength = names.stream()
.collect(Collectors.toMap(
name -> name, // key
String::length, // value
(v1, v2) -> v1 // merge function
));
map() vs flatMap()¶
map() transforms each element 1-to-1 (returns a single value). flatMap() transforms each element to a stream and flattens the result (1-to-many). Use flatMap when you have nested collections (List<List<T>>) and want a single flat stream.
Deep Dive: Examples
List<List<String>> nested = Arrays.asList(
Arrays.asList("a", "b"),
Arrays.asList("c", "d")
);
// map → Stream<List<String>> — still nested
// [[a, b], [c, d]]
// flatMap → Stream<String> — flattened
List<String> flat = nested.stream()
.flatMap(List::stream)
.collect(Collectors.toList());
// [a, b, c, d]
// Practical: split sentences into words
List<String> sentences = Arrays.asList("hello world", "java streams");
List<String> words = sentences.stream()
.flatMap(s -> Arrays.stream(s.split(" ")))
.collect(Collectors.toList());
// [hello, world, java, streams]
Parallel Streams¶
Parallel streams split the source into chunks, process them concurrently using ForkJoinPool, and merge results. Use for large datasets with CPU-intensive, independent operations. Avoid for small collections (overhead), shared mutable state, or when order matters (use forEachOrdered() if needed).
Deep Dive: When to Use and Pitfalls
// Sequential
int sum = numbers.stream().mapToInt(Integer::intValue).sum();
// Parallel — uses ForkJoinPool
int parallelSum = numbers.parallelStream().mapToInt(Integer::intValue).sum();
When to use:
- Large datasets (>10,000 elements)
- CPU-intensive operations
- Independent operations (no shared mutable state)
Pitfalls:
- Overhead makes it slower for small collections
- Shared mutable state causes race conditions
- Order may not be preserved (use
forEachOrdered()) - Uses common ForkJoinPool — can starve other tasks
Stream vs Collection¶
Collections store elements (eager, reusable, modifiable). Streams process elements (lazy, single-use, immutable pipeline). Streams don't store data — they operate on a source. A stream can only be consumed once; attempting to reuse it throws IllegalStateException.
Deep Dive: Comparison
| Feature | Collection | Stream |
|---|---|---|
| Storage | Stores elements | No storage, operates on source |
| Modification | Can add/remove | Immutable pipeline |
| Iteration | External (for-loop) | Internal (forEach) |
| Laziness | Eager | Intermediate ops are lazy |
| Reusability | Multiple iterations | Single use only |
Common Interview Questions¶
Common Interview Questions
- What is a lambda expression?
- What is a functional interface?
- What are the differences between
Predicate,Function,Consumer, andSupplier? - What is a Stream? How is it different from a Collection?
- What are intermediate and terminal operations?
- Why are intermediate operations lazy?
- What is the difference between
map()andflatMap()? - Can you reuse a Stream?
- When should you use parallel streams?
- What is a method reference? Give examples.
- How do you collect stream elements into a Map?
- What happens with duplicate keys in
Collectors.toMap()?