Java Lambda Deep Dive: Why It's Not Anonymous Inner Class Syntactic Sugar & How invokedynamic Powers It
This article dissects Java Lambda implementation, revealing how invokedynamic and LambdaMetafactory dynamically generate hidden classes at runtime, why non-capturing lambdas are singletons, how captured variables become final field copies, and the performance implications versus anonymous inner classes.
You write lambdas daily — list.forEach(item -> System.out.println(item)), Runnable r = () -> System.out.println("hello"), button.addActionListener(e -> dialog.setVisible(true)) — but have you wondered what they compile to, whether they're just anonymous inner class syntactic sugar, why non-capturing lambdas are singletons, what role invokedynamic plays, and why captured variables must be final?
I. Lambda Is Not an Anonymous Inner Class
1.1 What Anonymous Inner Classes Compile To
Traditional anonymous inner class:
public class Demo { public void test() { Runnable r = new Runnable() { @Override public void run() { System.out.println("hello"); } }; r.run(); } }Compilation produces two class files : Demo.class and Demo$1.class (the anonymous inner class). Decompiling Demo$1.class shows a real class with fields, constructor, and full class structure:
final class Demo$1 implements Runnable { Demo$1() {} public void run() { System.out.println("hello"); } }Anonymous inner classes are compile-time determined real classes.
1.2 What Lambdas Compile To
Same logic with lambda:
public class Demo { public void test() { Runnable r = () -> System.out.println("hello"); r.run(); } }Compilation produces only one class file : Demo.class. No Demo$$Lambda$1.class is generated. The lambda's implementation class is created at runtime .
1.3 Full Bytecode Comparison
Using javap -c -p -v Demo on the lambda version, the test() method shows:
private void test(); descriptor: ()V flags: (0x0002) ACC_PRIVATE Code: stack=1, locals=2, args_size=1 0: invokedynamic #2, 0 // InvokeDynamic #0:run:()Ljava/lang/Runnable; 5: astore_1 6: aload_1 7: invokeinterface #3, 1 // InterfaceMethod java/lang/Runnable.run:()V 12: returnKey at offset 0: invokedynamic #2, 0. The lambda compiles to an invokedynamic instruction, not a direct new of an implementation class.
1.4 Lambda Body Becomes a Private Method
The lambda body is not discarded; it's compiled into a private method in the enclosing class:
private static void lambda$test$0(); descriptor: ()V flags: (0x100a) ACC_PRIVATE, ACC_STATIC, ACC_SYNTHETIC Code: stack=2, locals=0, args_size=0 0: getstatic #4 // Field java/lang/System.out:Ljava/io/PrintStream; 3: ldc #5 // String hello 5: invokevirtual #6 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 8: return ACC_SYNTHETICmarks it as compiler-generated, invisible to source.
Naming rule: lambda$methodName$index, here lambda$test$0.
The dynamically generated implementation class will call this method via MethodHandle.
Lambda compilation yields two artifacts: (1) an invokedynamic instruction at the call site, (2) a private method lambda$xxx$0 holding the lambda body.
II. What Is invokedynamic ?
2.1 Origins
invokedynamicwas introduced in Java 7 (JSR-292) to support dynamic languages on the JVM (JRuby, Groovy, Nashorn). Java 8 adopted it for lambdas; Java 9+ uses it for string concatenation, switch pattern matching, etc.
JVM has five method invocation instructions:
invokestatic — static methods; target determined at compile time.
invokespecial — constructors, private methods, super calls; target determined at compile time.
invokevirtual — virtual methods (ordinary instance methods); target determined at runtime by actual type.
invokeinterface — interface methods; target determined at runtime by lookup.
invokedynamic — dynamic call sites; target determined on first execution via bootstrap method, then cached.
The first four have their target method fixed in the constant pool at compile time. invokedynamic on first execution calls a bootstrap method which returns a CallSite containing a MethodHandle — the actual target to execute.
2.2 Three Core Concepts
// 1. MethodHandle: method reference, like C function pointer MethodHandle mh = MethodHandles.lookup() .findStatic(Demo.class, "lambda$test$0", MethodType.methodType(void.class)); // 2. MethodType: method signature (params + return) MethodType mt = MethodType.methodType(void.class); // ()V MethodType mt2 = MethodType.methodType(String.class, int.class); // (int)String // 3. CallSite: call site holding a MethodHandle for invokedynamic CallSite callSite = new ConstantCallSite(mh); MethodHandleis a direct executable reference to a method. MethodType describes the signature for type matching. CallSite is what invokedynamic binds to.
2.3 BootstrapMethods Table
Decompiled class files end with a BootstrapMethods table defining which bootstrap method each invokedynamic uses:
BootstrapMethods: 0: #24 invokestatic java/lang/invoke/LambdaMetafactory.metafactory: (Ljava/lang/invoke/MethodHandles$Lookup; Ljava/lang/String; Ljava/lang/invoke/MethodType; Ljava/lang/invoke/MethodType; Ljava/lang/invoke/MethodHandle; Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite; Method arguments: #25 ()V // 1. SAM method signature (erased) #26 invokestatic Demo.lambda$test$0:()V // 2. Lambda body MethodHandle #27 ()V // 3. Instantiation signature (may carry generics)Three key arguments:
First (samMethodType) : SAM method signature, e.g., Runnable.run() is ()V.
Second (implMethod) : The private lambda body method, lambda$test$0.
Third (instantiatedMethodType) : Actual method signature for the generated class; carries concrete generic types when applicable.
Bootstrap method fixed signature:
public static CallSite metafactory(MethodHandles.Lookup caller, String invokedName, // interface method name, e.g., "run" MethodType invokedType, // invokedynamic type (constructor params -> interface) MethodType samMethodType, // SAM signature MethodHandle implMethod, // lambda body method MethodType instantiatedMethodType // instantiation signature)III. LambdaMetafactory: Runtime Class Generation
3.1 What metafactory Does
LambdaMetafactory.metafactory()is the bootstrap method; its core responsibilities:
At runtime, use ASM to dynamically generate an implementation class (implementing Runnable, Function, etc.).
Generated class methods call your compiled lambda$xxx$0 via MethodHandle.
Non-capturing lambdas return a singleton; capturing lambdas return a constructor method handle.
Wrap in ConstantCallSite and return; JVM caches it permanently.
Simplified flow:
invokedynamic first execution ↓ JVM calls LambdaMetafactory.metafactory() ↓ Internally uses ASM to generate a class (e.g., Demo$$Lambda$1) ↓ Class implements Runnable, run() calls Demo.lambda$test$0() ↓ Non-capturing: class holds static singleton INSTANCE, returns it Capturing: returns constructor method handle, new instance each call ↓ CallSite cached; subsequent invokedynamic skips bootstrap3.2 metafactory Source Core Logic
JDK source ( java.lang.invoke.LambdaMetafactory) simplified:
public static CallSite metafactory(MethodHandles.Lookup caller, String invokedName, MethodType invokedType, MethodType samMethodType, MethodHandle implMethod, MethodType instantiatedMethodType) { // 1. Validate: functional interface must have exactly one abstract method Class<?> targetClass = invokedType.returnType(); MethodHandle samMethod = ...; // find SAM method // 2. Use InnerClassLambdaMetafactory to actually generate class AbstractValidatingLambdaMetafactory mf = new InnerClassLambdaMetafactory(caller, targetClass, invokedName, invokedType, samMethodType, implMethod, instantiatedMethodType, false, EMPTY_CLASS_ARRAY, EMPTY_METHTYPE_ARRAY); mf.validateMetafactoryArgs(); // 3. Build CallSite return mf.buildCallSite(); } InnerClassLambdaMetafactory.buildCallSite()internals:
CallSite buildCallSite() throws LambdaConversionException { // Generate bytecode with ASM final Class<?> innerClass = spinInnerClass(); if (invokedType.parameterCount() == 0) { // Non-capturing: no captured params, generate singleton Object inst = UNSAFE.allocateInstance(innerClass); UNSAFE.putObject(inst, ..., capturedArgs); // Return MethodHandle that directly returns singleton return new ConstantCallSite(MethodHandles.constant(samBase, inst)); } else { // Capturing: return constructor handle, new instance each call MethodHandle mh = LOOKUP.findConstructor(innerClass, invokedType); return new ConstantCallSite(mh); } } spinInnerClass()uses ASM to emit bytecode; key fragments:
private Class<?> spinInnerClass() { // Generate class name: Demo$$Lambda$1 String className = targetClass.getName().replace('.', '/') + "$$Lambda$" + counter.incrementAndGet(); // Generate class implementing functional interface cw.visit(CLASSFILE_VERSION, ACC_FINAL | ACC_SYNTHETIC, className, null, "java/lang/Object", new String[]{samBase.getName().replace('.', '/')}); // Generate final fields for captured variables for (int i = 0; i < argTypes.length; i++) { FieldVisitor fv = cw.visitField(ACC_PRIVATE | ACC_FINAL, "arg$" + (i+1), argDescriptors[i], null, null); fv.visitEnd(); } // Generate constructor generateConstructor(); // Generate interface method (e.g., run), body calls lambda$xxx$0 generateForwardMethod(); cw.visitEnd(); // Load as hidden class via Lookup.defineHiddenClass return ...; }3.3 Dumping Generated Classes to Disk
Generated classes live in memory by default. JVM parameter to export:
java -Djdk.internal.lambda.dumpProxyClasses=/tmp/lambda DemoAfter running, /tmp/lambda contains Demo$$Lambda$1.class. Decompiled:
final class Demo$$Lambda$1 implements Runnable { private Demo$$Lambda$1() {} static final Demo$$Lambda$1 INSTANCE = new Demo$$Lambda$1(); public void run() { Demo.lambda$test$0(); } }Since Java 15, generated classes are loaded via Lookup.defineHiddenClass as hidden classes , not visible to normal class loaders; hence debugger class names carry suffixes like /0x0000000800000000 .
3.4 Why Generate at Runtime?
Reduce class file count : Thousands of lambdas per project would bloat JARs if each had a .class.
Lazy loading saves memory : Unexecuted lambdas don't generate classes, faster startup.
Flexible instantiation strategy : Non-capturing use singleton, capturing use new — decidable only at runtime.
JVM controls implementation : Different JVMs can optimize freely (e.g., direct inlining, no class generation).
IV. Capturing vs Non-Capturing Lambdas
This is the critical point affecting object creation and performance.
4.1 Non-Capturing Lambdas: Singleton
Lambda referencing no external local variables: Runnable r = () -> System.out.println("hello"); No captures → generated class has no fields, no-arg constructor → made singleton; all call sites share the same instance. Bytecode invokedynamic returns the static INSTANCE every time → zero object allocation .
4.2 Capturing Lambdas: New Object Each Time
Lambda referencing external local variable:
public void test(String name) { Runnable r = () -> System.out.println(name); r.run(); } nameis external; must be passed as parameter to generated class. Bytecode invokedynamic changes:
0: aload_0 // load this (if capturing instance variable) 1: aload_1 // load name parameter 2: invokedynamic #2, 2 // note: 2 args! 7: astore_2 invokedynamic #2, 2second operand is 2, meaning push this and name as arguments to bootstrap method.
Generated class roughly:
final class Demo$$Lambda$2 implements Runnable { private final Demo arg$1; // captured this (if using instance members) private final String arg$2; // captured name Demo$$Lambda$2(Demo arg$1, String arg$2) { this.arg$1 = arg$1; this.arg$2 = arg$2; } public void run() { this.arg$1.lambda$test$0(this.arg$2); } }Each invokedynamic execution invokes the constructor → new object every time .
4.3 Static vs Instance Methods
Whether lambda$xxx$0 is static or instance depends on whether the lambda uses this:
Lambda in static method → private static lambda$xxx$0; does not capture this.
Lambda in instance method, only local vars → private static lambda$xxx$0; does not capture this.
Lambda in instance method, uses instance field/method → private lambda$xxx$0 (non-static); captures this.
class Foo { private int x; void test() { // Only local var -> static method, no this capture Runnable r1 = () -> { int y = 1; }; // Uses instance field x -> instance method, captures this Runnable r2 = () -> System.out.println(x); } }4.4 Code Verification
public class LambdaSingleton { public static void main(String[] args) { // Non-capturing: same object Runnable r1 = () -> System.out.println("a"); Runnable r2 = () -> System.out.println("a"); System.out.println(r1 == r2); // true, singleton // Capturing: different objects String prefix = "x"; Runnable r3 = () -> System.out.println(prefix); Runnable r4 = () -> System.out.println(prefix); System.out.println(r3 == r4); // false, new each time } }4.5 Why Captured Variables Must Be final
int count = 0; list.forEach(item -> { // count++; // compile error! });Generated class stores a copy of the value (for references, a copy of the reference), not the outer variable itself. Allowing mutation would create semantic confusion:
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Java Tech Workshop
Focused on Java backend technologies, sharing fundamentals, multithreading, JVM, the Spring ecosystem, microservices, distributed systems, high concurrency, source‑code analysis, and practical experience. Continuously delivers high‑quality original content, interview guides, and learning roadmaps to help Java developers progress from beginner to advanced, enhancing technical skills and core competitiveness.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
