Lambdas and functional interfaces
Lambdas through Java 21 target typing, functional-interface families, syntax choices, invokedynamic translation, capture cost, and the `this` difference from anonymous classes.
On this page
🧩 What are lambdas and functional interfaces?
A lambda is an inline implementation of one behavior, and a functional interface is the one-method socket it plugs into. Predicate<Order> paid = order -> order.isPaid() means "here is code shaped like boolean test(Order)". Java did not add a standalone function type in Java 8; it reused interfaces with exactly one abstract method.
For this lesson, we'll focus on lambdas as Java actually uses them: the functional-interface families, lambda syntax, target typing, compilation with invokedynamic, and the this rule that separates lambdas from anonymous classes. We will link back to Functional interfaces in Java for the broad SAM rule and Functional programming in Java for pure functions and streams, then go deeper on the lambda machinery interviewers probe.
Below the line (out of scope)
- Method-reference syntax gets its own lesson next: Method references
- Chaining predicates and functions gets its own lesson: Composing functions and chaining predicates
- Streams are covered in later lessons, starting with Introduction to Java streams
The 30-second answer
A functional interface has exactly one abstract method, so a lambda can provide that method body without a named class. @FunctionalInterface is optional, but I use it on my own interfaces because the compiler then prevents someone from adding a second abstract method later. The main JDK shapes are Function<T,R>, BiFunction<T,U,R>, Supplier<T>, Consumer<T>, Predicate<T>, UnaryOperator<T>, BinaryOperator<T>, plus primitive versions such as IntPredicate, IntUnaryOperator and ToIntFunction<T> to avoid boxing. A lambda gets its type from the target context, and javac normally emits an invokedynamic call site whose bootstrap is LambdaMetafactory, not an extra anonymous-class file. Inside a lambda, this is the enclosing object; inside an anonymous class, this is the anonymous object.
What you’ll learn
- Define a functional interface as one abstract method and explain what
@FunctionalInterfacechecks - Choose the right
java.util.functionfamily, includingFunction,Predicate, operators and primitive specialisations - Read and write lambda syntax forms with expression bodies, block bodies, parameter types and target typing
- Explain how
javacusesinvokedynamicandLambdaMetafactory, with no anonymous inner class file - Compare
thisand local-variable rules inside a lambda versus an anonymous class - Spot when primitive functional interfaces avoid boxing in hot stream and callback paths
Interview radar
Asked very often: What is a functional interface, what does @FunctionalInterface do, and which standard interfaces do you reach for?
Core follow-up: Why does s -> s.length() have no type until it has a target?
Senior probe: What bytecode shape does a lambda use, and why is it not an anonymous inner class?
01Picture it first: plugs that learn their socket
A lambda is like a universal plug with no voltage printed on it. The wall socket tells it the shape: Predicate<String> means one String in and one boolean out; Function<String, Integer> means one String in and one Integer out. The same text can fit different sockets if the method shape lines up.
That is the mental model I want you to keep. Lambdas are not little untyped blobs floating around forever; they become real Java objects only when a target functional interface is known.
A lambda expression is shown as an untyped plug that becomes either Predicate String or Function String Integer when it is connected to a target functional interface socket
Notice the important direction. The variable declaration, method parameter, cast or return type tells the compiler what the lambda is allowed to mean.
02Lambdas under the hood: syntax, shapes and target typing
Try it yourself first
Before reading on, decide the type of each lambda below. Which ones can be inferred, and which ones need help?
import java.util.Comparator;
import java.util.function.*;
Predicate<String> blank = s -> s.isBlank();
Function<String, Integer> length = s -> s.length();
Consumer<String> print = System.out::println;
Supplier<Long> now = () -> System.nanoTime();
UnaryOperator<String> clean = s -> s.trim().toLowerCase();
BinaryOperator<Integer> max = (a, b) -> a >= b ? a : b;
var alsoBlank = (Predicate<String>) (s -> s.isBlank());
Comparator<Order> byTotal = (left, right) -> Long.compare(left.totalCents(), right.totalCents());
A lambda expression is poly expression in JLS language, which means its type depends on context. s -> s.length() is not automatically a Function<String, Integer>. Put it in a ToIntFunction<String>, and the result becomes primitive int; put it in a Predicate<String>, and it fails because int is not boolean.
Notice three things:
- The parameter list can be terse or explicit.
s -> s.length()is common;(String s) -> s.length()is equivalent when the target type is known. You cannot mix inferred and declared parameters, so(String s, i) -> ...is illegal. - The body can be an expression or a block.
x -> x + 1returns the expression.x -> { return x + 1; }needsreturn;x -> { x + 1; }is not a valid statement. varworks in lambda parameters since Java 11, mostly for annotations:(@Nonnull var s) -> s.trim(). If one parameter usesvar, they all must.
Here are the everyday syntax forms I expect a strong candidate to read without pausing.
| Form | Example | Notes |
|---|---|---|
| No parameters | () -> clock.instant() |
Fits Supplier<Instant> or Runnable depending on return |
| One inferred parameter | o -> o.isPaid() |
Parentheses optional only for one inferred parameter |
| Multiple parameters | (a, b) -> a + b |
Parentheses required |
| Explicit parameter type | (Order o) -> o.totalCents() |
Useful when overloads confuse inference |
| Block body | o -> { audit(o); return o.id(); } |
Needs return if the target method returns a value |
| Statement expression | r -> mailer.send(r) |
Fits Consumer<Receipt> if send returns void or a value you ignore |
The trade-off is readability. My default is inferred parameters and expression bodies for one-line transformations, then explicit types or a named method when the reader has to reverse-engineer the shape.
The functional-interface families
You already met the single-abstract-method rule in Functional interfaces in Java. In lambda-heavy code, the practical skill is choosing the right socket quickly.
Try it yourself first
Pick the best standard interface before you read the code: "parse a request body", "load config lazily", "send a receipt", "is this order risky", "add tax to cents", "sum two counts".
Function<String, Order> parseOrder = json -> OrderParser.parse(json);
BiFunction<Long, Integer, Long> discount = (cents, pct) -> cents * (100 - pct) / 100;
Supplier<Config> config = () -> Config.load("checkout.yaml");
Consumer<Receipt> sendReceipt = receipt -> mailer.send(receipt.email(), receipt.html());
Predicate<Order> risky = order -> order.totalCents() > 100_000 && order.country().equals("NG");
UnaryOperator<Long> addGst = cents -> cents * 118 / 100;
BinaryOperator<Long> sum = (left, right) -> left + right;
IntPredicate positive = value -> value > 0;
ToIntFunction<String> utf16Length = text -> text.length();
IntUnaryOperator clampPercent = pct -> Math.max(0, Math.min(100, pct));
ObjIntConsumer<Cart> reserve = (cart, quantity) -> inventory.reserve(cart.id(), quantity);
Function<T,R> transforms. Supplier<T> produces with no input. Consumer<T> accepts and returns nothing. Predicate<T> answers yes or no. UnaryOperator<T> is a Function<T,T>, and BinaryOperator<T> is a BiFunction<T,T,T>.
The naming rules are worth memorising because they scale across the package. IntPredicate means the input is primitive int; ToIntFunction<T> means the output is primitive int; ObjIntConsumer<T> means an object plus an int. There are 40-plus interfaces in java.util.function, but these prefixes keep the set predictable.
Boxing is the hidden cost in the wrong interface. A Function<Integer, Integer> accepts and returns references, so a hot loop over 10 million values can create millions of Integer boxes outside the -128 to 127 cache if escape analysis cannot remove them. IntUnaryOperator moves raw int values through applyAsInt(int), which is the interface I choose for numeric callbacks on a hot path.
Target typing in method calls and overloads
Target typing shows up most sharply at method-call boundaries. The compiler checks whether the lambda can implement the single abstract method of the parameter type.
static <T> T firstMatch(List<T> items, Predicate<T> keep) {
for (T item : items) {
if (keep.test(item)) return item;
}
return null;
}
Order expensive = firstMatch(orders, order -> order.totalCents() > 50_000);
order is inferred as Order because items is a List<Order> and the second parameter is Predicate<T>. That is why changing the receiver type can change the lambda without touching the lambda text.
Many candidates are quick to say "the lambda knows its parameter type". It doesn't. The target type pushes inward, then the lambda body is checked against it.
03How lambdas compile
Try it yourself first
Imagine compiling this class. How many .class files should appear, and what should this print from the lambda?
import java.util.function.LongUnaryOperator;
import java.util.function.Supplier;
public class PricingRules {
private final long serviceFee = 99;
Supplier<String> label(String market) {
return () -> "checkout:" + market; // captures a local value
}
LongUnaryOperator addFee() {
return cents -> cents + serviceFee; // captures this
}
Runnable whoAmI() {
return () -> System.out.println(this.getClass().getSimpleName());
}
}
With normal Java 21 compilation, you get PricingRules.class, not PricingRules$1.class. javac moves lambda bodies into private helper methods and places an invokedynamic instruction at the creation site. The bootstrap method is usually java.lang.invoke.LambdaMetafactory.metafactory, which links a call site that produces an implementation of the functional interface.
The lambda compilation path shows source code becoming private lambda body methods plus an invokedynamic call site, then LambdaMetafactory linking a hidden class at runtime with no extra inner class file on disk
There are two useful consequences. A non-capturing lambda, like () -> log.info("tick"), can be cached and reused by the runtime because it carries no per-evaluation state. A capturing lambda, like () -> market, needs to remember the captured value, so evaluating the lambda expression usually creates a small object, although the JIT can often remove it when it does not escape.
Do not promise object identity in an interview. The JLS does not require one instance per lambda or singleton reuse. The safe answer is the bytecode shape: invokedynamic, LambdaMetafactory, hidden runtime implementation, no anonymous inner class file.
04Real world: callbacks that stay readable
The first time I saw lambda misuse hurt a team, it was not because lambdas were slow. It was because a checkout service had a 14-line lambda inside forEach, with metrics, retries, JSON parsing and a database write all in one block. The code looked modern, but the failure path was harder to test than the old loop. We pulled the behavior into named methods and kept lambdas for the small predicates and transformations.
Here is the split I usually recommend in code review:
orders.stream()
.filter(this::isReadyForSettlement)
.map(this::toSettlementCommand)
.forEach(settlementClient::submit);
private boolean isReadyForSettlement(Order order) {
return order.isPaid() && !order.isRefunded() && order.totalCents() > 0;
}
The lambda or method reference is now a pointer to business language, not a hiding place. For three simple expressions, inline is fine. When the lambda needs logging, exception handling or comments, name the operation.
05Advanced: this, capture, and anonymous-class differences
Lambdas share the surrounding scope more tightly than anonymous classes do. They can capture local variables only if those variables are effectively final, meaning assigned once and never changed. The lambda captures the value, not a live local variable slot that can keep changing.
class ReceiptPrinter {
private final String prefix = "receipt";
Runnable lambdaPrinter(String id) {
return () -> System.out.println(this.prefix + ":" + id);
}
Runnable anonymousPrinter(String id) {
return new Runnable() {
private final String prefix = "anonymous";
@Override public void run() {
System.out.println(this.prefix + ":" + id);
}
};
}
}
In lambdaPrinter, this.prefix means the ReceiptPrinter field. In anonymousPrinter, this.prefix means the field on the anonymous Runnable object. That one word, this, is the fastest way to tell whether someone understands that lambdas are not anonymous classes in nicer syntax.
A lambda keeps this bound to the enclosing ReceiptPrinter while an anonymous class has its own this and can hold its own prefix field
Many candidates say a lambda is "an anonymous class behind the scenes". That answer causes three wrong follow-ups: expecting Outer$1.class, expecting this to mean the lambda object, and expecting every evaluation to allocate the same way an anonymous class does. Say it more carefully: Java uses functional interfaces as the type system bridge, and the JVM links the implementation through invokedynamic.
There is also a design pitfall. If a lambda is doing domain work that deserves a name, a custom functional interface can be better than Function<Long, Long>.
@FunctionalInterface
interface PriceRule {
long apply(long cents);
default PriceRule then(PriceRule next) {
return cents -> next.apply(apply(cents));
}
}
The simple option is the standard interface. It stops being enough when the name carries a contract, such as "price rules must be deterministic and use cents". My default: use java.util.function first, then introduce a domain interface when it improves call sites or documents rules the compiler cannot express.
06How to say it in the interview
When I ask this, I'm listening for the bridge between syntax and runtime. I want the one-abstract-method rule, the standard interface families, target typing, the primitive specialisations, and a non-hand-wavy explanation of invokedynamic.
In the interview
"A lambda is an inline implementation of a functional interface, and a functional interface is any interface with exactly one abstract method. @FunctionalInterface is optional but useful because it turns that rule into a compiler check. The lambda's type comes from the target, so the same s -> s.length() can be a Function<String,Integer> or a ToIntFunction<String>. I use the standard interfaces first: Function, BiFunction, Supplier, Consumer, Predicate, UnaryOperator, BinaryOperator, and primitive versions like IntPredicate when boxing matters. Under the hood, it is not an anonymous inner class file; javac emits invokedynamic, and LambdaMetafactory links a hidden implementation at runtime. Inside the lambda, this is the enclosing object."
On the whiteboard
functional interface = exactly 1 abstract method
@FunctionalInterface = compile-time guard
shapes:
Function<T,R> T -> R
BiFunction<T,U,R> (T,U) -> R
Supplier<T> () -> T
Consumer<T> T -> void
Predicate<T> T -> boolean
UnaryOperator<T> T -> T
BinaryOperator<T> (T,T) -> T
IntPredicate / ToIntFunction / IntUnaryOperator = avoid boxing
lambda syntax: params -> expression | params -> { statements }
type comes from target context
compile: lambda body + invokedynamic + LambdaMetafactory
no Outer$1.class for the lambda
this in lambda = enclosing this
this in anonymous class = anonymous object
Further reading (7)
- Package java.util.function (Java SE 21 API)
- JLS 21, Chapter 9.8: Functional Interfaces
- JLS 21, Chapter 15.27: Lambda Expressions
- JLS 21, Chapter 15.28: Method References
- LambdaMetafactory (Java SE 21 API)
- JEP 371: Hidden Classes
- Effective Java, 3rd ed., Items 42-44: lambdas, method references, standard functional interfaces