Lambda Expressions & Streams

Functional Programming in Java

← Back to Index

Introduction

Lambda expressions and the Stream API, introduced in Java 8 (2014), represent the most significant paradigm shift in Java's history. They brought functional programming capabilities to a traditionally object-oriented language, fundamentally changing how Java developers write code.

Before Java 8, processing collections required verbose imperative code with explicit loops and mutable state. Lambda expressions enable treating functions as first-class citizens—passing behavior as data. The Stream API provides a declarative, pipeline-based approach to data processing that's more readable, composable, and parallelizable.

The Java 8 Revolution: Lambda expressions weren't just syntactic sugar. They required fundamental changes to the JVM (invokedynamic instruction), the type system (functional interfaces, type inference improvements), and the standard library (java.util.function, Stream API). This was years in development under Project Lambda.

The Problem Lambdas Solve

To understand why lambdas matter, consider how Java handled behavior parameterization before Java 8:


┌─────────────────────────────────────────────────────────────────┐
│                    BEFORE JAVA 8: Anonymous Classes             │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  // To sort a list by length, you needed THIS:                  │
│                                                                 │
│  Collections.sort(names, new Comparator<String>() {            │
│      @Override                                                  │
│      public int compare(String s1, String s2) {                 │
│          return Integer.compare(s1.length(), s2.length());      │
│      }                                                          │
│  });                                                            │
│                                                                 │
│  Problem: 6 lines of boilerplate for 1 line of logic!           │
│                                                                 │
├─────────────────────────────────────────────────────────────────┤
│                    AFTER JAVA 8: Lambda Expression              │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  // Now you can write THIS:                                     │
│                                                                 │
│  names.sort((s1, s2) -> Integer.compare(s1.length(), s2.length()));
│                                                                 │
│  // Or even better with method reference:                       │
│                                                                 │
│  names.sort(Comparator.comparingInt(String::length));           │
│                                                                 │
│  Benefit: Express intent clearly, let compiler handle ceremony  │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘
            

Lambda Expressions

A lambda expression is an anonymous function—a block of code that can be passed around and executed later. Unlike methods, lambdas don't belong to a class; they represent pure behavior.

Lambda Syntax


┌─────────────────────────────────────────────────────────────────┐
│                      LAMBDA SYNTAX                              │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│   (parameters) -> expression                                    │
│                                                                 │
│   (parameters) -> { statements; }                               │
│                                                                 │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│   Components:                                                   │
│   ┌──────────┐   ┌─────┐   ┌────────────────┐                  │
│   │Parameters│ → │Arrow│ → │Body (expression│                  │
│   │  (x, y)  │   │  -> │   │  or block)     │                  │
│   └──────────┘   └─────┘   └────────────────┘                  │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘
            
// No parameters
() -> System.out.println("Hello")

// One parameter (parentheses optional)
x -> x * x
(x) -> x * x  // equivalent

// Multiple parameters (parentheses required)
(x, y) -> x + y

// Explicit types (usually inferred)
(Integer x, Integer y) -> x + y

// With code block (braces and return required)
(x, y) -> {
    int sum = x + y;
    return sum;
}

// Multi-line body
(x, y) -> {
    if (x > y) {
        return x;
    } else {
        return y;
    }
}

Before vs After: Real Examples

List<String> names = Arrays.asList("Alice", "Bob", "Charlie");

// ─── BEFORE: Anonymous class (verbose) ───
Collections.sort(names, new Comparator<String>() {
    @Override
    public int compare(String s1, String s2) {
        return s1.compareTo(s2);
    }
});

// ─── AFTER: Lambda expression (concise) ───
Collections.sort(names, (s1, s2) -> s1.compareTo(s2));

// ─── EVEN BETTER: Method reference ───
Collections.sort(names, String::compareTo);

// ─── BEST: List.sort() with Comparator ───
names.sort(String::compareTo);

Variable Capture and Effectively Final

Lambdas can capture (use) variables from their enclosing scope, but with restrictions:

