Lambda Expressions ·
‹ Previous Next ›
⏱ 5 min read Modified: 2026-08-17

Lambda Expressions in Java

This code looks perfectly ordinary, and it does not compile:

int count = 0;
cars.forEach(car -> count++); // error: local variables referenced from
                              // a lambda expression must be final or effectively final

A lambda body looks like a normal block of code inside a method, but it plays by different rules. Let's see what lambda expressions actually are, how they are compiled, and where you use them every day.

A lambda expression in Java is a short way to write an anonymous function - a block of behaviour that you can pass into a method as an argument or store in a variable. Lambda expressions were added in Java 8 (2014) and remain a core language feature in every modern LTS release. Instead of passing an object that has a method, you pass the method body itself.

1. Why lambda expressions exist

Every example below uses one small domain class:

public enum CarTypes {
    COMPACT, SPORT, SUV
}

public class Car {
    private final CarTypes type;
    private final int cost;

    public Car(CarTypes type, int cost) {
        this.type = type;
        this.cost = cost;
    }

    public CarTypes getType() {
        return type;
    }

    public int getCost() {
        return cost;
    }
}

Here is a method that counts compact cars:

private int getCompactCarsNumber(Car[] cars) {
    int result = 0;
    for (Car car : cars) {
        if (car.getType() == CarTypes.COMPACT) {
            result++;
        }
    }
    return result;
}

Now the requirements change and you also need to count cars that cost more than 20,000:

private int getExpensiveCarsNumber(Car[] cars) {
    int result = 0;
    for (Car car : cars) {
        if (car.getCost() > 20000) {
            result++;
        }
    }
    return result;
}

The two methods differ by exactly one line - the selection criterion. Before Java 8 the usual fix was to extract that criterion into an interface and write an implementation for every rule:

public interface Searchable {
    boolean test(Car car);
}
public class CompactCarSearch implements Searchable {
    @Override
    public boolean test(Car car) {
        return car.getType() == CarTypes.COMPACT;
    }
}
public class ExpensiveCarSearch implements Searchable {
    @Override
    public boolean test(Car car) {
        return car.getCost() > 20000;
    }
}
public class CarDemo {
    public static void main(String[] args) {
        Car[] cars = {
                new Car(CarTypes.COMPACT, 34000),
                new Car(CarTypes.SPORT, 44000),
                new Car(CarTypes.COMPACT, 14000),
                new Car(CarTypes.COMPACT, 10000),
        };

        CarDemo demo = new CarDemo();
        System.out.println(demo.getCarsNumber(cars, new CompactCarSearch()));   // 3
        System.out.println(demo.getCarsNumber(cars, new ExpensiveCarSearch())); // 2

        // the same rule written as an anonymous inner class
        System.out.println(demo.getCarsNumber(cars, new Searchable() {
            @Override
            public boolean test(Car car) {
                return car.getType() == CarTypes.COMPACT;
            }
        }));
    }

    private int getCarsNumber(Car[] cars, Searchable s) {
        int result = 0;
        for (Car car : cars) {
            if (s.test(car)) {
                result++;
            }
        }
        return result;
    }
}

Those six lines of anonymous class

demo.getCarsNumber(cars, new Searchable() {
    @Override
    public boolean test(Car car) {
        return car.getType() == CarTypes.COMPACT;
    }
})

collapse into a single lambda expression:

demo.getCarsNumber(cars, car -> car.getType() == CarTypes.COMPACT);

The logic is identical; what disappeared is the ceremony - the class name, new, @Override, the method signature. The compiler can fill all of that in because Searchable has exactly one abstract method.

A small detail worth copying

Enum constants are best compared with == rather than equals(). Every enum constant is a singleton, so reference comparison is exact, and == is additionally null-safe on the left-hand side. Writing car.getType().equals(CarTypes.COMPACT) works, but throws a NullPointerException when the type was never set.

2. Lambda expression syntax

The general form is:

(parameters) -> body

For example:

(Object a1, Object a2) -> a1.equals(a2)

A lambda expression has three parts: a parameter list, the arrow token (->) and a body. The body comes in two flavours: an expression body (no braces, its value is returned automatically) and a block body (in braces, with an explicit return when the method returns a value).

