Skip to content

Functional Interfaces and Lambdas — Senior

What? How lambdas actually compile and run: the invokedynamic bytecode + LambdaMetafactory.metafactory bootstrap (JEP 181), the call-site shape that the metafactory builds, capture semantics for locals and this, what makes a Serializable lambda special, and the memory footprint of a captured lambda. How? Read the bytecode in your head before you read it on disk: every lambda becomes a private static synthetic method + an invokedynamic call site + a tiny capture object (zero or more fields). Once you can name those three artefacts for a given lambda, the rest — including the surprises — fall out.


1. From source to bytecode: the three artefacts

Take a tiny example:

public final class M {
    static int add(int x, int y) {
        IntBinaryOperator f = (a, b) -> a + b;
        return f.applyAsInt(x, y);
    }
}

javac produces, for this one lambda:

  1. A private static synthetic method in the enclosing class with the lambda's body:
    private static int lambda$add$0(int a, int b) { return a + b; }
    
  2. An invokedynamic instruction at the source location of the lambda, with a BootstrapMethods attribute (JVMS §4.7.23) that names LambdaMetafactory.metafactory as the bootstrap.
  3. No anonymous inner class file. There is no M$1.class on disk — that's the headline change from pre-Java 8 idioms.

Decompiled bytecode looks roughly like (omitting types):

invokedynamic #N, 0   // applyAsInt: ()LIntBinaryOperator;
                      //   BSM: LambdaMetafactory.metafactory(...)
                      //   args: ( (II)I, lambda$add$0, (II)I )

The first time this invokedynamic instruction executes, the JVM:

  • Calls the bootstrap method (metafactory), passing the SAM signature ((II)I), a MethodHandle to lambda$add$0, and the instantiated signature.
  • The metafactory builds (via MethodHandleProxies / spinning) a class that implements IntBinaryOperator and forwards applyAsInt to the method handle.
  • It returns a CallSite (typically ConstantCallSite) whose target produces an instance of that class.
  • The call site is linked — every subsequent execution of that invokedynamic just runs the bound target, no metafactory call.

JEP 181 (the language change) plus the metafactory machinery is why a lambda has no fixed bytecode shape: the JDK is free to pick whatever implementation strategy is fastest. In current HotSpot, the spinned class is a MethodHandle-proxy-style class generated by InnerClassLambdaMetafactory via ASM.


2. The capture object — what allocation actually happens

A lambda that captures no variables is implemented as a singleton: the metafactory call site returns the same instance on every invocation, because the lambda's behaviour doesn't depend on the call context.

Runnable r = () -> System.out.println("hi");      // no captures: same object every time

A lambda that captures one or more variables becomes a small class whose constructor takes the captured values; each evaluation of the lambda expression allocates a new instance.

String tag = "user";
Runnable r = () -> System.out.println(tag);       // one capture: new object per evaluation

The footprint of the capture object is approximately:

Captures Object header Fields Total (compressed oops)
0 12 bytes 0 singleton, 16 bytes pad
1 reference 12 bytes 4 bytes (oop) ~16 bytes
1 int 12 bytes 4 bytes ~16 bytes
1 long/double 12 bytes 8 bytes ~24 bytes
2 references 12 bytes 8 bytes ~24 bytes

That's small, but in tight loops it adds up. If a method is called millions of times and creates a capturing lambda each time, that's millions of small allocations — usually swept up by C2's escape analysis, but not always (see optimize.md).

The non-capturing form is allocation-free in steady state — the same singleton object is reused. This is one of two reasons (the other being JIT inlining) why lambdas can be cheaper than anonymous inner classes in practice, even though both share the same underlying SAM dispatch.


3. Capture semantics for locals (effectively final)

JLS §15.27.2 requires captured locals to be final or effectively final. The compiler enforces this by copying the value into the capture object at the point the lambda expression is evaluated.

List<Supplier<Integer>> ss = new ArrayList<>();
for (int i = 0; i < 3; i++) {
    int snapshot = i;                  // effectively final inside this iteration
    ss.add(() -> snapshot);            // each lambda captures a different int
}
ss.forEach(s -> System.out.println(s.get()));   // 0, 1, 2

Each ss.add(...) evaluates a new lambda, which allocates a new capture object holding the current snapshot. That's why each supplier prints a different value despite all referring to a variable named snapshot.

The final/effectively final rule is a stronger constraint than what closures in many other languages enforce — Java refuses to implicitly share mutable state. The motivation is two-fold:

  • Lifetime safety. A lambda may outlive the stack frame; copying the value at capture time decouples the two lifetimes.
  • Thread safety. Shared mutable locals across threads would require synchronisation users couldn't see.

If you want shared mutable state, capture a holder:

AtomicInteger counter = new AtomicInteger();   // the reference is final;
                                                // the int inside is mutable.
executor.submit(() -> counter.incrementAndGet());
executor.submit(() -> counter.incrementAndGet());

The compiler doesn't help you here — you must reason about thread safety. The final/effectively-final rule only protects the reference.


4. this capture and the trap of nested anonymous classes

A lambda does not introduce a new this. Inside a lambda, this is the enclosing class's this — the same as in the surrounding statement.

public final class Service {
    private final String name = "svc";
    Runnable r() {
        return () -> System.out.println(this.name);   // Service.this
    }
}

This is usually what callers want. But it has a subtlety: because the lambda implicitly references this, the capture object holds a strong reference to the enclosing instance. A long-lived lambda retains its enclosing instance, which can prolong the lifetime of unrelated fields. Memory leaks are usually traceable to listener lambdas registered on long-lived buses while the registering object expected to be collected.

class Page {
    private final byte[] heavyData = new byte[10 * 1024 * 1024];
    void register(EventBus bus) {
        bus.on("tick", () -> log("tick"));     // implicit this — Page is retained!
    }
    private void log(String s) { /* ... */ }
}

Two fixes when this matters:

// 1. Make the lambda use a static method — no this capture:
bus.on("tick", Page::staticLog);

// 2. Pull what the lambda needs out into a local — still captures locals only:
String prefix = "tick";
bus.on("tick", () -> System.out.println(prefix));

Anonymous inner classes have the opposite behaviour: each anonymous instance has its own this, distinct from the enclosing class. When you refactor an anonymous class to a lambda and a method body uses this, the meaning changes silently. This is the most common subtle bug introduced by IDE auto-conversions — find-bug.md covers a worked case.

// Anonymous inner class — this == the inner instance:
button.setOnClick(new ActionListener() {
    @Override public void onClick() {
        System.out.println(this);    // prints "anonymous ActionListener$1"
    }
});

// "Converted" to a lambda — this == the enclosing class:
button.setOnClick(() -> System.out.println(this));   // prints the outer instance

5. Inside LambdaMetafactory.metafactory

The bootstrap method called by every lambda's invokedynamic site is:

public static CallSite metafactory(
    MethodHandles.Lookup     caller,
    String                   invokedName,        // SAM method name, e.g. "apply"
    MethodType               invokedType,        // (captures) -> SAM-iface
    MethodType               samMethodType,      // erased SAM signature
    MethodHandle             implMethod,         // points at lambda$xxx$N
    MethodType               instantiatedMethodType   // SAM with type parameters bound
) throws LambdaConversionException

For a non-serializable, non-multi-interface lambda this delegates to InnerClassLambdaMetafactory, which:

  1. Spins a class implementing the SAM interface.
  2. Generates fields for the captures (constructor parameters).
  3. Implements the SAM by calling implMethod with the captures + the SAM arguments.
  4. Returns a ConstantCallSite whose target either (a) returns the singleton for non-capturing lambdas, or (b) invokes the generated constructor on each call for capturing ones.

The spun class lives in an anonymous (in the host-class sense) ClassData — it doesn't appear in directory listings, but Class.getName() shows something like Service$$Lambda$23/0x000000800101a000. The class is dropped when its defining classloader is collected.

There is also altMetafactory, used when the lambda needs anything more than a single SAM: serializability, additional marker interfaces (e.g., Comparator<T> & Serializable), or bridge methods. altMetafactory takes a FLAG_* bitmask plus the variadic extras and returns a richer call site.


6. Serializable lambdas

A lambda is serializable when its target type is Serializable (either the functional interface is Serializable, or you write an intersection type cast: (Comparator<T> & Serializable)). There is no @SerializableLambda annotation — the JDK uses interface intersections.

import java.io.Serializable;

@FunctionalInterface
interface SerializableComparator<T> extends Comparator<T>, Serializable {}

SerializableComparator<String> byLen = (a, b) -> Integer.compare(a.length(), b.length());

// Or, ad-hoc, at the call site:
Comparator<String> byLen2 = (Comparator<String> & Serializable)
        (a, b) -> Integer.compare(a.length(), b.length());

When the metafactory sees the Serializable marker, it generates a writeReplace method on the spun class that produces a SerializedLambda object — a structural description of the lambda containing:

  • The capturing class name.
  • The functional interface name and SAM signature.
  • The implementation method's owner, name, kind, and signature.
  • The captured values.

On deserialization, the JVM calls readResolve on SerializedLambda, looks up the same lambda$N static method on the original class, and reconstructs an instance. Two practical consequences:

  • Refactoring breaks serialized lambdas. Rename the lambda's enclosing method or change its signature and previously-serialized lambdas deserialize to LambdaConversionException (find-bug.md covers a real instance). Treat serialized lambdas like binary APIs.
  • Captures must themselves be Serializable. Capturing a non-serializable object throws NotSerializableException at write time, not compile time.