int multiplier = 3;  // Effectively final (never modified)
List<Integer> numbers = Arrays.asList(1, 2, 3, 4);

// ✅ This works - multiplier is effectively final
List<Integer> result = numbers.stream()
    .map(n -> n * multiplier)
    .collect(Collectors.toList());

// ❌ This would NOT compile:
// multiplier = 4;  // Makes multiplier NOT effectively final
// Lambdas cannot capture mutable local variables

// Why? Lambdas may execute later (different thread), and Java
// avoids the complexity of capturing mutable state
Effectively Final Rule: Local variables used in lambdas must be effectively final—either explicitly declared final or never modified after initialization. Instance and static variables don't have this restriction, but modifying them from lambdas can cause thread-safety issues.

Functional Interfaces

A functional interface is an interface with exactly one abstract method (SAM - Single Abstract Method). Lambdas are the implementations of these interfaces. The @FunctionalInterface annotation is optional but recommended—it causes a compile error if the interface has more than one abstract method.

@FunctionalInterface
interface Calculator {
    int calculate(int a, int b);

    // Default methods are allowed (not abstract)
    default int addThenDouble(int a, int b) {
        return calculate(a, b) * 2;
    }

    // Static methods are allowed
    static Calculator adder() {
        return (a, b) -> a + b;
    }
}

// Using lambdas to implement the interface
Calculator add = (a, b) -> a + b;
Calculator multiply = (a, b) -> a * b;
Calculator max = (a, b) -> a > b ? a : b;

int sum = add.calculate(5, 3);           // 8
int product = multiply.calculate(5, 3);  // 15
int doubled = add.addThenDouble(5, 3);   // 16

Built-in Functional Interfaces (java.util.function)

Java provides a comprehensive set of functional interfaces so you rarely need to define your own:


┌─────────────────────────────────────────────────────────────────┐
│                  CORE FUNCTIONAL INTERFACES                     │
├─────────────────┬───────────────┬───────────────┬──────────────┤
│   Interface     │   Input       │   Output      │   Method     │
├─────────────────┼───────────────┼───────────────┼──────────────┤
│   Predicate<T>  │   T           │   boolean     │   test()     │
│   Function<T,R> │   T           │   R           │   apply()    │
│   Consumer<T>   │   T           │   void        │   accept()   │
│   Supplier<T>   │   (none)      │   T           │   get()      │
├─────────────────┼───────────────┼───────────────┼──────────────┤
│   BiPredicate   │   T, U        │   boolean     │   test()     │
│   BiFunction    │   T, U        │   R           │   apply()    │
│   BiConsumer    │   T, U        │   void        │   accept()   │
├─────────────────┼───────────────┼───────────────┼──────────────┤
│   UnaryOperator │   T           │   T           │   apply()    │
│   BinaryOperator│   T, T        │   T           │   apply()    │
└─────────────────┴───────────────┴───────────────┴──────────────┘
            
import java.util.function.*;

// ─── Predicate: T → boolean (for filtering/testing) ───
Predicate<Integer> isEven = num -> num % 2 == 0;
Predicate<String> isNotEmpty = str -> !str.isEmpty();

isEven.test(4);                         // true
isEven.and(n -> n > 0).test(4);         // true (chaining)
isEven.negate().test(4);                // false

// ─── Function: T → R (for transformation) ───
Function<String, Integer> length = str -> str.length();
Function<Integer, String> intToString = Object::toString;

length.apply("Hello");                  // 5
length.andThen(intToString).apply("Hi"); // "2" (chaining)

// ─── Consumer: T → void (for side effects) ───
Consumer<String> print = msg -> System.out.println(msg);
Consumer<String> log = msg -> logger.info(msg);

print.accept("Hello");                  // Prints "Hello"
print.andThen(log).accept("Hello");     // Print then log

// ─── Supplier: () → T (for lazy generation) ───
Supplier<Double> random = () -> Math.random();
Supplier<LocalDateTime> now = LocalDateTime::now;

double value = random.get();            // Random number

