skip to content
everything.deepDives.lambdaInternals

how lambdas are compiled

no anonymous classes. invokedynamic and LambdaMetafactory.

chapter
chapter 17.09
reading time
7 min read

a lambda is not an anonymous class in disguise. javac moves its body into a private synthetic method (lambda$main$0) and leaves one invokedynamic where the lambda was. the first time that instruction runs, LambdaMetafactory generates a small hidden class implementing your interface and links the call site. a lambda that captures nothing then returns the same object forever; a capturing one allocates a new object per evaluation.

the picture

the lambdas chapter covers the syntax and the effectively-final rule. this one compiles a lambda and looks at every byte.

here's the experiment: one file, two lambdas and an anonymous class, compiled with javac 25:

terminal
$ javac -d out Shout.java && ls out
Shout$1.class   Shout.class

the anonymous Runnable became Shout$1.class, a real file, the way java has always compiled inner classes. the two lambdas left no files behind at all. so where did they go?

think of it as a recipe versus a kitchen. an anonymous class ships you a whole kitchen (a class file) per recipe. a lambda ships only the recipe (a method) and a note saying "build a kitchen for this when someone first asks". the JVM builds it at run time, in memory, and can decide how.

you write =
Shout.java
  1. 1Function<String, String> up =
  2. 2 s -> s.toUpperCase();
javac puts in Shout.class
  1. // main():
  2. invokedynamic #0:apply:()Function
  3. // a new private method, holding the body
  4. private static String lambda$main$0(String s) {
  5. return s.toUpperCase();
  6. }
at run time: Shout$$Lambda (hidden class)
  1. final class Shout$$Lambda implements Function {
  2. private Shout$$Lambda() { super(); }
  3. public Object apply(Object s) {
  4. return Shout.lambda$main$0((String) s);
  5. }
  6. }
files on disk: Shout.class only
each evaluation: the call site is linked to one pre-built instance. every evaluation returns the same object: no allocation, ever.
pick one. left: what javac writes into Shout.class (real javap -c -p). right: the class that exists at run time (for the lambdas, the real hidden class, dumped from a live JVM). the amber lines are where the lambda went.

invokedynamic and the bootstrap

the invokedynamic instruction for s -> s.toUpperCase() points at a bootstrap method plus three static arguments. here they are, straight from javap -v:

terminal
$ javap -v Shout | grep -A5 BootstrapMethods
BootstrapMethods:
  0: #80 REF_invokeStatic java/lang/invoke/LambdaMetafactory.metafactory:(...)Ljava/lang/invoke/CallSite;
    Method arguments:
      #68 (Ljava/lang/Object;)Ljava/lang/Object;
      #69 REF_invokeStatic Shout.lambda$main$0:(Ljava/lang/String;)Ljava/lang/String;
      #72 (Ljava/lang/String;)Ljava/lang/String;

read them as: "implement a method whose erased signature is Object → Object (that's Function.apply), by calling Shout.lambda$main$0, and the specialized signature is String → String". the indy instruction itself says apply:()Ljava/util/function/Function;, meaning "a factory that takes no captured values and returns a Function". for the capturing lambda it's apply:(Ljava/lang/String;)...: the captured bang is an argument to the factory.

hover a stage.

hover a stage. everything up to the call site happens once per lambda expression in your code, the first time it runs. after that, the indy instruction is as cheap as a field load (non-capturing) or a new (capturing).

you can watch the classes being generated with the property named in InnerClassLambdaMetafactory:

terminal
$ java -Djdk.invoke.LambdaMetafactory.dumpProxyClassFiles=true Shout
HI HI!
anon
$ ls DUMP_LAMBDA_PROXY_CLASS_FILES
Shout$$Lambda.0x00001f0001040210.class   Shout$$Lambda.0x00001f0001040458.class

(older guides say -Djdk.internal.lambda.dumpProxyClasses=<dir>. that property is gone: the jdk 21 and jdk 25 sources both read the one above, and the directory name is fixed.)

the same object, or a new one

the last stage of that flow is the part interviewers love. when there is nothing to capture, the metafactory builds one instance up front and links the call site to a constant. when there is, it links to the constructor:

predict the output
java
var plain = new ArrayList<Supplier<String>>();
var capturing = new ArrayList<Supplier<String>>();
for (int i = 0; i < 3; i++) {
    plain.add(() -> "same");      // captures nothing
    int n = i;
    capturing.add(() -> "n=" + n); // captures n
}
IO.println((plain.get(0) == plain.get(1)) + " " + (plain.get(1) == plain.get(2)));
IO.println((capturing.get(0) == capturing.get(1)) + " " + (capturing.get(1) == capturing.get(2)));
java
void main() {
    var plain = new ArrayList<Supplier<String>>();
    var capturing = new ArrayList<Supplier<String>>();
    for (int i = 0; i < 3; i++) {
        plain.add(() -> "same");      // captures nothing
        int n = i;
        capturing.add(() -> "n=" + n); // captures n
    }
    IO.println((plain.get(0) == plain.get(1)) + " " + (plain.get(1) == plain.get(2)));
    IO.println((capturing.get(0) == capturing.get(1)) + " " + (capturing.get(1) == capturing.get(2)));
    IO.println(plain.get(0).getClass().isHidden() + " " + plain.get(0).getClass().isSynthetic());
}
output
true true
false false
true true

the last line confirms the class is hidden (no class loader can find it by name) and synthetic.

what counts as capturing

you writejavac generatescaptures
s -> s.length()private static lambda methodnothing: one shared instance
String::lengthno method at all, the handle points at String.lengthnothing
s -> s + bang (a local)private static, bang becomes a parameterthe value of bang, copied into a final field
s -> s + this.prefix, or any instance field or methodprivate instance lambda methodthis, the whole enclosing object
prefix::concat (a bound reference)nothingthe receiver object prefix

we checked the first and fourth rows with javap -p on a class with both: a lambda that touches nothing compiled to private static void lambda$..., one that reads a field compiled to private void lambda$... (no static), which means the generated class keeps a reference to this.

why bother with all this?

because "how to turn a lambda into an object" is now a decision the JDK makes at run time instead of a decision javac baked into your jar in 2014:

  • no class file per lambda. a big codebase has thousands of lambdas. with inner classes that's thousands of files to load, verify and keep in metaspace whether or not they ever run. with indy, nothing exists until a lambda is first evaluated.
  • free singletons. the non-capturing case allocates nothing, ever. an anonymous class can't do that without you writing a static field by hand.
  • the strategy can change. jdk 21 generated these classes with an internal copy of ASM; jdk 25 writes them with the ClassFile API (we diffed the two sources). code compiled years ago didn't notice and didn't need to. string concatenation uses the same trick, and so do records' toString/equals/hashCode (see invokedynamic).

the cost is paid once per lambda expression, on first use: running the bootstrap, generating and defining a class. that's measurable at startup (thousands of lambdas, thousands of tiny classes), and it's exactly what CDS and the AOT cache can archive ahead of time: the CDS.isDumpingArchive() check in the metafactory is there for that.

keylines
  • a lambda body becomes a private method (lambda$main$0). the expression becomes one invokedynamic.
  • on first use, LambdaMetafactory generates a hidden class implementing the interface and links the call site for good.
  • no captures: one shared instance, zero allocation. captures: a new object per evaluation, fields hold the copies.
  • touching a field captures this. that's how lambdas leak whole objects.
  • no class file per lambda. dump the generated ones with -Djdk.invoke.LambdaMetafactory.dumpProxyClassFiles.