skip to content

lambdas

syntax, target typing, and why captured variables must be effectively final.

chapter
chapter 07.01
reading time
10 min read

a lambda is a piece of behaviour you can pass around like a value: (a, b) -> a + b. it has no type of its own. the target type (an interface with one abstract method) decides what it becomes.

it can read local variables from around it, but only effectively final ones, because it gets a copy, not the variable.

the picture

you know how sorting a list by length used to take an anonymous class, six lines and an @Override, to say one thing? that's the problem lambdas fix. most of those six lines were the compiler asking questions it could already answer.

think of a plug and a socket. the lambda is the plug (just the pins: some parameters and a body). the functional interface is the socket on the wall (Comparator, Runnable, Predicate). the same plug fits any socket with the same pin layout, and it means whatever that socket is wired to.

Main.java160 charscompiles
Comparator<String> byLength = new Comparator<String>() {
    @Override
    public int compare(String a, String b) {
        return Integer.compare(a.length(), b.length());
    }
};

an anonymous class. the left side already says Comparator<String>, so the wrapper in red is ceremony.

step 1/5
step through it. each frame deletes what the compiler can already work out (red), and sometimes adds an arrow (amber). the part you can't see: the anonymous class also compiles to its own Main$1.class. the lambda doesn't.

the syntax

parameters, an arrow, a body. everything else is optional when the compiler can infer it.

Main.java
import java.util.function.*;
 
void main() {
    Supplier<Integer> answer = () -> 42;            // no parameters
    IntUnaryOperator twice = x -> x * 2;            // one: parens optional
    BinaryOperator<Integer> add = (a, b) -> a + b;  // two: parens required
    // explicit types, or var (all of them or none of them)
    BiFunction<String, Integer, String> rep = (String s, Integer n) -> s.repeat(n);
    BinaryOperator<String> pick = (var a, var b) -> a.length() >= b.length() ? a : b;
    // _ means "don't care" (java 22)
    BiFunction<String, Integer, String> second = (_, n) -> "#" + n;
    // a block body needs a return
    Function<String, Integer> loud = s -> {
        IO.println("measuring " + s);
        return s.length();
    };
 
    IO.println(answer.get());
    IO.println(twice.applyAsInt(21));
    IO.println(add.apply(40, 2));
    IO.println(rep.apply("ab", 3));
    IO.println(pick.apply("duke", "jdk"));
    IO.println(second.apply("ignored", 7));
    IO.println(loud.apply("lambda"));
}
output
42
42
42
ababab
duke
#7
measuring lambda
6

two rules people trip on: you can't mix styles inside one parameter list ((String a, b) is an error), and a block body { ... } needs an explicit return if the interface's method returns something.

target typing

s -> s.isEmpty() on its own means nothing. java needs to know which interface it's implementing, and it finds out from context: the variable it's assigned to, the parameter it's passed to, the cast in front of it. that's the target type.

target =
Predicate<String> f = s -> s.isEmpty();
its one methodboolean test(String s)the lambda isboolean test(String s) { return s.isEmpty(); }
compiles

the lambda becomes the body of test. s is a String, the result is a boolean. a perfect fit.

pick a target. the same lambda text turns into a different object, or into a compile error. the errors are the real ones from javac 25.

see? a lambda is not a function type, java doesn't have those. it's a short way to write "an object implementing this one-method interface". that's why var f = s -> s.isEmpty() can't work: var asks the right side for a type, and the right side is waiting for the left side to tell it.

quick check
which of these compiles?

capturing variables

a lambda can use locals from the method around it. it doesn't get the variable, it gets a copy of its value, stored inside the lambda object when the lambda is created.

Main.java
import java.util.function.*;
 
void main() {
    int base = 10;
    IntUnaryOperator addBase = x -> x + base;
    IO.println(addBase.applyAsInt(5));
}
output
15
stack
main
base10
addBase
heap
Main$$Lambdahidden class, made at runtime
arg$110
what that looks like in memory. the lambda is an object on the heap, and base was copied into a field of it (the JVM really names it arg$1, checked with reflection on jdk 25). two copies of 10 now exist.

two copies is fine as long as they can never disagree. so java makes sure they can't: a local used inside a lambda must be effectively final, meaning you never assign it again after it's initialized (you don't have to write final, you just have to act like it).

predict the output
java
int count = 0;
List.of("a", "b", "c").forEach(s -> count++);
IO.println(count);

why not just share the variable, like javascript or C# do? because a local lives in a stack frame, and the frame dies when the method returns. the lambda can outlive it (you can return it, store it, run it on another thread). sharing would mean secretly moving the local to the heap, and then two threads could race on what looks like a plain local variable. java picked the boring, safe option: copy it, and don't let it change.

fields are a different story. this.count++ inside a lambda is allowed, because the lambda captures this (a reference that never changes) and the field lives on the heap with its object. that's legal, and it's exactly as thread-unsafe as any other shared field.

this, inside a lambda

an anonymous class is a new class, so inside it this is the anonymous object. a lambda is not a class. it doesn't introduce a new scope at all, so this means whatever it meant in the method around it.

Main.java
public class Main {
    String name = "outer";
 
    void run() {
        Runnable anon = new Runnable() {
            String name = "anon";
            public void run() {
                System.out.println(this.name + " / " + this.getClass().getName());
            }
        };
        Runnable lambda = () ->
                System.out.println(this.name + " / " + this.getClass().getName());
        anon.run();
        lambda.run();
    }
 
    public static void main(String[] args) {
        new Main().run();
    }
}
output
anon / Main$1
outer / Main
stack
run
this
anon
lambda
heap
Main
name
String
"outer"
Main$1
name
String
"anon"
Main$$Lambda
arg$1
inside run(). the anonymous class is its own object with its own name. the lambda object holds a reference to the Main it was created in, so this inside it is that Main.

"no new scope" has a second effect: a lambda parameter can't reuse a local's name. int x = 1; IntUnaryOperator f = x -> x * 2; fails with variable x is already defined in method main(). an anonymous class would have happily shadowed it.

one level deeper:what javac actually emits+

no Main$1.class. javac turns the lambda's body into a private method on your own class (lambda$main$0, with the captured values as extra leading parameters), and puts an invokedynamic instruction where the lambda was:

text
0: bipush        10
2: istore_1
3: iload_1
4: invokedynamic #7,  0
   // InvokeDynamic #0:applyAsInt:(I)Ljava/util/function/IntUnaryOperator;

the first time that instruction runs, its bootstrap method (LambdaMetafactory.metafactory) spins up a hidden class implementing IntUnaryOperator whose applyAsInt calls lambda$main$0. after that, the call site is linked and costs about as much as a new. a lambda that captures nothing is even cheaper: the JVM hands back one shared instance every time (on jdk 25, two evaluations of () -> {} from the same spot are ==; two capturing ones aren't). don't rely on either, the spec leaves identity unspecified. full story in lambda internals and invokedynamic.

keylines
  • a lambda is an object implementing a one-method interface. that interface is its target type, and it comes from context.
  • same lambda text, different targets, different objects. no target, no lambda.
  • captured locals are copied in, so they must be effectively final.
  • this in a lambda is the enclosing object. lambdas don't open a new scope.
  • no class file per lambda: invokedynamic builds it at runtime.