// ─── BiFunction: (T, U) → R (two inputs) ───
BiFunction<String, String, String> concat = (a, b) -> a + b;
concat.apply("Hello, ", "World");       // "Hello, World"

// ─── UnaryOperator: T → T (same type in/out) ───
UnaryOperator<Integer> square = x -> x * x;
UnaryOperator<String> toUpper = String::toUpperCase;

// ─── BinaryOperator: (T, T) → T (for reduce operations) ───
BinaryOperator<Integer> sum = (a, b) -> a + b;
BinaryOperator<Integer> max = Integer::max;

Method References

Method references are shorthand for lambdas that simply call an existing method. They're more concise and clearly express the intent to delegate to an existing method.


┌─────────────────────────────────────────────────────────────────┐
│                    METHOD REFERENCE TYPES                       │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  Type                     │ Syntax           │ Lambda Equivalent│
│  ─────────────────────────┼──────────────────┼─────────────────│
│  Static method            │ Class::method    │ x -> Class.method(x)
│  Instance method (object) │ object::method   │ x -> object.method(x)
│  Instance method (type)   │ Class::method    │ (obj,x) -> obj.method(x)
│  Constructor              │ Class::new       │ x -> new Class(x)│
│                                                                 │
└─────────────────────────────────────────────────────────────────┘
            
List<String> names = Arrays.asList("Alice", "Bob", "Charlie");

// ─── Static Method Reference ───
// Lambda:          x -> Integer.parseInt(x)
// Method ref:      Integer::parseInt
Function<String, Integer> parser = Integer::parseInt;

// ─── Instance Method Reference (bound to object) ───
// Lambda:          x -> System.out.println(x)
// Method ref:      System.out::println
names.forEach(System.out::println);

String prefix = "Hello, ";
// Lambda:          x -> prefix.concat(x)
Function<String, String> greeter = prefix::concat;

// ─── Instance Method Reference (unbound - on type) ───
// Lambda:          (s1, s2) -> s1.compareTo(s2)
// Method ref:      String::compareTo
names.sort(String::compareToIgnoreCase);

// Lambda:          s -> s.toUpperCase()
// Method ref:      String::toUpperCase
List<String> upper = names.stream()
    .map(String::toUpperCase)
    .collect(Collectors.toList());

// ─── Constructor Reference ───
// Lambda:          s -> new StringBuilder(s)
// Method ref:      StringBuilder::new
Function<String, StringBuilder> sbCreator = StringBuilder::new;

// Creating objects in streams
List<Person> people = names.stream()
    .map(Person::new)  // Calls new Person(name)
    .collect(Collectors.toList());

// Array constructor reference
String[] array = names.stream().toArray(String[]::new);

Stream API

A Stream is a sequence of elements supporting sequential and parallel aggregate operations. Streams don't store data—they convey data from a source (collection, array, generator, I/O channel) through a pipeline of operations.


┌─────────────────────────────────────────────────────────────────┐
│                     STREAM PIPELINE                             │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│   ┌──────────┐   ┌─────────────────┐   ┌───────────────┐       │
│   │  SOURCE  │ → │  INTERMEDIATE   │ → │   TERMINAL    │       │
│   │          │   │   OPERATIONS    │   │   OPERATION   │       │
│   └──────────┘   └─────────────────┘   └───────────────┘       │
│                                                                 │
│   Collection     filter(), map(),      collect(), forEach(),   │
│   Array          sorted(), distinct(), reduce(), count(),      │
│   Generator      limit(), skip(),      findFirst(), anyMatch() │
│   I/O Channel    flatMap(), peek()     toArray(), min(), max() │
│                                                                 │
│   ┌─────────────────────────────────────────────────────────┐  │
│   │  LAZY EVALUATION: Intermediate operations don't execute │  │
│   │  until a terminal operation is invoked!                 │  │
│   └─────────────────────────────────────────────────────────┘  │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘
            

Creating Streams

// From Collection
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
Stream<Integer> stream = numbers.stream();

// From Array
String[] arr = {"A", "B", "C"};
Stream<String> arrStream = Arrays.stream(arr);

