The idea: a lambda is a call site that links itself once

javac does not turn x -> x * 2 into a class. It moves the body into a private static method, lambda$main$0, and at the place where the lambda is created it writes one instruction: invokedynamic. That instruction does not say which code to run. It names a bootstrap method that will decide, the first time the instruction runs.

Left: the source line IntUnaryOperator f = x -> x * 2. Right: what javac writes into Shop.class: in main, invokedynamic #0 applyAsInt:()IntUnaryOperator; a BootstrapMethods entry #0 pointing to LambdaMetafactory.metafactory with three static arguments; and a private static method lambda$main$0 holding the body iload_0 iconst_2 imul ireturn. No Shop$1.class file is written
javac writes three things for one lambda: the invokedynamic instruction, a BootstrapMethods entry, and the body as a private method. There is no class file for the lambda.

On the canvas the class file is on the left, the running main() frame and its two call sites in the middle, and metaspace, heap and stdout on the right. Press Next Call to run to the next call instruction, or Run Iteration for one pass of the loop. The lambda menu switches between a lambda that captures nothing and one that captures k.

What javac writes

  • In main: invokedynamic #0 applyAsInt:()IntUnaryOperator. The name is the interface method; the type says "takes nothing, returns an IntUnaryOperator". (Real javap prints invokedynamic #7, 0 // InvokeDynamic #0:applyAsInt:()Ljava/util/function/IntUnaryOperator;; the page shortens it.)
  • In the BootstrapMethods attribute, entry #0: the method handle LambdaMetafactory.metafactory and three static arguments: the erased interface method type (I)I, a handle to lambda$main$0, and the type the call will have, again (I)I.
  • The body as private static synthetic int lambda$main$0(int).

Calling the lambda later (f.applyAsInt(n)) is an ordinary invokeinterface. Only making the lambda object uses invokedynamic.

The first call: bootstrap and link

Every invokedynamic instruction in a class is its own call site, and it starts unlinked. The first time it runs (Demo: first call bootstraps the lambda site):

  1. The JVM resolves the BootstrapMethods entry: the bootstrap method handle and its static arguments.
  2. It calls the bootstrap method with a MethodHandles.Lookup for Shop (so it may see the private lambda$main$0), the name applyAsInt, the call type, and the static arguments.
  3. metafactory writes the bytes of a small class that implements IntUnaryOperator and whose applyAsInt calls lambda$main$0, and defines it with Lookup.defineHiddenClass. A hidden class has no class file, cannot be found by name and can be unloaded with its owner.
  4. It returns a ConstantCallSite whose target is a method handle that produces the lambda object.
  5. The JVM binds that call site to the instruction for good and invokes the target.
Two timelines. First run of invokedynamic #0: unlinked, read BootstrapMethods #0, call LambdaMetafactory.metafactory, spin hidden class Shop$$Lambda, return ConstantCallSite, invoke target, about five steps. Second and third runs: linked, invoke target, one step
The bootstrap runs once per call site; every later run is a direct call of the linked target, which the JIT can inline.

Later calls go straight through

On the second and third pass of the loop the same instructions run again, but the call sites are linked: the JVM calls the target directly. There is no bootstrap method, no new class, and for this lambda not even a new object (Demo: later iterations go straight through: 6 invokedynamic runs, 2 bootstrap calls, 4 fast-path runs). Because the target of a ConstantCallSite can never change, the JIT treats it like a constant and inlines through it, so a linked lambda call site costs about the same as a new or a field read.

Capturing and non-capturing lambdas

x -> x * 2x -> x * k
invokedynamic type()IntUnaryOperator(I)IntUnaryOperator: k is pushed first
Body methodlambda$main$0(int x)lambda$main$0(int k, int x)
Hidden classno fieldsfinal int arg$1 holds k
Call site targeta constant: the one instance made at link timea constructor handle: new Shop$$Lambda(k)
Objects after 3 passes13 (Demo: capturing lambda)

Either way the bootstrap runs once and only one hidden class exists. A captured variable must be effectively final, because the lambda gets a copy of its value, not the variable.

String concatenation with StringConcatFactory

Since Java 9 (JEP 280), javac compiles "f(" + n + ")=" + r to invokedynamic #1 makeConcatWithConstants:(II)String, with entry #1 naming StringConcatFactory.makeConcatWithConstants and one static argument, the recipe "f(\u0001)=\u0001": each \u0001 is a slot for an argument, the rest is constant text. The first run bootstraps a chain of method handles that computes the exact length, fills one byte[] and wraps it in a String, with no StringBuilder and no class file (Demo: string concat via StringConcatFactory). Later runs call that chain directly. The JDK can change the strategy without recompiling your code, because the strategy lives in the bootstrap, not in the class file.

Why invokedynamic and not inner classes

Anonymous inner classLambda with invokedynamic
Class filesone per lambda (Shop$1.class), loaded at startnone; a hidden class is spun on the first call
Cost if never runclass file in the jarnothing: the call site is never bootstrapped
Instancesnew Shop$1() each timenon-capturing: one shared instance
Strategyfixed in the bytecodechosen by the JDK in LambdaMetafactory

How the class file and the interpreter work in general is on How a Java Program Is Compiled and Run; ordinary class loading is on How Java Loads a Class, and how the JIT inlines a hot call site is on HotSpot JIT Tiers.

What the page leaves out

Method references (String::length, same mechanism with a handle to an existing method), serializable lambdas (altMetafactory), MutableCallSite and VolatileCallSite used by language runtimes such as JRuby and Nashorn, ConstantDynamic (ldc of a bootstrapped constant), switch on patterns and records' toString (ObjectMethods), CDS archiving of lambda classes, the exact JDK concat strategy (it changed again in JDK 23), and BootstrapMethodError when a bootstrap throws. Timing is not to scale.