The rules you need in practice:

  • A lambda takes zero or more parameters:
    (int a1, int a2) -> { return a1 - a2; }
    (String s) -> { System.out.println(s); }
    () -> 89
  • Parameter types can be written explicitly or left to the compiler, which infers them from the target type - the signature of the functional interface the lambda is assigned to:
    (String s) -> { System.out.println(s); }
    can be shortened to:
    (s) -> { System.out.println(s); }
  • Parentheses are required when there are no parameters or more than one:
    (a1, a2) -> a1 + a2
    (int a1, int a2) -> a1 + a2
    () -> 42
  • A single parameter can be written without parentheses, but then you cannot declare its type:
    a1 -> 2 * a1
  • Types are all-or-nothing: either every parameter is typed, or none is. (int a1, a2) -> a1 + a2 does not compile.
  • An expression body needs no braces and no return - its value is returned for you:
    () -> 4
    (int a) -> a * 6
  • A body with several statements needs braces, and an explicit return if a value must come back:
    () -> {
        System.out.println("Hi");
        return 4;
    }
    
    (int a) -> {
        System.out.println(a);
        return a * 6;
    }
  • A lambda may return nothing. A block body without return, and an expression body whose result is discarded, are both compatible with a void method of a functional interface:
    () -> System.out.println("Hi")
    () -> {
        System.out.println("Hi");
        return;
    }
  • Since Java 11 you may write var in the parameter list, which is handy when a parameter needs an annotation. You cannot mix var with explicit types or with no types at all:
    (var a1, var a2) -> a1 + a2
    (@NonNull var s) -> s.trim()
  • A lambda can read instance fields and static fields freely, and it can read local variables of the enclosing method only if they are final or effectively final (see section 8).
  • A lambda may only throw checked exceptions that the abstract method of the functional interface declares.
  • Inside the body you cannot call the default methods of the interface the lambda implements - there is no this pointing at the lambda itself. Call them on the resulting object instead: predicate.negate().

A lambda expression is legal wherever the compiler can work out a target type: in a variable declaration, an assignment, a return statement, an array initializer, a method or constructor argument, a ternary conditional expression and a cast expression.

Important

Every form above is a fragment. A lambda has no type of its own - it always needs a target type: a functional interface variable, a method parameter or a return type. That is why var f = () -> 42; does not compile, while Supplier<Integer> f = () -> 42; does.

3. Lambda vs anonymous inner class

A lambda is not simply "syntactic sugar over an anonymous class". The two differ in behaviour and in the bytecode the compiler produces.

Aspect Lambda expression Anonymous inner class
What this refers to The enclosing object The anonymous class instance itself
What it can implement A functional interface only Any interface or class, including ones with several methods
Own state No fields Can declare fields and initializer blocks
Bytecode An invokedynamic instruction; no extra class file A separate Outer$1.class file is generated
Variable names Cannot reuse a name already used by a local variable of the method Can shadow an enclosing variable
public class ThisDemo {
    private final String name = "enclosing object";

    public void run() {
        Runnable lambda = () -> System.out.println(this.name); // "enclosing object"

        Runnable anonymous = new Runnable() {
            private final String name = "anonymous class";

            @Override
            public void run() {
                System.out.println(this.name); // "anonymous class"
            }
        };

        lambda.run();
        anonymous.run();
    }
}

The classic place where this rewrite pays off is UI event handling. An anonymous ActionListener spends five lines saying "here is an object that has one method":

JButton button = new JButton("Click me");
JLabel label = new JLabel();

button.addActionListener(new ActionListener() {
    @Override
    public void actionPerformed(ActionEvent e) {
        label.setText("Button was clicked.");
    }
});

You never wanted to pass an object - you wanted to pass behaviour. With a lambda that is exactly what the code says:

button.addActionListener(e -> label.setText("Button was clicked."));

Note that the type of e is gone. The compiler infers ActionEvent from the target type of addActionListener, so writing it out adds nothing.

4. Where developers get tripped up

Captured variables must be effectively final

A lambda captures the value of a local variable, not the variable itself. That is why the variable must be final or effectively final - never reassigned after initialisation. This is exactly what breaks the snippet from the top of the lesson:

int count = 0;
cars.forEach(car -> count++); // does not compile

The right way to count is to ask the stream for the result instead of mutating something outside it:

long count = cars.stream()
        .filter(car -> car.getType() == CarTypes.COMPACT)
        .count();

If you genuinely need a mutable counter - in concurrent code, for instance - use AtomicInteger or LongAdder. The reference never changes; only the contents do.

AtomicInteger counter = new AtomicInteger();
cars.forEach(car -> counter.incrementAndGet());

Instance and static fields are not restricted this way - a lambda can assign to them freely.

Checked exceptions do not pass through a lambda

A lambda may only throw what the abstract method of its functional interface declares. Consumer.accept() has no throws clause, so this does not compile:

List<String> paths = List.of("a.txt", "b.txt");
paths.forEach(p -> Files.readAllBytes(Path.of(p))); // unhandled IOException

Your options: handle it inside the lambda with try/catch, wrap it in an unchecked exception such as UncheckedIOException, or declare your own functional interface whose method has throws.

Side effects inside a parallel stream

A lambda that writes into an external collection behaves unpredictably in parallelStream(): a plain ArrayList is not thread-safe, so you may end up with missing elements or a corrupted list. Build the result with collect() instead of forEach plus add.

A lambda cannot call itself directly

Recursion inside a lambda assigned to a local variable does not compile - the variable is not considered initialised inside its own initialiser. Make the lambda a field, or simply write an ordinary method and pass a method reference to it.

Lambdas that grew too long

A fifteen-line lambda loses the one thing lambdas are good at - readability. Move the body into a named method and pass a reference: cars.stream().filter(CarFilters::isCheapCompact).

Interview question

"Why must a captured local variable be effectively final while an instance field does not have to be?" Local variables live on the method's stack frame and disappear with it, so the lambda gets a copy - mutating a copy would be meaningless and misleading. Fields are reached through a reference to an object on the heap, and that object stays alive as long as the lambda does.

5. Key takeaways

  • A lambda expression is a compact anonymous function: (parameters) -> body.
  • It can only be assigned to a functional interface - an interface with exactly one abstract method.
  • Parameter types are inferred from the target type; parentheses may be dropped only for a single untyped parameter.
  • An expression body returns its value implicitly; a block body needs braces and an explicit return for non-void methods.
  • If a lambda only calls an existing method, use a method reference.
  • Lambdas are used everywhere in the Stream API: almost every stream operation takes a lambda or a method reference.
  • Captured local variables must be effectively final, and this inside a lambda points at the enclosing object.

Frequently asked questions

Is a lambda expression just shorthand for an anonymous inner class?

No. They solve similar problems, but they compile differently. An anonymous class produces a separate class file such as Outer$1.class, while a lambda compiles to an invokedynamic instruction that is linked at runtime by LambdaMetafactory. That difference shows up in behaviour too: this inside a lambda refers to the enclosing object, whereas inside an anonymous class it refers to the anonymous instance.

When should I use a method reference instead of a lambda?

Use a method reference when the lambda body is nothing but a call to an existing method with the same arguments, for example Car::getCost instead of car -> car.getCost(). Keep the lambda when you transform the arguments, chain several calls, or need a body with more than one statement - forcing a method reference there usually makes the code harder to read, not easier.

Can lambda expressions be used in Java 7 or earlier?

No. Lambda expressions arrived in Java 8 in 2014, together with functional interfaces and the Stream API. Compile with --release 7 and the compiler rejects the arrow token. Before Java 8 the equivalent tool was the anonymous inner class.

Do lambda expressions make code slower?

In practice, no. The very first call is slightly more expensive because the call site has to be linked through invokedynamic, but after that the JIT compiler inlines the body just like an ordinary method call. The only measurable cost usually comes from boxing, so in hot loops prefer specialised interfaces such as IntPredicate and ToIntFunction.

How do I debug a lambda expression?

You can set a breakpoint directly on the line that contains the lambda - IntelliJ IDEA and Eclipse stop inside the body. If the lambda is a one-liner and the breakpoint stops in the wrong place, temporarily expand it into a block body with braces or extract it into a named method. In stack traces such frames appear as lambda$methodName$0.

Comments

Please log in or register to have a possibility to add comment.