// Using Stream.of()
Stream<String> ofStream = Stream.of("X", "Y", "Z");

// Empty stream
Stream<Object> empty = Stream.empty();

// Infinite streams (use with limit()!)
Stream<Integer> infinite = Stream.iterate(0, n -> n + 2);  // 0, 2, 4, 6...
Stream<Double> randoms = Stream.generate(Math::random);   // Random numbers

// Finite iterate (Java 9+)
Stream<Integer> finite = Stream.iterate(0, n -> n < 10, n -> n + 1);

// From Builder
Stream<String> built = Stream.<String>builder()
    .add("one")
    .add("two")
    .add("three")
    .build();

// Primitive streams (avoid boxing overhead)
IntStream intStream = IntStream.range(1, 6);       // 1, 2, 3, 4, 5
IntStream inclusive = IntStream.rangeClosed(1, 5); // 1, 2, 3, 4, 5
LongStream longStream = LongStream.of(1L, 2L, 3L);
DoubleStream doubleStream = DoubleStream.of(1.0, 2.0);

Intermediate Operations (Lazy)

List<String> names = Arrays.asList("Alice", "Bob", "Charlie", "Alice");

// ─── filter(): Keep elements matching predicate ───
names.stream()
    .filter(name -> name.startsWith("A"))  // ["Alice", "Alice"]

// ─── map(): Transform each element ───
names.stream()
    .map(String::toUpperCase)  // ["ALICE", "BOB", "CHARLIE", "ALICE"]

// ─── flatMap(): Flatten nested structures ───
List<List<Integer>> nested = Arrays.asList(
    Arrays.asList(1, 2),
    Arrays.asList(3, 4)
);
nested.stream()
    .flatMap(List::stream)  // [1, 2, 3, 4]

// ─── distinct(): Remove duplicates ───
names.stream()
    .distinct()  // ["Alice", "Bob", "Charlie"]

// ─── sorted(): Sort elements ───
names.stream()
    .sorted()                                   // Natural order
    .sorted(Comparator.reverseOrder())          // Reverse
    .sorted(Comparator.comparingInt(String::length))  // By length

// ─── limit(): Take first N elements ───
names.stream()
    .limit(2)  // ["Alice", "Bob"]

// ─── skip(): Skip first N elements ───
names.stream()
    .skip(2)  // ["Charlie", "Alice"]

// ─── peek(): Debug without modifying (for side effects) ───
names.stream()
    .peek(name -> System.out.println("Processing: " + name))
    .map(String::toUpperCase)
    .collect(Collectors.toList());

// ─── takeWhile() / dropWhile() (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 (Trigger Execution)

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);

// ─── collect(): Gather results into collection ───
List<Integer> list = numbers.stream()
    .filter(n -> n > 2)
    .collect(Collectors.toList());

Set<Integer> set = numbers.stream()
    .collect(Collectors.toSet());

// ─── forEach(): Execute action for each element ───
numbers.stream().forEach(System.out::println);

// ─── reduce(): Combine all elements into one ───
int sum = numbers.stream()
    .reduce(0, (a, b) -> a + b);  // 15

Optional<Integer> max = numbers.stream()
    .reduce(Integer::max);  // Optional[5]

// ─── count(): Count elements ───
long count = numbers.stream().count();  // 5

// ─── min() / max(): Find extremes ───
Optional<Integer> min = numbers.stream().min(Integer::compare);
Optional<Integer> maxVal = numbers.stream().max(Integer::compare);

// ─── findFirst() / findAny(): Get element ───
Optional<Integer> first = numbers.stream()
    .filter(n -> n > 3)
    .findFirst();  // Optional[4]

// ─── anyMatch() / allMatch() / noneMatch(): Test predicates ───
boolean hasEven = numbers.stream().anyMatch(n -> n % 2 == 0);   // true
boolean allPos = numbers.stream().allMatch(n -> n > 0);          // true
boolean noneNeg = numbers.stream().noneMatch(n -> n < 0);        // true

// ─── toArray(): Convert to array ───
Integer[] arr = numbers.stream().toArray(Integer[]::new);

