Java Lambda 내부 구현 — invokedynamic, LambdaMetafactory, 그리고 람다가 익명 클래스가 아닌 이유
이 글은 Java 8에서 도입된 람다 표현식이 JVM에서 어떻게 동작하는지 추적합니다.
() -> {}한 줄이 컴파일러에서 사라진 뒤,invokedynamic한 줄과LambdaMetafactory부트스트랩 메서드, 그리고 런타임에 깎이는 익명 클래스로 변신하는 과정을 단계별로 풀어 봅니다. 람다가 그냥 익명 클래스의 신택스 슈가가 아닌 이유와, 같은 모양의 람다가 두 번 평가되어도 같은 객체를 돌려줄 수 있는 이유까지 다룹니다.
람다가 풀어야 했던 문제
Java 8 이전, 콜백을 넘기는 방법은 익명 내부 클래스였습니다.
button.addActionListener(new ActionListener() {
@Override
public void actionPerformed(ActionEvent e) {
System.out.println("clicked");
}
});
문법이 길다는 불편 외에도, 구현 관점에서 두 가지 비용이 있었습니다.
- 익명 클래스 하나마다
Outer\(1.class,Outer\)2.class같은 별도의 .class 파일이 디스크에 박힙니다. 클래스 로딩 비용과 메타스페이스 사용량이 람다 개수만큼 늘어납니다. - 콜백을 호출할 때마다
new Outer$1()이 일어납니다. 캡처할 외부 변수가 없는 stateless 콜백조차 매번 새로운 객체가 만들어집니다.
Java 8을 설계한 팀은 람다를 단순한 신택스 슈가로 두지 않기로 했습니다. Brian Goetz가 정리한 Translation of Lambda Expressions 문서는 그 결정의 출발점을 분명히 적었습니다.
"We want lambda translation to be as dynamic as possible, in particular, to defer the choice of translation strategy from compile time to run time."
즉 컴파일러가 "람다는 곧 익명 클래스다"라고 못박는 대신, 클래스 파일에는 람다를 만드는 레시피만 적어 두고, 실제 만드는 방법은 JVM이 런타임에 결정하도록 위임하기로 했습니다. 이 위임 지점을 만들기 위해 끌어다 쓴 도구가 JSR 292의 invokedynamic입니다.
flowchart LR
subgraph Compile["Compile time"]
SRC["() -> System.out.println('hi')"] --> JC[javac]
JC --> CLS["Class file:\nprivate static void lambda$0()\n+ invokedynamic INDY"]
end
subgraph Runtime["First call at runtime"]
CLS --> BSM["Bootstrap:\nLambdaMetafactory.metafactory"]
BSM --> CS[ConstantCallSite]
CS --> OBJ["Function object\n(spun inner class)"]
end
핵심은 컴파일러가 Runnable 인스턴스를 만드는 코드를 직접 찍지 않는다는 점입니다. "이런 시그니처의 함수 객체가 필요하다"는 명세만 적어 두고, 만드는 책임은 JVM과 표준 라이브러리에 넘깁니다.
javac가 만드는 것 — 두 가지 산출물
람다 한 줄을 컴파일하면 javac는 두 가지 산출물을 남깁니다.
- 람다 본문에 해당하는 private static 메서드 (또는 인스턴스 메서드)
- invokedynamic 명령어 한 줄과 그 명령어가 가리키는 BootstrapMethods 어트리뷰트 엔트리
1. 람다 본문은 메서드로 desugar 됩니다
다음 코드를 컴파일해 보겠습니다.
public class LambdaDemo {
public static void main(String[] args) {
Runnable r = () -> System.out.println("hi");
r.run();
}
}
javap -p -v LambdaDemo.class 결과의 핵심 부분만 옮기면 이렇습니다.
public static void main(java.lang.String[]);
Code:
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: return
private static void lambda\(main\)0();
Code:
0: getstatic #4 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #5 // String hi
5: invokevirtual #6 // Method java/io/PrintStream.println
8: return
람다 본문은 lambda\(main\)0이라는 private static 메서드로 옮겨졌습니다. 람다가 외부 인스턴스 필드를 캡처하는 경우 private 인스턴스 메서드로, this만 캡처하면 synthetic 인스턴스 메서드로 생성됩니다. 이름에는 컴파일러가 부여한 인덱스가 붙어 동일 메서드 안에 람다가 여러 개라도 충돌하지 않습니다.
2. invokedynamic은 "함수 객체를 다오"라는 요청
main의 첫 명령어 invokedynamic #2가 람다 객체를 만드는 자리입니다. invokedynamic은 일반 invokevirtual/invokestatic처럼 정해진 메서드를 부르는 게 아니라, CallSite 객체에 들어 있는 MethodHandle을 호출합니다. 그 CallSite는 처음 명령어에 도달했을 때 한 번만 부트스트랩됩니다.
#2는 상수 풀의 CONSTANT_InvokeDynamic_info 엔트리이고, 이 엔트리는 클래스 파일 뒤쪽 BootstrapMethods 어트리뷰트의 0번 엔트리를 가리킵니다.
BootstrapMethods:
0: #25 REF_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:
#26 ()V
#27 REF_invokeStatic LambdaDemo.lambda\(main\)0:()V
#26 ()V
이 엔트리가 JVM에게 던지는 메시지는 한 문장으로 줄일 수 있습니다.
"다음에 이 invokedynamic을 만나면,
LambdaMetafactory.metafactory를 호출해()→V형 함수를 반환하는 CallSite를 만들어 둬. 그 함수는LambdaDemo.lambda\(main\)0()을 호출하면 된다."
람다 객체를 만들지 않습니다. 만드는 방법만 적어 둡니다.
invokedynamic 한 줄이 약속하는 것
invokedynamic은 JSR 292(Java 7)에서 도입되어 처음에는 JRuby, Nashorn 같은 동적 언어를 위한 명령어로 자리잡았습니다. JEP 126 (Lambda Expressions, Java 8)에서 람다와 invokedynamic의 연결이 정식화된 이후 람다 구현이 이 명령어를 가장 큰 고객으로 만들었습니다.
명령어의 의미는 두 단계입니다.
flowchart TB
subgraph First["First time the indy executes"]
I1[invokedynamic] --> R[JVM resolves CONSTANT_InvokeDynamic]
R --> CALL[Call bootstrap method]
CALL --> CS["CallSite returned\n(target = MethodHandle)"]
CS --> CACHE[Cache CallSite on this indy site]
end
subgraph Later["Every subsequent execution"]
I2[invokedynamic] --> H[Invoke CallSite.target]
H --> RES[Push result on stack]
end
CACHE -.same site.-> I2
처음 만난 invokedynamic은 다음 순서로 풀립니다.
- JVM이 상수 풀에서 부트스트랩 메서드와 정적 인자들(이름, 시그니처, 메서드 핸들 등)을 읽습니다.
- 부트스트랩 메서드를 호출합니다. 람다의 경우 이게
LambdaMetafactory.metafactory(...)입니다. - 부트스트랩이 반환한
CallSite객체를 이 invokedynamic 자리에 묶어 둡니다. - CallSite의 target
MethodHandle을 호출해 결과를 스택에 올립니다.
두 번째 호출부터는 1~3이 생략됩니다. CallSite가 이미 자리에 묶여 있으므로 바로 target을 호출합니다. 람다는 ConstantCallSite를 쓰므로 한 번 묶인 target은 다시 바뀌지 않습니다. 이 사실이 뒤에서 보게 될 캐싱 동작의 근거입니다.
LambdaMetafactory.metafactory — 표준 부트스트랩
람다용 부트스트랩 메서드는 단 하나의 표준 함수, java.lang.invoke.LambdaMetafactory.metafactory입니다. OpenJDK 마스터 기준 시그니처는 이렇습니다.
public static CallSite metafactory(MethodHandles.Lookup caller,
String interfaceMethodName,
MethodType factoryType,
MethodType interfaceMethodType,
MethodHandle implementation,
MethodType dynamicMethodType)
throws LambdaConversionException
각 인자가 람다 한 줄에서 어떻게 매핑되는지 표로 정리해 봅니다. 위 Runnable r = () -> System.out.println("hi"); 기준입니다.
| 인자 | 값 | 의미 |
|---|---|---|
caller |
람다가 정의된 클래스의 Lookup | 접근 권한 컨텍스트. private 메서드인 람다 본문을 호출할 권한이 여기서 옵니다. |
interfaceMethodName |
"run" |
결과 함수 객체가 구현해야 할 함수형 인터페이스의 추상 메서드 이름. |
factoryType |
()Ljava/lang/Runnable; |
CallSite의 target이 호출되었을 때 반환할 타입. 캡처가 없으면 ()→인터페이스, 캡처가 있으면 (캡처들)→인터페이스. |
interfaceMethodType |
()V |
함수형 인터페이스 run의 erasure 시그니처. |
implementation |
MethodHandle → LambdaDemo.lambda\(main\)0()V |
람다 본문을 가리키는 직접 메서드 핸들. |
dynamicMethodType |
()V |
호출 지점에서 본 instantiated 시그니처. 제네릭 함수형 인터페이스라면 interfaceMethodType보다 좁아질 수 있습니다. |
부트스트랩이 끝나면 metafactory는 ConstantCallSite를 돌려줍니다. 그 CallSite의 target은 두 가지 동작 중 하나입니다.
- 캡처가 없는 람다: target은 미리 만들어 둔 함수 객체 인스턴스를 그대로 반환합니다. 즉 매 호출마다 같은 객체가 나옵니다.
- 캡처가 있는 람다: target은 캡처 변수들을 받아 새 인스턴스를 만들어 반환합니다.
이 차이가 () -> 1을 100만 번 평가해도 객체가 1개만 만들어지는 비밀입니다.
altMetafactory — 직렬화와 마커 인터페이스
위 표준 메서드는 가장 단순한 경우만 처리합니다. Serializable 람다나 추가 마커 인터페이스를 붙여야 할 때는 altMetafactory가 부트스트랩으로 쓰입니다.
public static CallSite altMetafactory(MethodHandles.Lookup caller,
String interfaceMethodName,
MethodType factoryType,
Object... args)
throws LambdaConversionException
가변 인자 첫 슬롯에 비트 플래그가 들어 있고 그 뒤로 추가 정보가 따라붙습니다.
| 플래그 | 값 | 의미 |
|---|---|---|
FLAG_SERIALIZABLE |
1 << 0 |
결과 함수 객체가 Serializable을 구현하도록 합니다. writeReplace 메서드까지 생성됩니다. |
FLAG_MARKERS |
1 << 1 |
함수형 인터페이스 외 추가 인터페이스를 구현하도록 합니다. 마커 인터페이스 개수와 이름이 가변 인자에 이어집니다. |
FLAG_BRIDGES |
1 << 2 |
제네릭 시그니처 차이를 메우는 bridge 메서드를 추가합니다. 인터페이스가 default/제네릭 메서드를 갖는 경우 필요합니다. |
이 플래그들은 람다가 모양은 같아도 실제 생성되는 클래스 모양이 달라지는 이유입니다.
InnerClassLambdaMetafactory — 런타임에 클래스를 깎는다
metafactory/altMetafactory는 자기들이 직접 클래스를 만들지 않습니다. 내부 구현인 InnerClassLambdaMetafactory로 작업을 넘깁니다. 이 클래스가 람다 객체의 실제 .class 바이트를 ASM으로 직접 작성합니다.
OpenJDK의 java.base/share/classes/java/lang/invoke/InnerClassLambdaMetafactory.java가 만드는 클래스의 골격은 대체로 다음과 같습니다.
final class LambdaDemo$$Lambda/0x0000600100123a78 implements Runnable {
private LambdaDemo$$Lambda/0x0000600100123a78() {}
public void run() {
LambdaDemo.lambda\(main\)0();
}
// FLAG_SERIALIZABLE 시 추가
// private Object writeReplace() { return new SerializedLambda(...); }
}
캡처가 있다면 인스턴스 필드와 그 필드를 받는 생성자가 추가됩니다.
final class Outer$$Lambda/0x... implements IntUnaryOperator {
private final int x;
private Outer$$Lambda(int x) { this.x = x; }
public int applyAsInt(int n) { return Outer.lambda$0(this.x, n); }
}
생성된 클래스 이름은 OuterClass$$Lambda/0x... 형태고 뒤에 16진 식별자가 붙습니다. JDK 8에서는 $$Lambda$N이었으나, JDK 15부터 hidden class가 정식 기능이 되면서 클래스 이름이 hidden class 형식으로 바뀌었습니다. JVM 파라미터 -Djdk.internal.lambda.dumpProxyClasses=/tmp (또는 9+ 이후 -Djdk.internal.lambda.dumpProxyClassFiles=true)를 지정하면 이 .class 파일을 디스크에 떨궈 직접 들여다볼 수 있습니다.
왜 hidden class인가
JDK 15의 JEP 371: Hidden Classes는 람다의 요구를 정식 메커니즘으로 끌어 올렸습니다. 람다용 합성 클래스는 다음 특성을 가져야 했습니다.
- 일반 클래스 로더의 클래스 목록에 노출되지 않음 — 리플렉션으로
Class.forName되면 안 됨 - GC 가능 — 람다를 만든 메서드가 사라지면 함께 회수되어야 함
- 정의자가 정확한 nest mate로 인식되어 private 멤버에 접근 가능
JDK 14 이전에는 Unsafe.defineAnonymousClass라는 내부 API에 의존했는데, hidden class가 이 동작을 명시적으로 정의하면서 람다 클래스가 자연스럽게 hidden class로 옮겨갔습니다.
Capture 메커니즘 — Stateless vs Capturing
람다가 외부 상태를 잡고 있는지 여부가 메모리/성능 패턴을 바꿉니다.
Runnable a = () -> System.out.println("hi"); // stateless
Runnable b = () -> System.out.println(value); // captures 'value'
flowchart LR
subgraph Stateless["Stateless: factoryType = () -> Runnable"]
CS1[ConstantCallSite] --> S1["Singleton Runnable instance\nreturned every call"]
end
subgraph Capturing["Capturing: factoryType = (int) -> Runnable"]
CS2[ConstantCallSite] --> F["Factory MethodHandle"]
F -->|"new Lambda(value)"| OBJ1[Instance #1]
F -->|"new Lambda(value)"| OBJ2[Instance #2]
end
Stateless 람다 — 같은 객체가 돌아온다
metafactory가 만든 CallSite의 target은 factoryType이 ()→Runnable인 경우 상수 함수가 됩니다. 즉 호출할 때마다 동일한 미리 만들어 둔 인스턴스를 반환합니다.
public class StatelessLambda {
public static void main(String[] args) {
Runnable r1 = () -> {};
Runnable r2 = () -> {};
System.out.println(r1 == r2); // 같은 람다 식이지만 다른 indy site → false
// 단, 같은 indy site에서 두 번 평가:
java.util.function.Supplier<Runnable> mk = () -> () -> {};
System.out.println(mk.get() == mk.get()); // 보통 true (HotSpot 구현 기준)
}
}
람다 식 표기가 두 곳에 있으면 indy site도 두 개이므로 별개 CallSite이고, 따라서 객체도 별개입니다. 그러나 같은 indy site가 여러 번 평가되면 HotSpot은 같은 인스턴스를 돌려줍니다. 이 동작은 JLS 15.27.4가 "Two functional interface instances of identical, non-capturing lambdas might or might not be the same instance"로 풀어 둔 약속의 한쪽 끝입니다. JVM 구현이 캐시를 선택할 수 있는 여지를 보장합니다.
Capturing 람다 — 매번 새 인스턴스
캡처가 있으면 factoryType이 (캡처들)→Runnable이 됩니다. CallSite의 target은 인자를 받아 새로운 인스턴스를 만드는 팩토리 메서드 핸들입니다.
for (int i = 0; i < 1_000_000; i++) {
int v = i;
submit(() -> work(v)); // 매번 새 객체
}
위 루프는 100만 개의 람다 객체를 만듭니다. 익명 클래스로 옮겨도 객체 수는 같지만, 람다는 Outer$1.class 같은 별도 클래스 파일이 디스크에 박혀 있지 않다는 점이 다릅니다. 클래스 로딩 비용과 메타스페이스 점유가 줄어듭니다.
final/effectively final 제약의 이유
캡처 변수는 final 또는 effectively final이어야 합니다. 이유는 캡처 시점에 값이 람다 객체의 불변 필드로 복사되기 때문입니다.
int counter = 0;
Runnable r = () -> counter++; // 컴파일 에러
만약 counter가 변경 가능하다면 람다 객체 내부의 복사본과 외부 변수가 어긋나게 됩니다. JVM은 이 어긋남을 동기화 없이 정의하기 어려우므로 언어 차원에서 금지합니다. 익명 클래스가 같은 제약을 받는 것과 동일한 이유입니다.
메서드 참조 — 더 가벼운 길
메서드 참조 System.out::println은 람다 x -> System.out.println(x)와 동등하지만 컴파일러는 본문 메서드를 따로 만들지 않습니다. implementation MethodHandle이 곧바로 PrintStream.println을 가리킵니다.
BootstrapMethods 인자:
implementation = REF_invokeVirtual java/io/PrintStream.println:(Ljava/lang/Object;)V
람다와 차이는 두 가지입니다.
- 합성 메서드
lambda$N이 만들어지지 않습니다. 클래스 파일이 그만큼 가벼워집니다. - 캡처가 있는 메서드 참조(
obj::method)도 동일한 indy site에서obj를 캡처로 받습니다.
정적 메서드 참조 vs 인스턴스 메서드 참조
| 형태 | 예시 | implementation 종류 | 캡처 |
|---|---|---|---|
| 정적 | Integer::parseInt |
REF_invokeStatic |
없음 |
| 특정 객체 | out::println |
REF_invokeVirtual |
객체 1개 |
| 임의 객체 | String::length |
REF_invokeVirtual |
없음(첫 인자가 receiver) |
| 생성자 | ArrayList::new |
REF_newInvokeSpecial |
없음 |
같은 모양처럼 보여도 캡처 유무와 호출 분류가 달라 factoryType도 달라집니다.
람다 vs 익명 클래스 — 무엇이 다른가
이제 두 방식을 정확히 비교할 수 있습니다.
| 항목 | 익명 클래스 | 람다 |
|---|---|---|
| 컴파일 산출물 | Outer$1.class 별도 파일 |
private 합성 메서드 + invokedynamic |
| 클래스 로딩 | 클래스마다 정의/검증/링크 | 첫 호출 시 1회, 이후 캐시 |
| this 의미 | 익명 클래스 인스턴스 | 람다를 둘러싼 메서드의 this |
| stateless 객체 캐싱 | 항상 새 객체 | 같은 indy site에서 캐시 가능 |
| 캡처 메커니즘 | 합성 필드 + 합성 생성자 | factory MethodHandle 인자 |
| 직렬화 | Serializable 구현하면 됨 |
altMetafactory 경유, SerializedLambda로 변환 |
| nest membership | 명시적 nest member | hidden class, 동적 nest 등록 |
| 디스크 .class | 컴파일 시 생성 | 런타임 hidden class, 디스크에 없음 |
성능에 대한 신중한 결론
람다는 "익명 클래스보다 빠르다"는 단순한 명제가 사실이 아닙니다. 다음만이 사실입니다.
- 첫 호출 비용: 람다는 부트스트랩 비용(bootstrap method 호출 + ASM 클래스 생성)이 있고, 익명 클래스는 클래스 로딩 비용이 있습니다. 둘 다 1회성이고, 워크로드에 따라 어느 쪽이 더 빠른지 다릅니다.
- 반복 호출 비용: stateless 람다는 인스턴스 재사용 덕에 익명 클래스보다 일관되게 빠릅니다. capturing 람다와 익명 클래스의 반복 호출 비용은 거의 차이가 없습니다.
- 클래스 수: 람다는 디스크 .class 파일을 만들지 않고 hidden class로 살기 때문에 클래스 로더 메타데이터/메타스페이스 점유가 적습니다.
- JIT 인라이닝: 두 방식 모두 결국
invokevirtual또는invokestatic으로 호출되므로 인라이닝 가능성에서 본질적인 차이는 없습니다.
벤치마크는 항상 자기 워크로드에서 직접 재 봐야 합니다.
Serializable Lambda — 깨지기 쉬운 약속
Serializable을 구현하는 함수형 인터페이스에 람다를 대입하면 altMetafactory가 FLAG_SERIALIZABLE로 호출됩니다. 생성된 hidden class는 writeReplace 메서드를 갖게 됩니다.
private Object writeReplace() {
return new SerializedLambda(
capturingClass,
functionalInterfaceClass, functionalInterfaceMethodName, functionalInterfaceMethodSignature,
implMethodKind, implClass, implMethodName, implMethodSignature,
instantiatedMethodType,
capturedArgs);
}
직렬화 결과는 람다 자체가 아니라 SerializedLambda 인스턴스입니다. 역직렬화는 그 안의 정보로 다시 LambdaMetafactory를 호출해 같은 모양의 람다를 재구성합니다. 이 설계가 가진 함정이 두 가지 있습니다.
- 클래스 경로에 람다 본문 메서드가 그대로 있어야 합니다. 람다는 본문 메서드 이름(
lambda\(main\)0)을 그대로 적어 두므로, 컴파일된 클래스 파일이 약간만 달라져도 역직렬화가 깨집니다. - 디버깅이 어렵습니다. 직렬화된 형태에 람다의 동작이 직접 드러나지 않습니다.
장기 보관이나 네트워크 너머 전송에 람다 직렬화를 쓰지 말라는 권고가 많은 이유입니다.
AOT/Native Image — invokedynamic의 어두운 면
invokedynamic은 런타임에 부트스트랩되는 명령어입니다. AOT(Ahead-Of-Time) 컴파일러나 GraalVM Native Image처럼 빌드 타임에 모든 코드를 분석해야 하는 환경에서는 다음 두 가지가 문제가 됩니다.
- 람다 객체의 실제 클래스가 빌드 타임에 존재하지 않습니다. ASM으로 런타임에 깎이기 때문입니다.
Serializable람다는writeReplace/readResolve경로가 동적이라 더 까다롭습니다.
GraalVM Native Image는 --initialize-at-build-time 플래그로 빌드 시점에 LambdaMetafactory를 돌려 람다 클래스를 미리 생성해 이미지에 박는 방식으로 대응합니다. Project Leyden의 AOT 컨디셔닝은 더 일반화된 해법을 모색하고 있고, JEP 483: Ahead-of-Time Class Loading & Linking이 그 흐름의 일부입니다. 이 영역은 빠르게 변하고 있어, 본인 워크로드에서 시험해 보는 것이 좋습니다.
직접 확인해 보기
생성된 클래스를 들여다보는 가장 빠른 방법은 두 가지입니다.
1. javap로 바이트코드 확인
javac LambdaDemo.java
javap -p -v LambdaDemo | grep -A 20 BootstrapMethods
BootstrapMethods 어트리뷰트에서 LambdaMetafactory.metafactory가 부트스트랩으로 등록된 것과, implementation MethodHandle이 lambda\(main\)0을 가리키는 것을 확인할 수 있습니다.
2. 런타임 hidden class를 디스크에 떨구기
java -Djdk.internal.lambda.dumpProxyClassFiles=true LambdaDemo
작업 디렉터리에 LambdaDemo$$Lambda$1.class 같은 파일이 떨어집니다. 다시 javap -p -v로 들여다보면 ASM이 짠 골격이 보입니다.
final class LambdaDemo$$Lambda$1 implements java.lang.Runnable {
public void run();
Code:
0: invokestatic LambdaDemo.lambda\(main\)0:()V
3: return
}
run() 한 줄이 곧바로 람다 본문 메서드로 점프합니다. 그 이상의 일을 하지 않습니다.
정리
람다는 익명 클래스의 단순한 신택스 슈가가 아닙니다. 컴파일러는 람다 본문을 클래스의 private 합성 메서드로 옮긴 다음, 그 메서드를 호출하는 함수 객체를 "어떻게 만들지"의 책임을 invokedynamic 한 줄에 위임합니다. 런타임에 LambdaMetafactory.metafactory가 부트스트랩되어 InnerClassLambdaMetafactory가 ASM으로 hidden class를 깎고, 이 클래스를 가리키는 ConstantCallSite가 자리에 묶입니다.
이 설계의 진짜 가치는 빠르다는 것이 아닙니다. 번역 전략을 컴파일 타임에 고정하지 않았다는 점입니다. JDK가 hidden class로 갈아탈 때 람다는 바뀐 정의를 그대로 따라왔고, 앞으로 더 효율적인 함수 객체 표현이 나와도 같은 indy site는 그대로입니다. 람다가 가진 자유는 표현식의 자유가 아니라 구현의 자유입니다.
참고자료
- Translation of Lambda Expressions — Brian Goetz (OpenJDK)
- LambdaMetafactory 소스 — openjdk/jdk master
- java.lang.invoke 패키지 개요 — Java SE 21 API Docs
- LambdaMetafactory — Java SE 21 API Docs
- JEP 371: Hidden Classes
- JEP 309: Dynamic Class-File Constants
- JEP 483: Ahead-of-Time Class Loading & Linking
- Method handles and invokedynamic — OpenJDK Wiki
- An Introduction to Invoke Dynamic in the JVM — Baeldung

