25:00
Focus
Lesson 2
~7 min75 XP

Functional Programming with Streams and Lambdas

Introduction

Java's evolution from a purely object-oriented language to one that embraces functional programming was a watershed moment. In this lesson, you'll master lambda expressions, functional interfaces, and the powerful Stream API — tools that let you process collections with declarative, readable, and often parallelizable code. By the end, you'll think about data transformation in an entirely new way.

Lambda Expressions: Functions as First-Class Citizens

Before Java 8, passing behavior as an argument required verbose anonymous inner classes. Lambda expressions changed everything by letting you express instances of single-method interfaces concisely.

A lambda expression has three parts: a parameter list, the arrow token ->, and a body.

// Old way: anonymous inner class
Comparator<String> comp = new Comparator<String>() {
    @Override
    public int compare(String a, String b) {
        return a.length() - b.length();
    }
};

// Lambda way
Comparator<String> comp = (a, b) -> a.length() - b.length();

The compiler performs type inference — it knows a and b are String because the target type is Comparator<String>. You don't need to declare types explicitly unless there's ambiguity.

Key rules to remember:

  • Zero parameters: () -> System.out.println("Hello")
  • One parameter (parentheses optional): x -> x * 2
  • Multiple statements require braces and an explicit return: (a, b) -> { int diff = a.length() - b.length(); return diff; }

Lambdas can capture variables from the enclosing scope, but those variables must be effectively final — meaning they are never reassigned after initialization. This restriction exists because the lambda might execute later (or on another thread), and Java avoids the complexity of mutable shared state.

int factor = 3; // effectively final
Function<Integer, Integer> multiply = x -> x * factor;
// factor = 5; // This would cause a compilation error

A common mistake is trying to modify a local variable inside a lambda. If you need mutable state, consider using an AtomicInteger or a single-element array as a workaround — but first ask yourself if there's a more functional way to achieve the same result.

Exercise 1Multiple Choice
Which of the following is a valid lambda expression in Java?

Functional Interfaces: The Type Behind the Lambda

A lambda expression doesn't exist in isolation — it always targets a functional interface, which is an interface with exactly one abstract method (SAM — Single Abstract Method). The @FunctionalInterface annotation is optional but strongly recommended, as it causes a compilation error if you accidentally add a second abstract method.

Java 8 introduced the java.util.function package with a rich set of built-in functional interfaces. Here are the ones you'll use constantly:

| Interface | Method | Signature | Use Case | |---|---|---|---| | Predicate<T> | test | TbooleanT \rightarrow boolean | Filtering | | Function<T, R> | apply | TRT \rightarrow R | Transformation | | Consumer<T> | accept | TvoidT \rightarrow void | Side effects | | Supplier<T> | get | ()T() \rightarrow T | Lazy generation | | UnaryOperator<T> | apply | TTT \rightarrow T | Same-type transformation | | BiFunction<T, U, R> | apply | (T,U)R(T, U) \rightarrow R | Two-argument transformation |

These interfaces also support composition. Predicate has and(), or(), and negate(). Function has andThen() and compose(). This lets you build complex behaviors from simple building blocks:

Predicate<String> isLong = s -> s.length() > 5;
Predicate<String> startsWithJ = s -> s.startsWith("J");
Predicate<String> combined = isLong.and(startsWithJ);

Function<String, String> trim = String::trim;
Function<String, String> lower = String::toLowerCase;
Function<String, String> pipeline = trim.andThen(lower);

Important: compose applies the argument function first, while andThen applies it second. f.andThen(g) means g(f(x))g(f(x)), whereas f.compose(g) means f(g(x))f(g(x)). This is a frequent source of bugs.

You can also create your own functional interfaces for domain-specific operations:

@FunctionalInterface
public interface Validator<T> {
    ValidationResult validate(T object);
}

Method references are syntactic sugar for lambdas that simply call an existing method. There are four kinds:

  • Static: Integer::parseInt (equivalent to s -> Integer.parseInt(s))
  • Instance (bound): System.out::println (equivalent to x -> System.out.println(x))
  • Instance (unbound): String::toLowerCase (equivalent to s -> s.toLowerCase())
  • Constructor: ArrayList::new (equivalent to () -> new ArrayList<>())
Exercise 2Fill in the Blank
A functional interface with the signature T -> boolean is called a ___ in the java.util.function package.

The Stream API: Declarative Collection Processing

The Stream API lets you express what you want to compute rather than how to compute it — a declarative style. A stream is not a data structure; it's a pipeline that carries elements from a source through a sequence of operations.

Every stream pipeline has three components:

  1. Source — where elements originate (collection, array, generator, I/O channel)
  2. Intermediate operations — transformations that return a new stream (lazy)
  3. Terminal operation — triggers computation and produces a result (eager)
List<String> names = List.of("Alice", "Bob", "Charlie", "Diana", "Eve");

List<String> result = names.stream()          // Source
    .filter(n -> n.length() > 3)              // Intermediate
    .map(String::toUpperCase)                 // Intermediate
    .sorted()                                 // Intermediate
    .collect(Collectors.toList());            // Terminal

