Lambdas, Functional Interfaces and Streams
Functional style in Java: lambda expressions, method references, functional interfaces such as Function and Predicate, and the Stream API for filtering, mapping, grouping and collecting.
The examples on this page are compact source files (see
The JDK, javac, java and Your First Program): void main() and IO.println, with all
of the java.base module — java.util,
java.util.function, java.util.stream —
imported automatically. In an ordinary class you would add imports
such as import java.util.function.Function;.
Passing behavior as a value
Many methods need a small piece of behavior from the caller: how to compare two tickets when sorting, which readings to keep, what to do with each element. Java has no standalone functions, so before Java 8 (2014) the behavior had to be wrapped in an object — usually an anonymous class that implements an interface on the spot. A lambda expression says the same in one line:
void main() {
List<Ticket> tickets = new ArrayList<>(List.of(
new Ticket("INC-10231", "NOC", 45),
new Ticket("INC-10232", "Billing", 15),
new Ticket("INC-10233", "NOC", 30)));
// Before Java 8: an anonymous class that implements Comparator
tickets.sort(new Comparator<Ticket>() {
@Override
public int compare(Ticket a, Ticket b) {
return Integer.compare(a.minutes(), b.minutes());
}
});
IO.println(tickets.getFirst().id());
// Since Java 8: a lambda expression
tickets.sort((a, b) -> Integer.compare(b.minutes(), a.minutes()));
IO.println(tickets.getFirst().id());
}
record Ticket(String id, String team, int minutes) { }
INC-10232 INC-10231
The first sort puts the shortest ticket first, the second the
longest. Passing functions to other functions is the core idea of
functional programming; sort is a
higher-order function
in that sense.
Lambda expressions
A
lambda expression
is an anonymous function written as parameters, an arrow
-> and a body. Its parameter types come from the
context, so you rarely write them. The forms:
-
One parameter, no parentheses needed:
amount -> amount * 1.21 -
Several or no parameters, in parentheses:
(kwh, tariff) -> kwh * tariff,() -> "escalate to NOC" -
A block body with braces and
returnwhen one expression is not enough
void main() {
Function<Double, Double> withVat = amount -> amount * 1.21;
BiFunction<Integer, Double, Double> cost = (kwh, tariff) -> kwh * tariff;
Supplier<String> nextStep = () -> "escalate to NOC";
Function<String, String> ticketLabel = id -> {
String number = id.substring(4);
return "Incident " + number;
};
IO.println(withVat.apply(100.0));
IO.println(cost.apply(350, 0.30));
IO.println(nextStep.get());
IO.println(ticketLabel.apply("INC-10231"));
}
121.0 105.0 escalate to NOC Incident 10231
A lambda is called through the method of its interface —
apply, get, test — not with
parentheses directly as in Python or JavaScript.
Capturing variables: effectively final
A lambda can use local variables of the surrounding method (it forms a closure), but only variables that are never changed after they are assigned — effectively final. The lambda captures the value, not the variable, so changing the variable would be ambiguous. Adding up with a lambda therefore fails:
void main() {
List<Integer> readings = List.of(412, 415, 419);
int total = 0;
readings.forEach(kwh -> total += kwh);
IO.println(total);
}
Main.java:4: error: local variables referenced from a lambda expression must be final or effectively final
Use a loop, or a stream that sums for you:
readings.stream().mapToInt(Integer::intValue).sum().
Fields are not restricted, but lambdas that change shared state are
hard to follow and unsafe across threads.
Functional interfaces
A
functional interface is
an interface with exactly one abstract method. Any lambda whose
parameters and result fit that method can be used where the
interface is expected; Comparator,
Runnable and Callable are older examples.
The optional annotation @FunctionalInterface makes the
compiler check the "exactly one" rule. Your own interfaces work the
same way:
void main() {
TariffRule weekend = kwh -> kwh * 0.22;
TariffRule standard = kwh -> kwh * 0.32;
IO.println(bill(350, weekend));
IO.println(bill(350, standard));
}
String bill(int kwh, TariffRule rule) {
return "%d kWh costs %.2f".formatted(kwh, rule.price(kwh));
}
@FunctionalInterface
interface TariffRule {
double price(int kwh);
}
350 kWh costs 77.00 350 kWh costs 112.00
Usually you do not need your own: the package
java.util.function has 43 ready-made ones in JDK 25.
The main shapes:
| Interface | Method | Shape | Typical use |
|---|---|---|---|
Function<T, R> |
R apply(T t) |
one value in, one out | map |
BiFunction<T, U, R> |
R apply(T t, U u) |
two values in, one out | combining values |
Predicate<T> |
boolean test(T t) |
a yes/no question | filter, removeIf |
Consumer<T> |
void accept(T t) |
takes a value, returns nothing | forEach |
Supplier<T> |
T get() |
produces a value | defaults, factories |
UnaryOperator<T> |
T apply(T t) |
a Function with the same type in and out |
replaceAll |
BinaryOperator<T> |
T apply(T a, T b) |
two of a type into one | reduce |
Variants such as IntPredicate,
ToIntFunction<T> and
IntBinaryOperator work with int,
long and double directly, without boxing
them in wrapper objects (see
Generics and primitive types). Many of the interfaces also have default methods to combine
them: and, or and negate on a
Predicate, andThen and
compose on a Function.
void main() {
Predicate<String> isIncident = id -> id.startsWith("INC-");
Predicate<String> isOld = id -> Integer.parseInt(id.substring(4)) < 10300;
Function<Double, Double> withVat = amount -> amount * 1.21;
Function<Double, Double> rounded = amount -> Math.round(amount * 100) / 100.0;
UnaryOperator<String> shout = String::toUpperCase;
BinaryOperator<Integer> add = Integer::sum;
Consumer<String> log = line -> IO.println("log: " + line);
IO.println(isIncident.and(isOld).test("INC-10231"));
IO.println(isIncident.and(isOld.negate()).test("INC-10231"));
IO.println(withVat.andThen(rounded).apply(12.35));
IO.println(shout.apply("kwh"));
IO.println(add.apply(412, 415));
log.accept("meter E-001 read");
}
true false 14.94 KWH 827 log: meter E-001 read
Method references
When a lambda does nothing but call one existing method, a
method reference says the
same more briefly: the class or object, two colons, and the method
name, without parentheses. Ticket::id means
t -> t.id(). There are four kinds:
void main() {
Function<String, Integer> parse = Integer::parseInt; // static method
Function<String, String> upper = String::toUpperCase; // instance method of the argument
String prefix = "Kestrel: ";
Function<String, String> brand = prefix::concat; // instance method of one object
Function<String, Meter> create = Meter::new; // constructor
IO.println(parse.apply("412") + 3);
IO.println(upper.apply("noc"));
IO.println(brand.apply("outage resolved"));
IO.println(create.apply("E-001"));
}
record Meter(String id) { }
415 NOC Kestrel: outage resolved Meter[id=E-001]
String::toUpperCase and
prefix::concat look alike but differ: the first calls
the method on whatever string is passed in; the second always calls
it on prefix and passes the argument along.
System.out::println is the best-known example of the
second kind.
Streams
The Stream API (java.util.stream, Java 8) processes a
sequence of elements in a declarative way: you describe
what should happen to the elements, and the library runs
the loop. Compare the same task written both ways:
void main() {
List<Ticket> tickets = List.of(
new Ticket("INC-10231", "NOC", 45),
new Ticket("INC-10232", "Billing", 15),
new Ticket("INC-10233", "NOC", 30),
new Ticket("INC-10234", "Portal", 90));
// With a loop
List<String> slow = new ArrayList<>();
for (Ticket t : tickets) {
if (t.minutes() > 20) {
slow.add(t.id());
}
}
IO.println(slow);
// With a stream
List<String> slowAgain = tickets.stream()
.filter(t -> t.minutes() > 20)
.map(Ticket::id)
.toList();
IO.println(slowAgain);
}
record Ticket(String id, String team, int minutes) { }
[INC-10231, INC-10233, INC-10234] [INC-10231, INC-10233, INC-10234]
A stream is not a collection: it stores nothing, and it does not
change its source. (It is also unrelated to the byte streams of
java.io, such as InputStream, which
Files, I/O and JSON covers.)
The pipeline
A
stream pipeline has three
parts: a source (collection.stream(),
Stream.of(…), Files.lines(path)), any
number of intermediate operations, and exactly one terminal
operation.
-
An
intermediate operation
such as
filter,map,sortedorlimitreturns a new stream and does no work yet: it only adds a step to the pipeline. -
A
terminal operation such
as
toList,count,forEachorcollectproduces a result or a side effect. Only then does the pipeline run, and afterward the stream is used up.
Laziness, in practice
Printing from inside the lambdas shows when the work really happens.
Nothing runs while the pipeline is built; once
toList() starts, each element goes through
filter and map before the next one is
read, and limit(2) stops the source as soon as two
elements have come through — G-001 and
E-003 are never looked at.
void main() {
List<String> meters = List.of("E-001", "E-002", "G-001", "E-003");
Stream<String> pipeline = meters.stream()
.filter(id -> {
IO.println("filter " + id);
return id.startsWith("E-");
})
.map(id -> {
IO.println("map " + id);
return id.toLowerCase();
});
IO.println("Pipeline built, nothing has run yet");
List<String> firstTwo = pipeline.limit(2).toList();
IO.println(firstTwo);
}
Pipeline built, nothing has run yet filter E-001 map E-001 filter E-002 map E-002 [e-001, e-002]
Lambdas in a stream should not print or change outside state in real code; this is only a demonstration. Because a stream is used up by its terminal operation, reusing it fails:
void main() {
Stream<String> meters = Stream.of("E-001", "E-002", "G-001");
IO.println(meters.count());
IO.println(meters.count());
}
3 Exception in thread "main" java.lang.IllegalStateException: stream has already been operated upon or closed
Keep the collection and call .stream() again for each
pipeline; that is cheap.
Common operations
Sorting, de-duplicating and slicing
sorted takes an optional Comparator;
Comparator.comparingInt(Reading::kwh).reversed() sorts
by usage, highest first. distinct drops duplicates (by
equals), limit(n) keeps the first
n elements and skip(n) drops them.
void main() {
List<Reading> readings = List.of(
new Reading("E-003", 388), new Reading("E-001", 412),
new Reading("E-002", 1510), new Reading("E-001", 415),
new Reading("E-004", 1280), new Reading("E-003", 391));
List<String> topThree = readings.stream()
.sorted(Comparator.comparingInt(Reading::kwh).reversed())
.limit(3)
.map(r -> r.meterId() + "=" + r.kwh())
.toList();
IO.println(topThree);
List<String> meters = readings.stream()
.map(Reading::meterId)
.distinct()
.sorted()
.toList();
IO.println(meters);
}
record Reading(String meterId, int kwh) { }
[E-002=1510, E-004=1280, E-001=415] [E-001, E-002, E-003, E-004]
Stream.toList() (Java 16) returns an unmodifiable list;
calling add on it throws an
UnsupportedOperationException. Use
collect(Collectors.toCollection(ArrayList::new)) when
you need to change the result.
Counting, summing and searching
Terminal operations that reduce a stream to one value:
void main() {
List<Ticket> tickets = List.of(
new Ticket("INC-10231", "NOC", 45),
new Ticket("INC-10232", "Billing", 15),
new Ticket("INC-10233", "NOC", 30),
new Ticket("INC-10234", "Portal", 90));
IO.println(tickets.stream().filter(t -> t.team().equals("NOC")).count());
IO.println(tickets.stream().mapToInt(Ticket::minutes).sum());
IO.println(tickets.stream().mapToInt(Ticket::minutes).average().orElse(0));
IO.println(tickets.stream().max(Comparator.comparingInt(Ticket::minutes)).map(Ticket::id).orElse("none"));
IO.println(tickets.stream().anyMatch(t -> t.minutes() > 60));
IO.println(tickets.stream().allMatch(t -> t.minutes() > 10));
IO.println(tickets.stream().filter(t -> t.team().equals("Field")).findFirst());
IO.println(tickets.stream().map(Ticket::minutes).reduce(0, Integer::sum));
}
record Ticket(String id, String team, int minutes) { }
2 180 45.0 INC-10234 true true Optional.empty 180
-
mapToIntturns aStream<Ticket>into anIntStreamof plainints, which hassum,average,maxandsummaryStatistics. -
Operations that may find nothing —
max,findFirst,average— return anOptional(orOptionalDouble), nevernull; see Null, Optional and NullPointerException. -
reduce(0, Integer::sum)combines all elements with a start value and aBinaryOperator; the specialized methods are clearer when they exist.
Number streams
IntStream, LongStream and
DoubleStream can also be sources by themselves:
void main() {
IO.println(IntStream.rangeClosed(1, 5).sum());
IO.println(IntStream.of(412, 415, 419).max().getAsInt());
IO.println(IntStream.of(412, 415, 419).summaryStatistics());
IO.println(Stream.iterate(1, n -> n * 2).limit(6).toList());
}
15
419
IntSummaryStatistics{count=3, sum=1246, min=412, average=415.333333, max=419}
[1, 2, 4, 8, 16, 32]
Stream.iterate produces an endless stream;
limit makes it finite. Without limit,
toList() would never finish.
Collecting results
collect is the most flexible terminal operation. It
takes a collector: a recipe that
says how to accumulate the elements into a result — a string, a
list, a map of groups. The class Collectors has the
ready-made ones:
void main() {
List<Ticket> tickets = List.of(
new Ticket("INC-10231", "NOC", 45),
new Ticket("INC-10232", "Billing", 15),
new Ticket("INC-10233", "NOC", 30),
new Ticket("INC-10234", "Portal", 90),
new Ticket("INC-10235", "Billing", 30));
String ids = tickets.stream()
.map(Ticket::id)
.collect(Collectors.joining(", ", "[", "]"));
IO.println(ids);
Map<String, Long> perTeam = tickets.stream()
.collect(Collectors.groupingBy(Ticket::team, TreeMap::new, Collectors.counting()));
IO.println(perTeam);
Map<String, Integer> minutesPerTeam = tickets.stream()
.collect(Collectors.groupingBy(Ticket::team, TreeMap::new,
Collectors.summingInt(Ticket::minutes)));
IO.println(minutesPerTeam);
Map<Boolean, List<String>> slow = tickets.stream()
.collect(Collectors.partitioningBy(t -> t.minutes() > 30,
Collectors.mapping(Ticket::id, Collectors.toList())));
IO.println(slow);
Map<String, Integer> minutesById = tickets.stream()
.collect(Collectors.toMap(Ticket::id, Ticket::minutes, (a, b) -> a, TreeMap::new));
IO.println(minutesById.get("INC-10234"));
}
record Ticket(String id, String team, int minutes) { }
[INC-10231, INC-10232, INC-10233, INC-10234, INC-10235]
{Billing=2, NOC=2, Portal=1}
{Billing=45, NOC=75, Portal=90}
{false=[INC-10232, INC-10233, INC-10235], true=[INC-10231, INC-10234]}
90
-
joining(separator, prefix, suffix)concatenates strings. -
groupingBy(classifier, mapFactory, downstream)groups by a key and then applies a second collector to each group:counting(),summingInt(…),mapping(…),toList(). -
partitioningByis grouping by aPredicate: always exactly two groups,falseandtrue. -
toMap(key, value, merge, mapFactory)builds a map. Without the merge function, a duplicate key throws anIllegalStateException.
Gatherers: custom intermediate steps
Since Java 24 a stream can also use gather(…), an
intermediate operation that takes a gatherer. The class
Gatherers provides a few that were hard to write with
the other operations, such as fixed-size windows and running totals.
Twelve monthly readings become four quarters:
void main() {
List<Integer> monthlyKwh = List.of(410, 395, 380, 300, 240, 210, 205, 215, 260, 330, 390, 420);
List<Integer> quarters = monthlyKwh.stream()
.gather(Gatherers.windowFixed(3))
.map(quarter -> quarter.stream().mapToInt(Integer::intValue).sum())
.toList();
IO.println(quarters);
List<Integer> runningTotal = monthlyKwh.stream()
.gather(Gatherers.scan(() -> 0, Integer::sum))
.toList();
IO.println(runningTotal.getLast());
}
[1185, 750, 680, 1140] 3755
windowFixed(3) turns the stream of numbers into a
stream of lists of three; windowSliding(n) makes
overlapping windows (for moving averages), and
scan emits a running total after each element.
Lambdas and checked exceptions
The methods of Function, Predicate and the
other standard interfaces do not declare any checked exceptions. A
lambda that calls a method throwing one, such as
Files.readString, therefore does not compile (see
checked exceptions):
void main() {
List<Path> files = List.of(Path.of("readings-1.csv"), Path.of("readings-2.csv"));
List<String> contents = files.stream()
.map(path -> Files.readString(path))
.toList();
IO.println(contents);
}
Main.java:4: error: unreported exception IOException; must be caught or declared to be thrown
The usual solution is a small method that catches the checked exception and rethrows it wrapped in an unchecked one, with the original as its cause. The example writes its two files first:
void main() throws IOException {
Files.writeString(Path.of("readings-1.csv"), "E-001;412");
Files.writeString(Path.of("readings-2.csv"), "E-002;388");
List<String> contents = Stream.of("readings-1.csv", "readings-2.csv")
.map(Path::of)
.map(this::read)
.toList();
IO.println(contents);
}
String read(Path path) {
try {
return Files.readString(path);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
[E-001;412, E-002;388]
this::read refers to the method of the current object;
in a compact source file, main and read
are instance methods of the same implicit class.
Streams or loops?
Streams shine for pipelines of filtering, transforming, grouping and summarizing — the kind of work SQL does on tables. A plain loop is often clearer when the logic has several branches, needs to stop early on a complex condition, changes variables outside, or handles checked exceptions. Neither is "more professional"; choose what reads best. Some practical points:
- Keep lambdas in a pipeline short and free of side effects. A long lambda is better as a named method, used through a method reference.
-
Stack traces from streams contain many frames from
java.util.stream; look for the lines of your own lambdas, which appear aslambda$main$0and similar. -
parallelStream()splits work over several threads. It helps only for large, CPU-heavy work without shared state, and it uses a pool shared by the whole JVM; measure before using it (see Concurrency: Threads, Executors and Virtual Threads).
| Java stream | Python | C# LINQ |
|---|---|---|
filter(t -> …) |
[t for t in xs if …] |
Where |
map(t -> …) |
[f(t) for t in xs] |
Select |
sorted(comparator) |
sorted(xs, key=…) |
OrderBy |
collect(groupingBy(…)) |
itertools.groupby (after sorting) |
GroupBy |
reduce(…) |
functools.reduce |
Aggregate |
toList() |
list(…) |
ToList() |