Lambda Expressions & Streams

Functional programming in Java โ€” treat behaviour as data, process collections as pipelines

← Back to Index

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());
Streams don't store data

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

Streams can only be consumed once
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();
No terminal operation = nothing runs
// โŒ 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();
Side effects in stream operations
// โŒ 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());
Null elements in streams
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

๐ŸŽ“ Junior level

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.

๐Ÿ”ฅ Senior level

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.