Collectors Deep Dive

The Collectors utility class provides powerful reduction operations for stream pipelines:

List<Person> people = Arrays.asList(
    new Person("Alice", 25, "Engineering"),
    new Person("Bob", 30, "Engineering"),
    new Person("Charlie", 35, "Marketing"),
    new Person("Diana", 28, "Marketing")
);

// ─── Basic Collection ───
List<String> names = people.stream()
    .map(Person::getName)
    .collect(Collectors.toList());

Set<String> departments = people.stream()
    .map(Person::getDepartment)
    .collect(Collectors.toSet());

// ─── Joining Strings ───
String joined = people.stream()
    .map(Person::getName)
    .collect(Collectors.joining(", "));  // "Alice, Bob, Charlie, Diana"

String formatted = people.stream()
    .map(Person::getName)
    .collect(Collectors.joining(", ", "[People: ", "]"));
    // "[People: Alice, Bob, Charlie, Diana]"

// ─── Grouping By ───
Map<String, List<Person>> byDept = people.stream()
    .collect(Collectors.groupingBy(Person::getDepartment));
// {Engineering=[Alice, Bob], Marketing=[Charlie, Diana]}

// Grouping with downstream collector
Map<String, Long> countByDept = people.stream()
    .collect(Collectors.groupingBy(
        Person::getDepartment,
        Collectors.counting()
    ));
// {Engineering=2, Marketing=2}

Map<String, Double> avgAgeByDept = people.stream()
    .collect(Collectors.groupingBy(
        Person::getDepartment,
        Collectors.averagingInt(Person::getAge)
    ));
// {Engineering=27.5, Marketing=31.5}

// ─── Partitioning (boolean grouping) ───
Map<Boolean, List<Person>> partitioned = people.stream()
    .collect(Collectors.partitioningBy(p -> p.getAge() >= 30));
// {false=[Alice, Diana], true=[Bob, Charlie]}

// ─── To Map ───
Map<String, Integer> nameToAge = people.stream()
    .collect(Collectors.toMap(
        Person::getName,
        Person::getAge
    ));
// {Alice=25, Bob=30, Charlie=35, Diana=28}

// Handle duplicate keys
Map<String, Integer> deptToMaxAge = people.stream()
    .collect(Collectors.toMap(
        Person::getDepartment,
        Person::getAge,
        Integer::max  // Merge function for duplicates
    ));

// ─── Statistics ───
IntSummaryStatistics stats = people.stream()
    .collect(Collectors.summarizingInt(Person::getAge));
// count=4, sum=118, min=25, average=29.5, max=35

Parallel Streams

Streams can easily be parallelized to leverage multi-core processors. However, parallelization has overhead and isn't always beneficial.


┌─────────────────────────────────────────────────────────────────┐
│                    PARALLEL STREAM EXECUTION                    │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  Sequential:   [1, 2, 3, 4, 5, 6, 7, 8] ─────────────→ Result  │
│                      ↓ single thread                            │
│                                                                 │
│  Parallel:     [1, 2, 3, 4, 5, 6, 7, 8]                        │
│                      │                                          │
│                      ├── Thread 1: [1, 2] ──┐                   │
│                      ├── Thread 2: [3, 4] ──┼──→ Combine → Result
│                      ├── Thread 3: [5, 6] ──┤                   │
│                      └── Thread 4: [7, 8] ──┘                   │
│                                                                 │
│  Uses ForkJoinPool.commonPool() by default                      │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘
            
List<Integer> numbers = IntStream.rangeClosed(1, 1_000_000)
    .boxed()
    .collect(Collectors.toList());

// ─── Creating Parallel Streams ───
long sum1 = numbers.parallelStream()
    .mapToLong(Integer::longValue)
    .sum();

long sum2 = numbers.stream()
    .parallel()  // Convert existing stream to parallel
    .mapToLong(Integer::longValue)
    .sum();

