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:
$ javac -d out Shout.java && ls out
Shout$1.class Shout.classthe 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.
- 1Function<String, String> up =
- 2 s -> s.toUpperCase();
- // main():
- invokedynamic #0:apply:()Function
- // a new private method, holding the body
- private static String lambda$main$0(String s) {
- return s.toUpperCase();
- }
- final class Shout$$Lambda implements Function {
- private Shout$$Lambda() { super(); }
- public Object apply(Object s) {
- return Shout.lambda$main$0((String) s);
- }
- }
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:
$ 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.
you can watch the classes being generated with the property named in InnerClassLambdaMetafactory:
$ 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:
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)));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());
}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 write | javac generates | captures |
|---|---|---|
s -> s.length() | private static lambda method | nothing: one shared instance |
String::length | no method at all, the handle points at String.length | nothing |
s -> s + bang (a local) | private static, bang becomes a parameter | the value of bang, copied into a final field |
s -> s + this.prefix, or any instance field or method | private instance lambda method | this, the whole enclosing object |
prefix::concat (a bound reference) | nothing | the 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.
- a lambda body becomes a private method (
lambda$main$0). the expression becomes oneinvokedynamic. - on first use,
LambdaMetafactorygenerates 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.