Skip to content

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
Predicate<String> isEmpty = String::isEmpty;
Predicate<Integer> isEven = n -> n % 2 == 0;
Function<String, Integer> length = String::length;
Consumer<String> print = System.out::println;
Supplier<Double> random = Math::random;

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 elements
  • map(Function) — transform elements
  • flatMap(Function) — flatten nested structures
  • distinct() — remove duplicates
  • sorted() / sorted(Comparator) — sort
  • limit(n) / skip(n) — take/skip n elements
  • peek(Consumer) — debug without affecting stream

Terminal operations (eager, produce result):

  • collect(Collector) — gather into collection
  • forEach(Consumer) — iterate
  • reduce(BinaryOperator) — combine elements
  • count(), min(), max()
  • anyMatch(), allMatch(), noneMatch()
  • findFirst(), findAny()
List<String> result = names.stream()
    .filter(name -> name.length() > 3)  // Intermediate
    .map(String::toUpperCase)           // Intermediate
    .sorted()                           // Intermediate
    .collect(Collectors.toList());      // Terminal — triggers execution

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
Stream<String> stream = names.stream();
stream.forEach(System.out::println);  // OK
stream.forEach(System.out::println);  // IllegalStateException!

Common Interview Questions

Common Interview Questions
  • What is a lambda expression?
  • What is a functional interface?
  • What are the differences between Predicate, Function, Consumer, and Supplier?
  • 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() and flatMap()?
  • 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()?