// ─── When Parallel Helps ───
// ✅ Large datasets (thousands+ elements)
// ✅ CPU-intensive operations per element
// ✅ Stateless, independent operations
// ✅ Easy-to-split data sources (ArrayList, arrays)

// ─── When Parallel Hurts ───
// ❌ Small datasets (overhead > benefit)
// ❌ I/O-bound operations
// ❌ LinkedList, Streams with limit()
// ❌ Operations with ordering requirements
// ❌ Shared mutable state
Parallel Stream Pitfalls:
  • Don't use with blocking I/O - monopolizes common pool threads
  • Avoid shared mutable state - causes race conditions
  • Order may not be preserved - use forEachOrdered() if needed
  • Measure before using - parallel isn't always faster

Practical Examples

Example 1: Processing User Data

class User {
    private String name;
    private int age;
    private List<String> roles;
    private boolean active;
    // getters...
}

List<User> users = getUsers();

// Find all active admin users over 18, sorted by name
List<User> activeAdmins = users.stream()
    .filter(User::isActive)
    .filter(u -> u.getAge() >= 18)
    .filter(u -> u.getRoles().contains("ADMIN"))
    .sorted(Comparator.comparing(User::getName))
    .collect(Collectors.toList());

// Get all unique roles across all users
Set<String> allRoles = users.stream()
    .flatMap(u -> u.getRoles().stream())
    .collect(Collectors.toSet());

// Count users by role
Map<String, Long> usersByRole = users.stream()
    .flatMap(u -> u.getRoles().stream())
    .collect(Collectors.groupingBy(
        Function.identity(),
        Collectors.counting()
    ));

Example 2: File Processing

// Read file, process lines, count word frequencies
Map<String, Long> wordFrequency;

try (Stream<String> lines = Files.lines(Path.of("document.txt"))) {
    wordFrequency = lines
        .flatMap(line -> Arrays.stream(line.split("\\s+")))
        .map(String::toLowerCase)
        .filter(word -> !word.isEmpty())
        .collect(Collectors.groupingBy(
            Function.identity(),
            Collectors.counting()
        ));
}

// Find top 10 most frequent words
List<Map.Entry<String, Long>> top10 = wordFrequency.entrySet().stream()
    .sorted(Map.Entry.<String, Long>comparingByValue().reversed())
    .limit(10)
    .collect(Collectors.toList());

Example 3: E-commerce Order Processing

List<Order> orders = getOrders();

// Calculate total revenue by product category
Map<String, BigDecimal> revenueByCategory = orders.stream()
    .flatMap(order -> order.getItems().stream())
    .collect(Collectors.groupingBy(
        OrderItem::getCategory,
        Collectors.reducing(
            BigDecimal.ZERO,
            item -> item.getPrice().multiply(BigDecimal.valueOf(item.getQuantity())),
            BigDecimal::add
        )
    ));

// Find customers who spent more than $1000
List<Customer> highValueCustomers = orders.stream()
    .collect(Collectors.groupingBy(
        Order::getCustomer,
        Collectors.summingDouble(Order::getTotal)
    ))
    .entrySet().stream()
    .filter(e -> e.getValue() > 1000)
    .map(Map.Entry::getKey)
    .collect(Collectors.toList());

// Get orders from last 30 days, grouped by status
LocalDate thirtyDaysAgo = LocalDate.now().minusDays(30);

Map<OrderStatus, List<Order>> recentByStatus = orders.stream()
    .filter(o -> o.getOrderDate().isAfter(thirtyDaysAgo))
    .collect(Collectors.groupingBy(Order::getStatus));

Common Pitfalls

⚠️ Pitfall 1: Reusing Streams

Streams can only be consumed once. After a terminal operation, the stream is closed.

Stream<String> stream = Stream.of("a", "b", "c");

stream.forEach(System.out::println);  // Works
stream.forEach(System.out::println);  // IllegalStateException!

// Solution: Create a new stream each time, or collect first
List<String> list = Stream.of("a", "b", "c").collect(Collectors.toList());
list.forEach(System.out::println);  // Works
list.forEach(System.out::println);  // Works again

⚠️ Pitfall 2: Side Effects in Streams

