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.
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:
() -> System.out.println("Hello")x -> x * 2return:
(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.
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 | | Filtering |
| Function<T, R> | apply | | Transformation |
| Consumer<T> | accept | | Side effects |
| Supplier<T> | get | | Lazy generation |
| UnaryOperator<T> | apply | | Same-type transformation |
| BiFunction<T, U, R> | apply | | 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:
composeapplies the argument function first, whileandThenapplies it second.f.andThen(g)means , whereasf.compose(g)means . 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:
Integer::parseInt (equivalent to s -> Integer.parseInt(s))System.out::println (equivalent to x -> System.out.println(x))String::toLowerCase (equivalent to s -> s.toLowerCase())ArrayList::new (equivalent to () -> new ArrayList<>())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:
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 conditionmap(Function) — transforms each elementflatMap(Function) — transforms each element into a stream, then flattensdistinct() — removes duplicates (uses equals())sorted() / sorted(Comparator) — sorts elementspeek(Consumer) — performs a side effect (useful for debugging)limit(n) / skip(n) — truncates the streamCommon terminal operations:
collect(Collector) — accumulates into a collection or valueforEach(Consumer) — performs an action per elementreduce(identity, BinaryOperator) — folds elements into a single valuecount(), min(), max() — aggregate operationsanyMatch(), allMatch(), noneMatch() — short-circuit boolean testsfindFirst(), findAny() — returns an OptionalWarning: 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.
Now let's tackle the operations that trip up even experienced developers.
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 for all . 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.
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.
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.
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:
ArrayList, arrays — not LinkedList)Avoid parallel when:
LinkedList, Stream.iterate)forEachOrderedForkJoinPool)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 datasetsStream<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();
As you incorporate functional programming into real Java codebases, keep these principles in mind.
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");
}
Functional programming thrives on immutable data. Use Collectors.toUnmodifiableList(), Collectors.toUnmodifiableMap(), and Java records to keep your pipelines free of side effects.
Not everything should be a stream pipeline. A simple for loop can be clearer for:
The goal is readable, maintainable code — not "stream everything."
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.
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.
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.flatMap flattens nested structures, reduce folds streams into single values, and Collectors like groupingBy with downstream collectors enable powerful aggregation.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.