What Are Lambdas and Streams?
Introduced in Java 8 (2014), lambdas and the Stream API represent the most significant shift in how Java code is written since the language was created. Together they bring functional programming into a traditionally object-oriented language.
A lambda expression is an anonymous function โ a block of behaviour you can pass around as a value, store in a variable, or hand to a method. Before Java 8, passing behaviour required anonymous classes: 6 lines of boilerplate for 1 line of logic.
The Stream API is a pipeline model for processing sequences of data. Instead of writing explicit loops that mutate state, you declare what you want โ filter this, transform that, collect the results โ and the Stream handles the how.
// BEFORE Java 8: anonymous class for sorting โ 6 lines for 1 idea
Collections.sort(names, new Comparator<String>() {
@Override
public int compare(String s1, String s2) {
return s1.compareTo(s2);
}
});
// AFTER: lambda โ 1 line, same result
names.sort((s1, s2) -> s1.compareTo(s2));
// EVEN BETTER: method reference โ reads like English
names.sort(String::compareTo);
// BEFORE: imperative loop โ mutable state, hard to parallelise
List<String> result = new ArrayList<>();
for (User user : users) {
if (user.isActive() && user.getAge() >= 18) {
result.add(user.getName().toUpperCase());
}
}
Collections.sort(result);
// AFTER: stream pipeline โ declarative, composable, parallelisable
List<String> result = users.stream()
.filter(User::isActive)
.filter(u -> u.getAge() >= 18)
.map(User::getName)
.map(String::toUpperCase)
.sorted()
.collect(Collectors.toList());
A Stream is not a data structure โ it's a pipeline. It conveys elements from a source (collection, array, generator) through a sequence of operations. Intermediate operations are lazy: nothing executes until a terminal operation is called. Once consumed, a stream cannot be reused.
Lambda Expressions
Syntax
// (parameters) -> expression OR (parameters) -> { statements; }
() -> System.out.println("hello") // no params, expression body
x -> x * x // one param, parens optional
(x, y) -> x + y // multiple params
(x, y) -> { int s = x + y; return s; } // block body needs return
(String s) -> s.length() // explicit type (usually inferred)
Variable capture โ effectively final
int multiplier = 3; // effectively final โ never reassigned
List<Integer> result = numbers.stream()
.map(n -> n * multiplier) // โ
captures effectively final variable
.collect(Collectors.toList());
// โ This breaks it:
multiplier = 5; // now NOT effectively final โ compile error in lambda above
// Why the restriction: the lambda may run on a different thread.
// Capturing mutable state would require synchronisation or volatile.
// Java chose the clean solution: capture only immutable data.
// Workaround when you need mutation: use an AtomicInteger or collect results
AtomicInteger counter = new AtomicInteger(0);
names.stream()
.filter(n -> n.startsWith("A"))
.forEach(n -> counter.incrementAndGet()); // โ
object ref is final; state is mutable
Functional Interfaces
A lambda expression is an implementation of a functional interface โ any interface with exactly one abstract method (SAM). The compiler infers which interface from the context.
// java.util.function โ the core four
// Predicate<T>: T โ boolean โ use for filtering/testing
Predicate<String> isLong = s -> s.length() > 5;
Predicate<String> isNotBlank = Predicate.not(String::isBlank); // Java 11+
isLong.and(isNotBlank).test("hello world"); // true โ composable
isLong.negate().test("hi"); // true
// Function<T,R>: T โ R โ use for transformation
Function<String, Integer> length = String::length;
Function<String, String> trimmed = String::strip;
Function<String, Integer> combined = trimmed.andThen(length); // compose
// Consumer<T>: T โ void โ use for side effects (logging, saving)
Consumer<User> saveUser = userRepo::save;
Consumer<User> logUser = u -> log.info("Saved: {}", u.getName());
saveUser.andThen(logUser).accept(newUser); // save then log
// Supplier<T>: () โ T โ use for lazy/deferred creation
Supplier<Connection> lazyConn = dataSource::getConnection;
// Connection not opened until lazyConn.get() is called
// Other useful types:
BiFunction<String, Integer, String> repeat = (String::repeat);
UnaryOperator<String> upper = String::toUpperCase; // T โ T
BinaryOperator<Integer> max = Integer::max; // (T, T) โ T
Method references โ four kinds
// 1. Static method: ClassName::staticMethod
Function<String, Integer> parse = Integer::parseInt;
// 2. Bound instance: object::instanceMethod (specific object)
Consumer<String> print = System.out::println;
// 3. Unbound instance: ClassName::instanceMethod (receiver is first param)
Function<String, String> upper = String::toUpperCase; // s -> s.toUpperCase()
Comparator<String> cmp = String::compareToIgnoreCase;
// 4. Constructor: ClassName::new
Function<String, StringBuilder> build = StringBuilder::new;
List<User> users = names.stream().map(User::new).toList();
The Stream Pipeline
/*
* SOURCE โ INTERMEDIATE OPERATIONS (lazy) โ TERMINAL OPERATION
*
* List/Set/Map filter() collect()
* Array map() reduce()
* Stream.of() flatMap() forEach()
* IntStream sorted() count()
* Files.lines() distinct() findFirst()
* limit/skip anyMatch/allMatch
* peek() toList() Java 16+
*
* Nothing runs until the terminal operation is called.
*/
Intermediate operations
List<String> names = List.of("Alice", "Bob", "Charlie", "Alice");
names.stream().filter(s -> s.startsWith("A")); // [Alice, Alice]
names.stream().map(String::toUpperCase); // [ALICE, BOB, CHARLIE, ALICE]
names.stream().distinct(); // [Alice, Bob, Charlie]
names.stream().sorted(); // alphabetical
names.stream().sorted(Comparator.comparingInt(String::length)); // by length
names.stream().limit(2); // [Alice, Bob]
names.stream().skip(2); // [Charlie, Alice]
// flatMap: each element โ multiple elements, then flatten
List<List<String>> nested = List.of(List.of("a","b"), List.of("c","d"));
nested.stream().flatMap(List::stream); // [a, b, c, d]
// mapToInt/mapToLong/mapToDouble โ avoid boxing overhead
names.stream().mapToInt(String::length).sum(); // primitive IntStream
// Java 9+
Stream.of(1,2,3,4,5).takeWhile(n -> n < 4); // [1, 2, 3]
Stream.of(1,2,3,4,5).dropWhile(n -> n < 4); // [4, 5]
Terminal operations
List<Integer> nums = List.of(1, 2, 3, 4, 5);
// Collect
nums.stream().collect(Collectors.toList()); // mutable list
nums.stream().toList(); // unmodifiable list (Java 16+)
nums.stream().collect(Collectors.toSet());
// Reduction
nums.stream().reduce(0, Integer::sum); // 15
nums.stream().mapToInt(Integer::intValue).sum(); // 15 โ no Optional
// Search (short-circuit โ stops as soon as result is found)
nums.stream().findFirst(); // Optional[1]
nums.stream().filter(n -> n > 3).findFirst(); // Optional[4]
nums.stream().anyMatch(n -> n > 3); // true
nums.stream().allMatch(n -> n > 0); // true
nums.stream().noneMatch(n -> n < 0); // true
// Count / statistics
nums.stream().count(); // 5
nums.stream().max(Integer::compare); // Optional[5]
nums.stream().mapToInt(Integer::intValue).average(); // OptionalDouble[3.0]
Collectors โ Grouping and Aggregating
record Person(String name, int age, String dept) {}
List<Person> people = List.of(
new Person("Alice", 25, "Eng"), new Person("Bob", 30, "Eng"),
new Person("Carol", 35, "Mkt"), new Person("Dan", 28, "Mkt")
);
// Joining strings
people.stream().map(Person::name)
.collect(Collectors.joining(", ", "[", "]")); // [Alice, Bob, Carol, Dan]
// groupingBy โ Map<K, List<V>>
Map<String, List<Person>> byDept = people.stream()
.collect(Collectors.groupingBy(Person::dept));
// groupingBy with downstream collector
Map<String, Long> countByDept = people.stream()
.collect(Collectors.groupingBy(Person::dept, Collectors.counting()));
Map<String, Double> avgAge = people.stream()
.collect(Collectors.groupingBy(Person::dept,
Collectors.averagingInt(Person::age)));
Map<String, Optional<Person>> oldest = people.stream()
.collect(Collectors.groupingBy(Person::dept,
Collectors.maxBy(Comparator.comparingInt(Person::age))));
// partitioningBy โ Map<Boolean, List> (binary split)
Map<Boolean, List<Person>> senior = people.stream()
.collect(Collectors.partitioningBy(p -> p.age() >= 30));
// {false=[Alice, Dan], true=[Bob, Carol]}
// toMap โ watch out for duplicate keys (throws by default)
Map<String, Integer> nameToAge = people.stream()
.collect(Collectors.toMap(Person::name, Person::age));
// toMap with merge function for duplicates
Map<String, Integer> maxAgeByDept = people.stream()
.collect(Collectors.toMap(Person::dept, Person::age, Integer::max));
Parallel Streams
One word converts a stream to parallel: .parallelStream() or
.parallel(). The stream splits the data, processes chunks on
the ForkJoinPool.commonPool(), and merges results. It is
not always faster โ the overhead of splitting and merging
exceeds the benefit for small datasets.
// CPU-bound work on large data: parallel wins
long sum = IntStream.rangeClosed(1, 10_000_000)
.parallel()
.asLongStream()
.sum();
// โ
Use parallel when:
// - Large dataset (tens of thousands+ elements)
// - CPU-intensive operation per element
// - Stateless, independent operations
// - Source is easily splittable (ArrayList, arrays โ NOT LinkedList)
// โ Avoid parallel when:
// - Small dataset (overhead > gain)
// - I/O-bound work (blocks ForkJoinPool threads โ starves other parallel streams)
// - Order matters AND you can't afford forEachOrdered() cost
// - Shared mutable state (race conditions)
// Custom pool โ avoid starving common pool with I/O
ForkJoinPool pool = new ForkJoinPool(4);
long result = pool.submit(() ->
data.parallelStream().mapToLong(Item::computeExpensive).sum()
).get();
pool.shutdown();
Common Pitfalls
Stream<String> stream = Stream.of("a", "b");
stream.forEach(System.out::println); // โ
stream.forEach(System.out::println); // โ IllegalStateException: stream already consumed
// โ
Collect first if you need to iterate multiple times
List<String> list = Stream.of("a", "b").toList();
// โ Nothing prints โ peek() is lazy, no terminal op
names.stream()
.filter(n -> n.length() > 3)
.peek(System.out::println); // never executes!
// โ
Add terminal operation
names.stream()
.filter(n -> n.length() > 3)
.peek(System.out::println)
.toList();
// โ forEach adding to external list โ not thread-safe with parallel
List<String> result = new ArrayList<>();
names.stream().filter(n -> n.length() > 3).forEach(result::add);
// โ
Use collect() โ works correctly with both sequential and parallel
List<String> result = names.stream()
.filter(n -> n.length() > 3)
.collect(Collectors.toList());
List<String> names = Arrays.asList("Alice", null, "Bob");
names.stream().map(String::toUpperCase).toList(); // โ NullPointerException
// โ
Filter nulls explicitly
names.stream()
.filter(Objects::nonNull)
.map(String::toUpperCase)
.toList();
Senior Topics: Advanced Patterns
Real-world pipeline: order processing
// Revenue per category, using BigDecimal for money
Map<String, BigDecimal> revenueByCategory = orders.stream()
.flatMap(o -> o.items().stream()) // Order โ OrderItem
.collect(Collectors.groupingBy(
OrderItem::category,
Collectors.reducing(
BigDecimal.ZERO,
item -> item.price().multiply(BigDecimal.valueOf(item.qty())),
BigDecimal::add
)
));
// High-value customers (total spent > 1000)
List<String> whales = orders.stream()
.collect(Collectors.groupingBy(Order::customerId,
Collectors.summingDouble(Order::total)))
.entrySet().stream()
.filter(e -> e.getValue() > 1000)
.map(Map.Entry::getKey)
.toList();
Custom Collector
// When built-in collectors aren't enough: implement Collector<T, A, R>
// T=input, A=accumulator, R=result
Collector<String, StringJoiner, String> csvCollector =
Collector.of(
() -> new StringJoiner(","), // supplier
StringJoiner::add, // accumulator
StringJoiner::merge, // combiner (for parallel)
StringJoiner::toString // finisher
);
String csv = Stream.of("Alice", "Bob", "Carol").collect(csvCollector);
// "Alice,Bob,Carol"
Lazy evaluation โ performance implications
// Intermediate operations fuse into a single pass over the data.
// This pipeline does NOT create 3 intermediate lists:
long count = IntStream.rangeClosed(1, 1_000_000)
.filter(n -> n % 2 == 0) // \
.filter(n -> n % 3 == 0) // fused into one pass
.map(n -> n * n) // /
.count();
// Short-circuit: stops as soon as result is found โ never touches the rest
Optional<Integer> first = IntStream.rangeClosed(1, 1_000_000)
.filter(n -> n % 17 == 0)
.findFirst(); // stops at 17 โ doesn't process 999,983 more elements
// Order operations from most restrictive to least:
// filter early โ fewer elements for expensive map/sorted
users.stream()
.filter(User::isActive) // cheap: boolean check, eliminates many
.filter(u -> u.age() >= 18) // cheap: int comparison
.map(User::loadFullProfile) // expensive: DB call โ only on survivors
.sorted(Comparator.comparing(User::score))
.limit(10)
.toList();
Interview Questions
Q: What is a lambda expression?
An anonymous function โ a block of code with parameters and a body,
with no name and no class. Used wherever a functional interface is
expected. Syntax: (params) -> expression. The compiler
infers the functional interface type from context.
Q: What is the difference between intermediate and terminal operations?
Intermediate operations (filter, map, sorted)
return a new Stream and are lazy โ they don't execute until a terminal
operation is called. Terminal operations (collect, forEach,
reduce, count) trigger the pipeline and produce a result
or side effect. A stream can only be consumed by one terminal operation.
Q: What does "effectively final" mean for lambdas?
A local variable that is never reassigned after initialisation. Lambdas can
only capture local variables that are effectively final โ because the lambda
captures a copy of the value and the copy must remain consistent. Reassigning
the variable would make the copy stale. Instance and static variables don't
have this restriction.
Q: What is the difference between map() and flatMap()?
map() transforms each element to exactly one output โ one-to-one.
flatMap() transforms each element to a Stream of zero or more outputs,
then flattens all those streams into one โ one-to-many. Classic use:
orders.stream().flatMap(o -> o.items().stream()) gives all items
across all orders as a flat sequence.
Q: How does lazy evaluation benefit streams?
(1) Operation fusion: multiple intermediate operations are
merged into a single pass over the data โ no intermediate collections created.
(2) Short-circuiting: findFirst(),
anyMatch(), limit() stop processing as soon as
the answer is known โ potentially saving millions of iterations.
(3) Infinite streams become possible:
Stream.iterate(0, n -> n+1) is infinite; lazy evaluation means
only the elements you actually consume are generated.
Q: When should you NOT use parallel streams?
(1) I/O-bound work: blocking operations hold
ForkJoinPool.commonPool() threads, starving other parallel streams
across the JVM. Use a dedicated pool or virtual threads instead.
(2) Small data: the cost of splitting + merging exceeds the
gain โ parallel streams break even at roughly 10,000+ elements for simple
operations. (3) Ordering required: parallel streams process
elements in arbitrary order โ forEachOrdered() restores order
but eliminates most of the parallelism benefit.
(4) Shared mutable state: race conditions. Always benchmark
before reaching for parallelStream().
Q: How would you implement a custom Collector?
Implement Collector<T, A, R> or use
Collector.of(supplier, accumulator, combiner, finisher).
The combiner is critical for parallel correctness โ it
merges two partial accumulators from different threads. If your combiner
is wrong, the parallel result will be corrupted. For most custom aggregations,
start with Collectors.toMap(), groupingBy() with
a downstream, or reducing() before writing a full custom
Collector.
Q: What is the difference between Stream.forEach() and Iterable.forEach()?
Iterable.forEach() (on List, Set, etc.) iterates the collection
directly โ no stream overhead, always sequential, order is defined by the
collection. Stream.forEach() is a terminal stream operation โ
order is undefined for parallel streams (forEachOrdered() fixes
this). For simple iteration with side effects on a collection, prefer
list.forEach(). Use stream .forEach() only at the
end of a stream pipeline.