For most code paths, prefer not to serialize lambdas. Where you must (some distributed-computing frameworks rely on it), document the serialized shape and avoid refactoring the enclosing class.


7. Bridge methods, generics, and the instantiated type

Generics in functional interfaces are erased at runtime; the SAM signature in bytecode uses Object. The metafactory inserts a bridge method that performs the necessary casts:

Function<String, Integer> f = String::length;
// Erased SAM:        Object apply(Object)
// Bridge generated:  Object apply(Object x) { return apply((String) x); }
// Specialised body:  Integer apply(String x) { return ((String) x).length(); }

This is why an invokeinterface call to Function.apply(Object) on a Function<String, Integer> works correctly even though the actual implementation method takes a String. The bridge does the cast; if you somehow hand in the wrong type (raw types, unsafe reflection), you get a ClassCastException at the bridge, not where the value originated.

The instantiatedMethodType parameter of metafactory carries the post-erasure-but-pre-bridge typing, which is what the JIT uses to specialise the call when possible.


8. When a lambda is not free

Common cases where the cheap-by-default story breaks:

  • The lambda captures a long or double plus references. The capture object grows; allocation isn't free.
  • The lambda is evaluated inside a megamorphic call site. If list.forEach(...) sees many different lambda shapes, HotSpot's type profile saturates and falls back to a true invokeinterface.
  • The lambda is serialized. writeReplace allocates a SerializedLambda; readResolve reflects.
  • The lambda's body itself allocates. A Function<T, R> that builds a new object every call is not a lambda problem — it's a body problem — but lambda syntax can hide allocations behind tiny expressions (x -> x.toString() is one String allocation per call).

Optimize.md walks the JIT side of this. The point here is structural: every lambda has a fixed cost (one capture object, one SAM call) plus the cost of its body. Make both small.


9. Reflection on lambdas — limited and intentional

There is no public way to read a lambda's body. Class.getDeclaredMethods on a spun lambda class returns the SAM and bridges; Method.toGenericString is not a substitute for source. The metafactory deliberately hides the implementation strategy so future JDKs can change it.

For Serializable lambdas, you can reflect on SerializedLambda (via the implicit writeReplace) to retrieve the implementation method's name and owner — which is how some libraries (Spring Data, jOOQ) recover method names from lambdas for query DSLs:

import java.lang.invoke.SerializedLambda;
import java.lang.reflect.Method;

static SerializedLambda extract(Serializable lambda) throws Exception {
    Method m = lambda.getClass().getDeclaredMethod("writeReplace");
    m.setAccessible(true);
    return (SerializedLambda) m.invoke(lambda);
}

This is a reflection contract, not a language one — production code should pin a specific JDK and test on upgrade. Cross-reference: see ../03-reflection-and-annotations/ for the reflection mechanics.


10. Quick rules

  • Every lambda compiles to a private static method + an invokedynamic site bound to LambdaMetafactory.metafactory.
  • Non-capturing lambdas are singletons; capturing ones allocate a small object per evaluation (~16–24 bytes).
  • Captured locals must be effectively final; the JVM copies them at evaluation time.
  • A lambda's this is the enclosing class's this — implicit capture retains the outer instance.
  • Anonymous-class → lambda conversion changes the meaning of this. Audit before applying IDE quick-fixes.
  • Serializable lambdas use intersection types (SAM & Serializable), not an annotation, and break on refactor.
  • Reflection on lambda bodies is intentionally limited; only SerializedLambda exposes the impl method name.

11. What's next

Topic File
Reviewing lambdas at the team level; library API design professional.md
JLS/JVMS/JEP authority for everything in this file specification.md
Ten silent lambda bugs caught in real systems find-bug.md
Cold-start cost, primitive specializations, JIT inlining optimize.md
Exercises tasks.md
Interview Q&A interview.md

See also: ../../06-method-dispatch-and-internals/01-jvm-method-dispatch/ for invokevirtual/invokeinterface/invokedynamic in depth; ../03-reflection-and-annotations/ for MethodHandle and MethodHandles.Lookup; ../05-default-methods-and-diamond-problem/ for how default methods coexist with SAM detection.


Memorize this: a lambda is three artefacts — a synthetic private static method, an invokedynamic site bound to LambdaMetafactory.metafactory, and a tiny capture object (often a singleton). this is outer, captures are copies, serializability is an intersection type. Once you can sketch those three artefacts in your head for a given lambda, the surprises — refactors that change this, leaks via implicit outer capture, broken serialized lambdas after rename — stop being surprises.