Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

Lambdas and invokedynamic: How They Really Work

Why a lambda is not an anonymous class, what invokedynamic and LambdaMetafactory do at first call, the allocation difference between capturing and non-capturing lambdas, and what this means.

Intermediate6 min readUpdated
On this page

Interviewers ask "what is the difference between a lambda and an anonymous inner class?" expecting "less boilerplate". The real answer involves a bytecode instruction added specifically to support dynamic languages, and it explains a measurable performance difference.

Key Takeaways

  • A lambda compiles to an invokedynamic call site plus a private static method holding the body. No extra class file.
  • LambdaMetafactory spins the implementation class at first execution and links the call site permanently.
  • A non-capturing lambda is a singleton — allocated once, reused forever. A capturing one allocates per evaluation.
  • this inside a lambda is the enclosing instance; inside an anonymous class it is the anonymous object.
  • Captured locals must be effectively final because the value is copied, not the variable.

What the compiler emits

Source.java
public class Source {
    public Runnable make() {
        return () -> System.out.println("hello");
    }
}

The compiler does two things. It moves the body into a synthetic private static method, and it replaces the lambda expression with an invokedynamic instruction naming a bootstrap method:

javap -p -c Source
public java.lang.Runnable make();
    0: invokedynamic #2,  0   // InvokeDynamic #0:run:()Ljava/lang/Runnable;
    5: areturn
 
private static void lambda$make$0();     // the body, lifted out
    0: getstatic     #3      // Field java/lang/System.out
    3: ldc           #4      // String hello
    5: invokevirtual #5      // println
    8: return
 
BootstrapMethods:
  0: #20 java/lang/invoke/LambdaMetafactory.metafactory(...)

Compare that with an anonymous class, which produces a genuine second class file — Source$1.class — present on disk, loaded eagerly, and counted in class-loading statistics. A codebase converting a thousand anonymous classes to lambdas removes a thousand class files, which measurably improves startup and reduces Metaspace usage.

What happens at runtime

The bootstrap runs once per call site. Afterwards the invokedynamic instruction is a direct, inlinable call.

invokedynamic was added in Java 7 for dynamic languages on the JVM; Java 8 reused it for lambdas. The benefit is that the strategy for creating the implementation is not baked into the bytecode. The JDK is free to change it — and has, using hidden classes since Java 15 — without recompiling a single application.

After linking, the call site is a direct invocation that the JIT inlines like any other. A lambda in a hot loop compiles to the same machine code as the equivalent hand-written call.

Capturing versus non-capturing

This is the part with a measurable cost.

two very different lambdas
// Non-capturing: depends on nothing outside itself.
Supplier<List<String>> factory = ArrayList::new;
Predicate<String> notEmpty = s -> !s.isEmpty();
 
// Capturing: closes over `prefix`.
String prefix = "order-";
Predicate<String> matches = s -> s.startsWith(prefix);

A non-capturing lambda has no state, so LambdaMetafactory creates one instance and the call site returns that same object every time. Evaluating the expression in a loop a million times allocates nothing.

A capturing lambda needs somewhere to put the captured values, so a new object is allocated on each evaluation. In a hot loop that is real garbage:

the difference in a loop
for (Order order : orders) {
    // Captures `order` — a new Predicate object per iteration.
    process(o -> o.id().equals(order.id()));
 
    // Non-capturing — one object for the whole program.
    process(o -> o.id() != null);
}

The this difference

Scoping.java
public class Scoping {
    private String name = "outer";
 
    void anonymous() {
        Runnable r = new Runnable() {
            private String name = "inner";
            @Override public void run() {
                System.out.println(this.name);        // "inner" — this is the Runnable
                System.out.println(Scoping.this.name); // "outer" — needs qualification
            }
        };
    }
 
    void lambda() {
        Runnable r = () -> {
            System.out.println(this.name);   // "outer" — this is the Scoping instance
        };
        // A lambda cannot declare a field, and cannot shadow `name` with a
        // local of the same name — the enclosing scope is shared, not nested.
    }
}

A lambda does not introduce a new lexical scope. It shares the enclosing method's scope for this, for parameter names, and for shadowing. That last part bites when converting an anonymous class to a lambda: a parameter name that was legal inside the anonymous class becomes a duplicate-variable compile error in the lambda.

It also means a lambda cannot be recursive by referring to itself — there is no name to refer to. The workaround is a field or an array holding the reference, or simply writing a method.

Effectively final, precisely

why the restriction exists
int total = 0;
list.forEach(x -> total += x);       // does not compile
 
// The compiler's objection is not "mutation is bad"; it is that `total`
// lives on the stack frame of a method that may already have returned by
// the time the lambda runs. The lambda holds a COPY of the value.
 
// Instance fields are captured through `this`, so they have no restriction:
class Counter {
    int total = 0;
    void sum(List<Integer> list) {
        list.forEach(x -> total += x);   // compiles — but not thread-safe
    }
}

The usual escape hatches — an AtomicInteger, a one-element array, a mutable holder — all work by capturing a reference to a mutable container rather than a value. They compile, and they are almost always a signal that the loop wanted to be a reduce, a count(), or a Collectors.summingInt.

Method references, and one subtlety

String::length and s -> s.length() compile to nearly the same thing, and the method reference is usually clearer. One case is genuinely different:

bound receiver evaluation
List<String> list = getList();
 
Runnable a = list::size;              // `list` is evaluated NOW and captured
Runnable b = () -> list.size();       // `list` is read when the lambda RUNS
 
// If `list` were a field reassigned in between, a and b would disagree.

With object::method, the receiver expression is evaluated at the point the reference is created. With the equivalent lambda, it is evaluated at invocation. For a local variable this is indistinguishable — it is effectively final — but for a field or an expression with side effects, it is not.

The answer to give

"A lambda is not an anonymous class. javac lifts the body into a private static method and emits invokedynamic; LambdaMetafactory generates the implementing class at first call and links the call site, so there is no extra class file, non-capturing lambdas are singletons, and this refers to the enclosing instance rather than a new object."

Then be ready for: which allocates more, why locals must be effectively final, and what this means in each. Those three follow-ups cover the whole question.

Frequently Asked Questions

Is a lambda just syntactic sugar for an anonymous inner class?
No. An anonymous class produces a separate class file at compile time and a new object at every evaluation. A lambda compiles to an invokedynamic instruction plus a private static method holding the body; the implementation class is spun at runtime by LambdaMetafactory on first execution and then cached. A non-capturing lambda reuses one singleton instance forever.
What does this refer to inside a lambda?
The enclosing instance, exactly as in the surrounding method. A lambda does not introduce a new scope — it shares the enclosing method scope for this, for variable names, and for shadowing rules. In an anonymous class, this refers to the anonymous instance itself, which is the most visible behavioural difference between the two.
Why must captured local variables be effectively final?
Because the lambda captures the value, not the storage. The value is copied into the lambda when it is created, so a later reassignment of the local would not be visible and the two would silently diverge. Instance and static fields have no such restriction, because those are captured by reference through this.

Related tutorials