// result: [ALICE, CHARLIE, DIANA]

Laziness is a critical concept. Intermediate operations do nothing until a terminal operation is invoked. The stream pipeline fuses operations together, processing each element through the entire chain before moving to the next. This means if you have a filter followed by findFirst, the stream stops as soon as it finds one match — it doesn't filter the entire collection first.

Common intermediate operations:

  • filter(Predicate) — keeps elements matching a condition
  • map(Function) — transforms each element
  • flatMap(Function) — transforms each element into a stream, then flattens
  • distinct() — removes duplicates (uses equals())
  • sorted() / sorted(Comparator) — sorts elements
  • peek(Consumer) — performs a side effect (useful for debugging)
  • limit(n) / skip(n) — truncates the stream

Common terminal operations:

  • collect(Collector) — accumulates into a collection or value
  • forEach(Consumer) — performs an action per element
  • reduce(identity, BinaryOperator) — folds elements into a single value
  • count(), min(), max() — aggregate operations
  • anyMatch(), allMatch(), noneMatch() — short-circuit boolean tests
  • findFirst(), findAny() — returns an Optional

Warning: A stream can only be consumed once. Attempting to reuse a stream after a terminal operation throws an IllegalStateException. If you need to apply multiple terminal operations, create a new stream each time from the original source.

Exercise 3True or False
Intermediate stream operations like filter() and map() are executed immediately when called.

Advanced Stream Operations: reduce, flatMap, and Collectors

Now let's tackle the operations that trip up even experienced developers.

reduce: Folding a Stream into a Single Value

The reduce operation combines all elements into one result using an associative accumulator function. It has three forms:

// Form 1: With identity value (always returns T)
int sum = numbers.stream().reduce(0, Integer::sum);

// Form 2: Without identity (returns Optional<T>)
Optional<Integer> sum = numbers.stream().reduce(Integer::sum);

// Form 3: With identity, accumulator, and combiner (for parallel streams)
int totalLength = words.stream()
    .reduce(0, (acc, word) -> acc + word.length(), Integer::sum);

The identity value is the starting point and must satisfy f(identity,x)=xf(identity, x) = x for all xx. For addition it's 0, for multiplication it's 1, for string concatenation it's "".

The combiner (third form) is only used in parallel streams to merge partial results. A common mistake is providing a combiner that isn't consistent with the accumulator, which produces incorrect results in parallel.

flatMap: Flattening Nested Structures

When map would give you a stream of streams (or a stream of collections), flatMap flattens them into a single stream:

List<List<String>> nested = List.of(
    List.of("a", "b"),
    List.of("c", "d"),
    List.of("e")
);

List<String> flat = nested.stream()
    .flatMap(Collection::stream)   // Stream<List<String>> -> Stream<String>
    .collect(Collectors.toList()); // [a, b, c, d, e]

Think of flatMap as "map, then flatten." It's essential when dealing with one-to-many relationships.

Collectors: The Swiss Army Knife

The Collectors utility class provides ready-made collectors for common aggregation patterns:

// Group by a classifier
Map<Integer, List<String>> byLength = words.stream()
    .collect(Collectors.groupingBy(String::length));

// Partition into true/false groups
Map<Boolean, List<String>> partitioned = words.stream()
    .collect(Collectors.partitioningBy(w -> w.length() > 3));

// Join into a single string
String csv = words.stream()
    .collect(Collectors.joining(", "));

// Downstream collectors (group + transform)
Map<Integer, Long> countByLength = words.stream()
    .collect(Collectors.groupingBy(String::length, Collectors.counting()));

// Collect to an unmodifiable list (Java 10+)
List<String> immutable = words.stream()
    .collect(Collectors.toUnmodifiableList());

Downstream collectors are incredibly powerful — they let you perform further aggregation within each group. groupingBy paired with mapping, counting, summarizingInt, or even another groupingBy enables complex transformations in a single pipeline.

Exercise 4Multiple Choice
Given a List<List<Integer>> called 'lists', which pipeline correctly produces a single flat List<Integer> containing all elements?

Parallel Streams and Performance Pitfalls

One of the Stream API's most compelling features is how easily you can parallelize computation — just call .parallelStream() instead of .stream(), or invoke .parallel() on an existing stream. Under the hood, it uses the ForkJoinPool to split work across multiple threads.

long count = hugeList.parallelStream()
    .filter(item -> expensiveCheck(item))
    .count();

But parallel streams are not a free performance boost. They introduce overhead for splitting, thread coordination, and combining results. Here's when to consider parallel:

Use parallel when:

  • The dataset is large (thousands+ of elements)
  • Operations are CPU-intensive and independent
  • The source supports efficient splitting (ArrayList, arrays — not LinkedList)
  • There is no shared mutable state

Avoid parallel when:

  • The dataset is small (overhead exceeds gains)
  • Operations involve I/O or synchronization
  • The source splits poorly (LinkedList, Stream.iterate)
  • Order matters and you use order-dependent operations like forEachOrdered
  • You're inside a web server already handling concurrent requests (you'd be competing for the shared ForkJoinPool)

A particularly dangerous bug is mutating shared state in a parallel stream:

// BROKEN: Race condition!
List<String> results = new ArrayList<>();
words.parallelStream()
    .filter(w -> w.length() > 3)
    .forEach(results::add);  // ArrayList is not thread-safe

// CORRECT: Use collect instead
List<String> results = words.parallelStream()
    .filter(w -> w.length() > 3)
    .collect(Collectors.toList());

Best Practice: Always benchmark before and after adding .parallel(). Use JMH (Java Microbenchmark Harness) for accurate results. In many real-world cases, the sequential stream is faster due to lower overhead.

Beyond parallel streams, be mindful of general performance characteristics:

  • sorted() forces the stream to buffer all elements (it's a stateful intermediate operation)
  • distinct() maintains internal state and may be expensive with large datasets
  • Autoboxing in streams (e.g., Stream<Integer> vs. IntStream) creates garbage collection pressure — use primitive specializations like IntStream, LongStream, and DoubleStream when working with numeric data
// Avoid: boxes every int to Integer
int sum = numbers.stream().map(n -> n * 2).reduce(0, Integer::sum);

// Prefer: stays in primitive int land
int sum = numbers.stream().mapToInt(n -> n * 2).sum();

Putting It All Together: Design Patterns and Best Practices

As you incorporate functional programming into real Java codebases, keep these principles in mind.

Keep Lambdas Short and Focused

If a lambda exceeds 2-3 lines, extract it into a named method and use a method reference. This improves readability and testability:

// Hard to read
users.stream()
    .filter(u -> u.getAge() > 18 && u.isActive() && u.getCountry().equals("US"))
    .collect(Collectors.toList());

// Better: extract to a descriptive method
users.stream()
    .filter(this::isEligibleUser)
    .collect(Collectors.toList());

private boolean isEligibleUser(User u) {
    return u.getAge() > 18 && u.isActive() && u.getCountry().equals("US");
}

Prefer Immutability

Functional programming thrives on immutable data. Use Collectors.toUnmodifiableList(), Collectors.toUnmodifiableMap(), and Java records to keep your pipelines free of side effects.

Don't Overuse Streams

Not everything should be a stream pipeline. A simple for loop can be clearer for:

  • Operations with complex control flow (multiple early exits, exception handling)
  • Algorithms requiring index access
  • Side-effect-heavy processing

The goal is readable, maintainable code — not "stream everything."

The Optional Connection

Stream terminal operations like findFirst(), findAny(), min(), and max() return Optional<T>. Embrace the Optional API to continue the functional chain:

String display = users.stream()
    .filter(User::isAdmin)
    .findFirst()
    .map(User::getName)
    .orElse("No admin found");

This avoids null checks and keeps the code declarative.

Composing Complex Pipelines

Build reusable pipeline components by passing Function, Predicate, or Comparator objects:

public static <T> Predicate<T> not(Predicate<T> p) {
    return p.negate();
}

// Usage (Java 11+ has Predicate.not() built in)
words.stream()
    .filter(Predicate.not(String::isEmpty))
    .collect(Collectors.toList());

The power of the Stream API lies not just in individual operations but in how they compose. Master the building blocks — filter, map, flatMap, reduce, and collect — and you can solve remarkably complex data processing problems in clean, concise pipelines.

Key Takeaways

  • Lambda expressions provide concise syntax for implementing functional interfaces, with type inference eliminating boilerplate; captured variables must be effectively final.
  • Functional interfaces in java.util.function (Predicate, Function, Consumer, Supplier) are the foundation — learn them, compose them with andThen() and compose(), and use method references where they improve clarity.
  • Stream pipelines follow the Source → Intermediate (lazy) → Terminal (eager) pattern; laziness enables short-circuiting and operation fusion for efficiency.
  • flatMap flattens nested structures, reduce folds streams into single values, and Collectors like groupingBy with downstream collectors enable powerful aggregation.
  • Parallel streams use the ForkJoinPool but are not automatically faster — benchmark first, avoid shared mutable state, prefer primitive specializations for numeric work, and ensure the data source splits efficiently.
  • Write streams for readability first: extract complex lambdas into named methods, keep pipelines linear, and fall back to imperative loops when control flow demands it.
Check Your Understanding

Java's Stream API provides two categories of operations: **intermediate operations** (like `filter`, `map`, and `sorted`) and **terminal operations** (like `collect`, `forEach`, and `reduce`). Understanding the distinction between these two categories is essential for writing correct and efficient stream pipelines. Explain in your own words what makes intermediate operations different from terminal operations in a Java Stream pipeline. In your explanation, address the following: What does it mean that intermediate operations are "lazy"? What role does a terminal operation play in actually triggering computation? Why does this design matter for performance, especially when working with large data sets? Use a concrete example scenario (such as filtering and transforming a large list of objects) to illustrate how laziness avoids unnecessary work.

🔒Upgrade to submit written responses and get AI feedback
Go deeper
  • Why must captured variables be effectively final exactly?🔒
  • How do parallel streams handle thread safety internally?🔒
  • When should I avoid using streams over loops?🔒
  • What's the performance cost of boxing in streams?🔒
  • How do method references differ from lambda expressions?🔒