Definition
- Synthetic method – the lambda body compiles to a
private static(or instance) method in the enclosing class, not a separate class file. - invokedynamic – a bytecode instruction that is unlinked when loaded and gets linked on first execution by calling a bootstrap method.
- LambdaMetafactory – the bootstrap method that creates the functional-interface implementation, typically by generating a small hidden class at run time.
- CallSite caching – the factory returns a
CallSitewhose target is cached, so subsequent executions reuse it with no re-bootstrapping. - Non-capturing singleton – a lambda that captures no state is created once and reused, allocating nothing on hot paths.
Suppose National Bank of India needs a small rule like "charge a fee only if the balance is above ten lakh." With the old anonymous-class approach, the bank would print a brand-new permanent rulebook for that one rule up front, a whole class file. The lambda approach is smarter: the bank writes the rule as a short private note in the existing manual and leaves a placeholder that says "ask the factory to wire this up the first time we actually need it." The first time a clerk reaches that spot, the factory assembles a tiny adapter pointing at the note and pins it there, so every later clerk reuses the same adapter.
Understanding the Concepts
How are lambda expressions implemented at the bytecode level?
A lambda expression is not compiled into a separate anonymous class. Instead, the compiler emits the lambda body as a private synthetic method in the enclosing class, and at the use site it emits an invokedynamic bytecode instruction whose bootstrap method is LambdaMetafactory.
package com.bank.service;
import java.math.BigDecimal;
import java.util.List;
import java.util.stream.Collectors;
public class LambdaBytecodeDemo {
private static final BigDecimal TEN_LAKH = new BigDecimal("1000000");
public List<BigDecimal> filterHighBalance(List<BigDecimal> balances) {
// This lambda compiles to a private static method:
// private static boolean lambda$filterHighBalance$0(BigDecimal b)
// And an invokedynamic site bootstrapped by LambdaMetafactory
return balances.stream()
.filter(b -> b.compareTo(TEN_LAKH) > 0)
.collect(Collectors.toList());
}
public static void main(String[] args) {
List<BigDecimal> balances = List.of(
new BigDecimal("500000"),
new BigDecimal("1500000"),
new BigDecimal("2000000"));
List<BigDecimal> high = new LambdaBytecodeDemo()
.filterHighBalance(balances);
System.out.println("High-balance accounts: " + high);
}
}
High-balance accounts: [1500000, 2000000]
# Inspect the bytecode to see invokedynamic
javap -c -p com.bank.service.LambdaBytecodeDemo
# Relevant bytecode output:
invokedynamic #2, 0 // InvokeDynamic #0:test:()Ljava/util/function/Predicate;
...
private static boolean lambda$filterHighBalance$0(java.math.BigDecimal);
Why did the language designers choose this approach?
Before lambdas, anonymous inner classes were the only way to pass behavior. Each anonymous class generated a separate .class file. If you had thousands of small callbacks, you got thousands of tiny class files, which bloated the JAR, slowed class loading, and pressured Metaspace. The designers wanted lambdas to be lightweight and the implementation strategy to be flexible enough to improve without changing bytecode.
// Before Java 8: anonymous class generates a separate class file
import java.util.function.Predicate;
import java.math.BigDecimal;
public class AnonymousClassDemo {
// Generates: AnonymousClassDemo$1.class
Predicate<BigDecimal> isHighBalance = new Predicate<BigDecimal>() {
@Override
public boolean test(BigDecimal balance) {
return balance.compareTo(new BigDecimal("1000000")) > 0;
}
};
}
Anonymous class approach: AnonymousClassDemo.class AnonymousClassDemo$1.class ← extra class file per callback AnonymousClassDemo$2.class AnonymousClassDemo$3.class ← JAR bloat Lambda approach: LambdaBytecodeDemo.class ← one class, lambda bodies are methods inside it
How does invokedynamic and LambdaMetafactory work?
The invokedynamic instruction is unlinked when the class is loaded. The first time it executes, the JVM calls the bootstrap method, which is LambdaMetafactory.metafactory. The factory receives the functional interface type, the signature, and a method handle pointing to the synthetic lambda method. It creates the implementation and returns a CallSite that is cached.
package com.bank.service;
import java.lang.invoke.*;
import java.math.BigDecimal;
import java.util.function.Predicate;
public class ManualBootstrapDemo {
// This is what LambdaMetafactory does behind the scenes
public static void main(String[] args) throws Throwable {
MethodHandles.Lookup lookup = MethodHandles.lookup();
// The lambda body as a method handle
MethodHandle lambdaBody = lookup.findStatic(
ManualBootstrapDemo.class,
"isHighBalance",
MethodType.methodType(boolean.class, BigDecimal.class));
// Bootstrap: create the Predicate implementation
CallSite callSite = LambdaMetafactory.metafactory(
lookup,
"test", // SAM method name
MethodType.methodType(Predicate.class), // factory return type
MethodType.methodType(boolean.class, Object.class), // erased SAM
lambdaBody, // implementation
MethodType.methodType(boolean.class, BigDecimal.class) // specific
);
// Get the Predicate (cached after first call)
Predicate<BigDecimal> predicate =
(Predicate<BigDecimal>) callSite.getTarget().invoke();
System.out.println("Is high balance: "
+ predicate.test(new BigDecimal("1500000")));
}
private static boolean isHighBalance(BigDecimal balance) {
return balance.compareTo(new BigDecimal("1000000")) > 0;
}
}
Is high balance: true
Why is this cheaper than anonymous classes?
Four concrete reasons make lambdas cheaper.
- No per-lambda class file at compile time: JARs are smaller, fewer classes to load and verify.
- Runtime flexibility:
LambdaMetafactorydecides the best representation at run time, and the JDK can optimize it without changing bytecode. - Non-capturing singleton: a stateless lambda is created once and reused, allocating nothing after the first link.
- Lazy bootstrapping: the cost is paid only the first time the site runs, and only if it runs.
package com.bank.service;
import java.util.function.Predicate;
import java.math.BigDecimal;
public class SingletonLambdaDemo {
public static void main(String[] args) {
// Non-capturing lambda: same object reused
Predicate<BigDecimal> a = b -> b.signum() > 0;
Predicate<BigDecimal> b = b2 -> b2.signum() > 0;
// Implementation-dependent, but typically:
// Non-capturing lambdas at the same site are the same object
System.out.println("Same expression, same site: " + (a == a));
}
}
How do capturing vs non-capturing lambdas differ?
A capturing lambda closes over local variables or this, so it needs an instance holding the captured values. A non-capturing lambda has no state and can be a singleton.
package com.bank.service;
import java.math.BigDecimal;
import java.util.function.Predicate;
public class CapturingDemo {
public static void main(String[] args) {
BigDecimal threshold = new BigDecimal("500000");
// Capturing lambda: closes over 'threshold'
// Needs an instance holding the captured value
Predicate<BigDecimal> isAbove = balance ->
balance.compareTo(threshold) > 0;
// Non-capturing lambda: no captured state
// Can be a singleton, zero allocation after first link
Predicate<BigDecimal> isPositive = balance ->
balance.signum() > 0;
System.out.println("Above 5L: " + isAbove.test(new BigDecimal("600000")));
System.out.println("Positive: " + isPositive.test(new BigDecimal("100")));
}
}
Above 5L: true
Positive: true
What about JIT inlining of lambdas?
Because the lambda body is a method in the enclosing class, the JIT compiler can inline through the functional-interface call. Hot lambdas can be as fast as direct calls once optimized.
package com.bank.service;
import java.math.BigDecimal;
import java.util.List;
public class InliningDemo {
public static void main(String[] args) {
List<BigDecimal> accounts = List.of(
new BigDecimal("100000"),
new BigDecimal("200000"),
new BigDecimal("300000"));
// The JIT can inline this entire chain:
// stream() -> filter(lambda) -> map(lambda) -> sum
BigDecimal total = accounts.stream()
.filter(b -> b.compareTo(new BigDecimal("150000")) > 0)
.reduce(BigDecimal.ZERO, BigDecimal::add);
System.out.println("Total above 1.5L: " + total);
}
}
Total above 1.5L: 500000
What else uses invokedynamic?
The same invokedynamic plus bootstrap-method machinery powers other dynamic features in Java.
- String concatenation (Java 9+):
"Hello " + namecompiles toinvokedynamicwithStringConcatFactory. - Record components: pattern matching and deconstruction use
invokedynamic. - Switch expressions with pattern matching use
invokedynamicfor dispatch.
package com.bank.service;
public class StringConcatDemo {
public static void main(String[] args) {
String accountId = "SAV2001";
String name = "Rajesh";
// Java 9+: this compiles to invokedynamic + StringConcatFactory
// Not StringBuilder like older Java
String message = "Account " + accountId + " belongs to " + name;
System.out.println(message);
}
}
What should a senior engineer know?
- Capturing lambdas are cheaper in class count than anonymous classes but not singletons.
- Because lambda classes are generated at run time, they do not exist as static class files, which can complicate bytecode-manipulation and native-image scenarios.
- GraalVM native image needs special handling for lambda classes.
- You can observe singleton behavior: two references to the same non-capturing lambda expression at the same site are the same object.
package com.bank.service;
import java.util.function.Function;
import java.math.BigDecimal;
public class LambdaIdentityDemo {
public static void main(String[] args) {
// Non-capturing lambda at the same call site
Function<BigDecimal, String> fn1 = getFormatter();
Function<BigDecimal, String> fn2 = getFormatter();
// Typically the same object (singleton)
System.out.println("Same object: " + (fn1 == fn2));
}
private static Function<BigDecimal, String> getFormatter() {
return amount -> "INR " + amount.toPlainString();
}
}
Same object: true
Visual Overview
Anonymous Class (Old Way):
┌─────────────────┐
│ Compiler │
│ │
│ MyClass.java │──▶ MyClass.class
│ │──▶ MyClass$1.class (anonymous class 1)
│ │──▶ MyClass$2.class (anonymous class 2)
│ │──▶ MyClass$3.class (anonymous class 3)
└─────────────────┘ Each callback = new class file
Lambda (New Way):
┌─────────────────┐
│ Compiler │
│ │
│ MyClass.java │──▶ MyClass.class
│ │ (lambda bodies are methods inside)
│ │ (invokedynamic sites = placeholders)
└─────────────────┘
Runtime Bootstrap:
┌───────────┐ first call ┌──────────────────┐
│invokedyn │──────────────▶│ LambdaMetafactory│
│amic site │ │ │
└───────────┘ │ 1. Read method │
│ │ handle │
│ │ 2. Generate tiny │
│ │ hidden class │
│ │ 3. Return cached │
│ │ CallSite │
│ └──────────────────┘
│
▼ subsequent calls
┌───────────┐
│ Cached │ (no re-bootstrapping,
│ CallSite │ non-capturing = singleton)
└───────────┘
Key Takeaway
A lambda expression is not compiled into a separate anonymous class. Instead, the compiler emits the lambda body as a private synthetic method in the enclosing class, and at the use site it emits an invokedynamic bytecode instruction whose bootstrap method is LambdaMetafactory. The first time that invokedynamic site executes, the JVM calls LambdaMetafactory, which creates an object implementing the target functional interface whose single method forwards to the synthetic method, and then caches that linkage in the call site so every later execution reuses it with no re-bootstrapping.
This is cheaper than an anonymous class per lambda for four reasons. No separate class file is produced at compile time, so JARs are smaller and there are fewer classes to load. The creation strategy is decided at run time by LambdaMetafactory, so the JDK can optimize it. Most importantly, a non-capturing lambda is created once and reused as a singleton, so a hot loop allocates nothing after the first link. And the bootstrap cost is lazy, paid only the first time the site runs.
In the transaction processor at National Bank of India Rajesh used this heavily. The rules engine applies many small predicates and transformations as lambdas, which kept the class count down and, for non-capturing rules like a credit-check threshold, avoided per-iteration allocations in hot filter loops. A rule that closes over a run-time threshold needs an instance holding that value, while a stateless rule becomes a reused singleton.
Remember one sentence: a lambda compiles to a private method plus an invokedynamic site bootstrapped by LambdaMetafactory, which lazily creates and caches the functional-interface implementation so non-capturing lambdas are singletons and no extra class files are produced.
---