Avoid modifying external state in stream operations—it's error-prone and breaks parallelism.

// ❌ BAD: Mutating external collection
List<String> results = new ArrayList<>();
names.stream()
    .filter(n -> n.length() > 3)
    .forEach(results::add);  // Not thread-safe!

// ✅ GOOD: Use collect()
List<String> results = names.stream()
    .filter(n -> n.length() > 3)
    .collect(Collectors.toList());

⚠️ Pitfall 3: Infinite Streams Without Limit

Infinite streams without a short-circuit operation will run forever.

// ❌ BAD: Never terminates!
Stream.iterate(0, n -> n + 1)
    .filter(n -> n > 100)  // Always more to check
    .collect(Collectors.toList());

// ✅ GOOD: Use limit()
Stream.iterate(0, n -> n + 1)
    .limit(200)
    .filter(n -> n > 100)
    .collect(Collectors.toList());

// ✅ GOOD: Use takeWhile() (Java 9+)
Stream.iterate(0, n -> n + 1)
    .takeWhile(n -> n <= 200)
    .filter(n -> n > 100)
    .collect(Collectors.toList());

⚠️ Pitfall 4: Forgetting Streams Are Lazy

Without a terminal operation, nothing happens!

// ❌ BAD: Nothing prints! peek() is lazy
names.stream()
    .filter(n -> n.length() > 3)
    .peek(System.out::println);  // No terminal operation!

// ✅ GOOD: Add terminal operation
names.stream()
    .filter(n -> n.length() > 3)
    .peek(System.out::println)
    .collect(Collectors.toList());  // Now it executes

⚠️ Pitfall 5: NullPointerException in Streams

Streams don't handle nulls gracefully in most operations.

List<String> names = Arrays.asList("Alice", null, "Bob");

// ❌ BAD: NullPointerException
names.stream()
    .map(String::toUpperCase)
    .collect(Collectors.toList());

// ✅ GOOD: Filter nulls first
names.stream()
    .filter(Objects::nonNull)
    .map(String::toUpperCase)
    .collect(Collectors.toList());

// ✅ GOOD: Use Optional in map
names.stream()
    .map(n -> Optional.ofNullable(n).map(String::toUpperCase).orElse(""))
    .collect(Collectors.toList());

Interview Questions

Q1: What's 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. They can be chained.

Terminal operations (collect, forEach, reduce) trigger the actual processing of the pipeline and produce a result or side-effect. After a terminal operation, the stream cannot be reused.

Q2: What is a functional interface? Name some built-in ones.

A functional interface has exactly one abstract method. Examples: Predicate<T> (test), Function<T,R> (apply), Consumer<T> (accept), Supplier<T> (get), Comparator<T> (compare), Runnable (run), Callable<V> (call).

Q3: What does "effectively final" mean?

A variable is effectively final if it's never modified after initialization. Lambdas can only capture local variables that are effectively final. This avoids the complexity of capturing mutable state across threads.

Q4: Explain flatMap vs map.

map() transforms each element to exactly one output element. flatMap() transforms each element to zero or more elements and flattens the result into a single stream.

// map: Stream<List<String>> → Stream<List<String>>
// flatMap: Stream<List<String>> → Stream<String>

Q5: When should you use parallel streams?

Use parallel streams when: large datasets (thousands+), CPU-intensive operations, stateless operations, easily splittable sources (ArrayList, arrays). Avoid for: small datasets, I/O-bound work, operations requiring order, shared mutable state.

Q6: What are the four types of method references?

  1. Static: ClassName::staticMethod
  2. Bound instance: object::instanceMethod
  3. Unbound instance: ClassName::instanceMethod
  4. Constructor: ClassName::new

Q7: How does lazy evaluation benefit streams?

Lazy evaluation means operations only execute when needed. Benefits: (1) short-circuit operations like findFirst() can stop early, (2) intermediate operations are fused into a single pass, (3) infinite streams are possible, (4) no unnecessary computation if result isn't used.

Best Practices

Why It Matters

See Also