You often need to do the same thing over a collection: pick out the matching elements, extract something from them, add up a total. This used to always be a for loop. Lambdas and the Stream API let you describe what you want to get, rather than how to iterate. Let's work through both mechanisms from scratch.
Why you need this: the "what", not the "how"
Picture a task: from a list of users, keep the adults and collect their names. A plain loop:
List<String> names = new ArrayList<>();
for (User u : users) {
if (u.age() >= 18) { // select
names.add(u.name()); // extract the name
}
}
Two things are tangled together here: the intent (select and extract the name) and the mechanics (create a list, iterate, add). The Stream API lets you write only the intent:
List<String> names = users.stream()
.filter(u -> u.age() >= 18) // select
.map(User::name) // extract the name
.toList();
To get to the second version, you first need to understand two things: what a lambda is and what a functional interface is.
Functional interfaces
A functional interface is an interface with exactly one abstract method. It's precisely this "single method" that lets the compiler figure out what we mean when we pass a short function.
The java.util.function package has four base interfaces that come up constantly:
Function<T, R>— takes aT, returns anR. Methodapply. "A transformation."Predicate<T>— takes aT, returns aboolean. Methodtest. "A check/condition."Consumer<T>— takes aT, returns nothing. Methodaccept. "An action with a side effect."Supplier<T>— takes nothing, returns aT. Methodget. "A value provider."
Function<String, Integer> length = s -> s.length(); // string -> its length
Predicate<Integer> isPositive = n -> n > 0; // number -> true/false
Consumer<String> printer = s -> System.out.println(s);// string -> print
Supplier<Long> now = () -> System.currentTimeMillis();// no input -> number
System.out.println(length.apply("hello")); // 5
System.out.println(isPositive.test(-3)); // false
printer.accept("hello"); // prints: hello
System.out.println(now.get()); // current time in ms
You can write your own functional interfaces too. The @FunctionalInterface annotation is optional, but it's a safeguard: the compiler will verify that there really is just one method.
@FunctionalInterface
interface Discount {
int applyTo(int price); // exactly one abstract method
}
Lambdas
A lambda is a short way to write an implementation of a functional interface right where it's needed, without a separate class. The short formula: (arguments) -> body.
Discount half = price -> price / 2; // a single expression — the result is returned automatically
System.out.println(half.applyTo(100)); // 50
Several ways to write one:
() -> 42 // no arguments
x -> x + 1 // one argument, the parentheses can be omitted
(x, y) -> x + y // two arguments — parentheses are required
(int x, int y) -> x + y // you can state the types explicitly
x -> { // a multi-line body — needs braces and return
int doubled = x * 2;
return doubled + 1;
}
The parameter type is usually inferred automatically from the functional interface, which is why it's almost never written out.
An important detail: a lambda may use variables from the surrounding code, but only if they don't actually change (are effectively final).
int bonus = 10; // never assigned again — so it's effectively final
Function<Integer, Integer> add = x -> x + bonus; // the lambda "captures" bonus
System.out.println(add.apply(5)); // 15
Method references
If a lambda simply calls an already-existing method, you can write it even more concisely — via a method reference with the :: operator. It's the exact same behavior, just more compact and readable.
Function<String, Integer> length = String::length; // instead of s -> s.length()
Consumer<String> printer = System.out::println; // instead of s -> System.out.println(s)
Supplier<ArrayList<String>> factory = ArrayList::new; // instead of () -> new ArrayList<>()
Four kinds of references:
String::length— to an instance method by type (the stream supplies the object itself).System.out::println— to a method of a specific object.Integer::parseInt— to a static method.ArrayList::new— to a constructor.
The rule for choosing is simple: if a lambda only forwards the call into a single method with no extra logic — use a method reference; if there's anything else inside — keep the lambda.
The Stream API: a processing pipeline
A Stream is a pipeline for processing a sequence of elements. It doesn't store data (the source is a collection, an array, etc.) and doesn't change the source: each step produces a new stream.
A pipeline always has three parts:
long count = users.stream() // 1. source
.filter(u -> u.age() >= 18) // 2. intermediate operations
.map(User::name)
.count(); // 3. terminal operation
Intermediate vs terminal operations
This is the key distinction, without which streams feel like magic.
- Intermediate operations (
filter,map,sorted,distinct,limit) return a new Stream and do nothing right away — they only record what needs to be done. - A terminal operation (
collect,toList,count,forEach,reduce,findFirst) runs the whole pipeline and returns a result (a value or a collection). After it, the stream can no longer be used.
From this follows laziness: as long as there's no terminal operation, nothing runs.
Stream<String> s = users.stream()
.filter(u -> { System.out.println("checking " + u); return u.age() >= 18; })
.map(User::name);
// up to this line NOTHING is printed to the console — there's no terminal operation
List<String> result = s.toList(); // now the pipeline actually runs
Laziness also saves work: elements go through the pipeline one at a time, and limit can stop processing without traversing the whole source.
filter, map
filter keeps the elements that match a Predicate. map transforms each element through a Function.
List<String> result = List.of("apple", "kiwi", "banana", "fig").stream()
.filter(s -> s.length() > 3) // keep those longer than 3 characters: apple, banana
.map(String::toUpperCase) // to upper case: APPLE, BANANA
.toList(); // [APPLE, BANANA]
reduce
reduce collapses a stream into a single value: it takes an initial value and a function that combines the "accumulated" value with the next element.
int sum = List.of(1, 2, 3, 4).stream()
.reduce(0, (acc, n) -> acc + n); // 0+1+2+3+4
System.out.println(sum); // 10
For numbers, specialized streams are more common — shorter and without boxing:
int sum = List.of(1, 2, 3, 4).stream()
.mapToInt(Integer::intValue)
.sum(); // 10
collect and Collectors
collect gathers a stream into a collection or another structure. Most often you use ready-made collectors from the Collectors class. For a simple list, Java 21 has the short toList().
import static java.util.stream.Collectors.*;
List<String> names = users.stream().map(User::name).toList();
// group users by age: Map<Integer, List<User>>
Map<Integer, List<User>> byAge = users.stream()
.collect(groupingBy(User::age));
// join the names with commas: "Anna, Boris, Vera"
String joined = users.stream()
.map(User::name)
.collect(joining(", "));
When to use a stream, and when a plain loop
The Stream API is not a replacement for the loop "always and everywhere". The guideline is simple.
A stream fits when there's a chain of transformations over a collection (select → transform → collect/count) — it reads like a description of intent.
A plain loop is better when:
- you need a side effect at every step (writing to a file, to a database) — a loop is more honest for that than
forEach; - the logic is complex, with early exit, several state variables or nested conditions;
- step-by-step debugging matters or you need the element's index;
- it's a hot path, where boxing/unboxing of numbers in a stream adds unnecessary overhead.
The short formula: a stream is for transforming data, a loop is for controlling the flow of execution. And don't modify the source inside a stream — it breaks the stream's model and leads to hard-to-catch bugs.
In short
- A functional interface is an interface with one abstract method; the base ones:
Function,Predicate,Consumer,Supplier. - A lambda
(args) -> bodyis an implementation of such an interface right on the spot; captured variables must be effectively final. - A method reference (
String::length,System.out::println,ArrayList::new) is the short form of a lambda when it merely calls an existing method. - A Stream is a pipeline: source → intermediate operations (
filter,map) → terminal one (collect,count,reduce). - Intermediate operations are lazy: nothing runs until a terminal operation is called.
collect+Collectors(groupingBy,joining) assemble the result; for a simple list —toList().- A stream is for transforming data; a plain loop is for side effects, complex control flow and hot paths.
What to read next
- Generics: parameterized types — why
Function<T, R>andList<String>are written with angle brackets. - Records and modern Java — compact data types that are convenient to run through streams.
- Collections — List, Set, Map: the things the Stream API is most often built on top of.