Java Lambda & invokedynamic 심층 분석
1. 들어가며
Java 8부터 도입된 람다 표현식(Lambda Expression)은 코드를 간결하게 만들고 함수형 프로그래밍 스타일을 지원하기 위한 핵심 기능이다. 단순한 문법적 설탕(syntactic sugar)처럼 생각할 수 있지만, 실제 내부 동작은 전혀 다르다. 특히 invokedynamic 명령과 LambdaMetafactory의 역할을 알면 람다의 진짜 정체와 동작 메커니즘을 깊이 이해할 수 있다.
(이 글에서는 초보자도 따라오기 쉽도록 쉽게 글을 써봤습니다.혹시 읽다가 틀린내용이 있으면 알려주면 감사하겠습니다.)
3. 람다와 익명 클래스의 차이점
Runnable r1 = () -> System.out.println("Lambda"); // invokedynamic 사용
Runnable r2 = new Runnable() { // 익명 클래스 사용
public void run() {
System.out.println("Anonymous");
}
};
익명 클래스를 생성하면 위와같이 컴파일러가 자동으로 익명 클래스를 만들어 준다.
이제 익명 클래스 대신 람다를 사용해보자.
public class LambdaCheck {
public static void main(String[] args) {
Runnable r = () -> System.out.println("Hello Lambda!");
r.run();
}
}
람다 클래스를 생성하면, 클래스 파일이 하나만 만들어진다.
결론부터 말하자면, 익명 클래스는 컴파일 타임에 클래스로 명확히 생성되지만, 람다는 invokedynamic을 통해 런타임에 동적으로 생성되며, 바이트코드에서도 그 차이를 확인할 수 있다.
그렇다면 invokedynamic이란 무엇일까?
4. invokedynamic 내부 동작 흐름
invokedynamic 의 흐름은 위와 같다.
- invokedynamic 명령이 실행되면, JVM은 아직 호출할 메서드를 모르기 때문에 연결을 위해 Bootstrap Method를 호출한다.
- Bootstrap Method에서는 람다의 본문에 해당하는 메서드를 찾아 MethodHandle로 감싼다. 그리고 이 핸들을 CallSite에 저장한다.
- CallSite & MethodHandle은 메서드 정보를 담고 있는 저장소(캐시)이다. 여기서 연결된 메서드를 꺼내 실제로 실행한다.
- MethodHandle은 실제로 실행할 메서드의 참조(포인터)이다. r.run()과 같은 호출이 발생하면, JVM은 CallSite에서 MethodHandle을 꺼내 해당 메서드를 실행한다.
- MethodHandle은 실제로 실행할 메서드의 참조(포인터)이다. r.run()과 같은 호출이 발생하면, JVM은 CallSite에서 MethodHandle을 꺼내 해당 메서드를 실행한다.
- Execution 단계에서는 실제로 람다 함수가 실행된다. (System.out.println("Hello") 같은 코드)
처음 한 번만 부트스트랩(Bootstrap) 메서드를 호출하고, 그 이후엔 캐시된 CallSite 덕분에 람다를 바로 실행하므로 빠르다.
더 자세히보기위해,예시로 위 코드(좌측)를 바이트코드(우측)로 확인해보자.
위에서 작성한 람다 코드를 컴파일한 뒤, 바이트코드를 열어보면 INVOKEDYNAMIC이라는 명령어가 등장한다. 그리고 lambda$main$0()이라는 정적 메서드도 자동으로 생성된 것을 볼 수 있다.
.lambda$main$0() 메서드는 우리가 코드로 직접 작성하지는 않았지만, 컴파일러가 람다의 본문을 따로 분리해 자동으로 생성한 정적 메서드이다. 이 메서드는 실행 시점에 JVM에 의해 MethodHandle로 참조되며, 이 참조는 CallSite에 저장된다.
이후 invokedynamic 명령이 실행되면, JVM은 해당 CallSite에서 연결된 MethodHandle을 꺼내 실제 메서드를 실행한다.
예시 코드
Runnable r = () -> System.out.println("Hello Lambda!");
r.run();
컴파일 시 생성되는 바이트코드 주요 부분
// access flags 0x9
public static main([Ljava/lang/String;)V // 메인 메서드 시작
L0
LINENUMBER 5 L0
INVOKEDYNAMIC run()Ljava/lang/Runnable; [
// 🔽 아래는 invokedynamic 명령을 실행할 때 사용할 부트스트랩 정보
// handle kind 0x6 : INVOKESTATIC
// LambdaMetafactory.metafactory(...)를 호출해서 어떤 메서드를 실행할지 "런타임에" 결정
java/lang/invoke/LambdaMetafactory.metafactory(
Ljava/lang/invoke/MethodHandles$Lookup; // 호출자 정보 (Lookup 객체)
Ljava/lang/String; // 호출될 메서드 이름 (run)
Ljava/lang/invoke/MethodType; // 함수형 인터페이스 타입 정보 (Runnable)
Ljava/lang/invoke/MethodType; // 람다가 구현해야 할 메서드 시그니처 (()V)
Ljava/lang/invoke/MethodHandle; // 실제 실행할 메서드 (lambda$main$0)
Ljava/lang/invoke/MethodType; // 인스턴스화된 메서드 타입 (()V)
)Ljava/lang/invoke/CallSite; // 메서드 핸들을 담은 CallSite를 반환
// arguments:
()V, // 함수형 인터페이스 Runnable의 run() 메서드 시그니처
// 이 람다식이 호출할 실제 구현 메서드
lambda/ex3/LambdaCheck.lambda$main$0()V, // → 람다 본문 코드
()V
]
이제 핵심 동작을 한줄씩 뜯어보도록 하자.
INVOKEDYNAMIC run()Ljava/lang/Runnable; [
이 줄은 JVM에게 "람다식이 여기 있다"라고 알려주는 명령이다. 하지만 이 시점에서는 어떤 메서드를 실제로 실행해야 할지 JVM도 아직 모른다.
그래서 JVM은 먼저 슬로우 패스(slow path)를 따라간다. 즉, 초기 연결이 안 되어 있으니, 대신 Bootstrap Method라고 불리는 특수 메서드를 호출해서 "이 람다가 어떤 메서드를 실행해야 하는지"를 런타임에 연결한다.
이때 호출되는 메서드가 바로 아래와같다.:
java/lang/invoke/LambdaMetafactory.metafactory(..)
..
lambda/ex3/LambdaCheck.lambda$main$0()V, // → 람다 본문 코드
..
이 metafactory() 메서드는 내부적으로 작성한 () -> System.out.println("Hello Lambda!") 같은 람다식의 본문을 컴파일러가 정적 메서드로 따로 만들어 둔다. → 이게 lambda$main$0()이다.
lambda$main$0()을 MethodHandle이라는 리모컨 객체로 감싼다. 그리고 이 MethodHandle을 CallSite라는 저장소(캐시)에 담아서 JVM에게 넘긴다.
정리하면, invokedynamic가 처음엔 "어떤 메서드를 실행해야 할지" 모르기 때문에, LambdaMetafactory.metafactory()가 대신 찾아서 lambda$main$0()을 MethodHandle로 만들어 CallSite에 저장하고, 나중에 r.run() 할 때 그걸 실행하게 만드는 구조이다.
💡 자바 람다의 컴파일 & 실행 정리
이제 다시 코드를 보며 이해한 내용을 확인해보자.
Runnable r = () -> System.out.println("Hello Lambda!");
r.run();
컴파일 시
- `Runnable r = ...` → `invokedynamic` 명령어로 대체
- 람다 본문은 `lambda$main$0()` 정적 메서드로 분리 생성됨
- 아직 어떤 메서드가 실행될지는 정해지지 않음
컴파일시 System.out.println("Hello Lambda!") 부분은 lambda$main$0()이라는 정적 메서드로 따로 만들어진다.
변수 r에는 바로 메서드가 담기는 게 아니라, invokedynamic 명령어가 들어간다.
이 람다 () -> System.out.println(...)는 어떤 클래스도 없고, 메서드 이름도 없고, 우리가 만든 객체도 없다.하지만 실행은되야하기에, 컴파일러가 이 람다의 실제 동작 코드(본문)를 lambda$main$0()이라는 정적 메서드로 미리 만들어둔다.
즉 Runnable r = ... 이 부분에 있는 람다식은 메서드가 곧장 들어가는 게 아니고, JVM은 이걸 "나중에 실행할게." 하고 일단 invokedynamic이라는 명령어만 넣어둔다.
런타임 첫 실행 시 (Runtime)
- `r.run()` 호출 → JVM이 `invokedynamic` 명령을 처음 실행
- `LambdaMetafactory.metafactory()` (부트스트랩 메서드) 호출
- 이 안에서 `lambda$main$0()` 메서드를 가리키는 `MethodHandle` 생성
- 이를 `CallSite`에 저장 (이후 재사용을 위한 캐시)
프로그램이 실행되어 r.run()이 처음 호출되면,JVM은 invokedynamic 명령을 만나고 어떤 메서드를 실행할지 결정하기 위해
LambdaMetafactory.metafactory()라는 부트스트랩 메서드를 호출한다.이 과정에서 컴파일러가 미리 만들어둔 lambda$main$0() 정적 메서드를 JVM이 MethodHandle이라는 방식으로 가리키고, 그 정보를 CallSite라는 저장소에 저장한다.
이후에는 r.run()이 실행될 때마다 이 CallSite에서 메서드 정보를 꺼내 lambda$main$0()을 실행한다.
결과적으로 "Hello Lambda!"가 출력된다.
즉 이는 r.run()을 처음 실행할 때, JVM이 r과 실제 실행할 정적 메서드(lambda$main$0)를 런타임에 연결해주는 과정이다.
이후 재실행 (Fast Path)
- `r.run()` 실행 시, `CallSite`에서 캐시된 `MethodHandle`을 꺼내 바로 실행
- 즉, 부트스트랩은 한 번만 호출되고, 이후엔 빠르게 처리됨
단계를 정리하면 아래와같다.
컴파일 시 | 람다 본문 → 정적 메서드(lambda$main$0)로 분리 + invokedynamic 삽입 |
실행 첫 순간 | LambdaMetafactory 통해 메서드 연결 (MethodHandle 생성 & 캐싱) |
그 이후 실행 | 캐시된 CallSite에서 메서드를 빠르게 호출 |
람다는 런타임에 필요한 시점에 동적으로 "람다 객체"를 생성한다.
JVM 관점 동작 순서
- 자바 소스 → 컴파일 → invokedynamic 명령어가 생김
(아직 메서드는 직접 연결되지 않음) - JVM이 이 invokedynamic을 처음 실행할 때, 부트스트랩 메서드(LambdaMetafactory.metafactory)를 호출함
- 이 과정에서 실제 실행할 MethodHandle을 결정함
- CallSite에 저장 (이제 메서드 연결됨! ✅)
- 그 이후부터는 해당 CallSite를 직접 참조해서 빠르게 실행
8. 마치며
람다는 단순한 문법 축약이 아니다. JVM은 invokedynamic, LambdaMetafactory, MethodHandle, CallSite를 통해 람다를 런타임에 동적으로 생성하고 실행한다. 이 구조 덕분에 익명 클래스 없이도, 필요할 때만 객체를 생성하며, 성능까지 최적화할 수 있는 유연한 실행 방식이 가능해진다.
'Java' 카테고리의 다른 글
[Java] 김영한의 실전 자바 - 고급 3편 섹션6 메서드 참조 (0) | 2025.04.12 |
---|---|
[Java] 김영한의 실전 자바 - 고급 3편 섹션4,5 람다활용,익명클래스와의 차이 (+정적 팩토리 메서드) (0) | 2025.04.07 |
[Java] 김영한의 실전 자바 - 고급 3편 섹션3 함수형 인터페이스 (0) | 2025.04.03 |
[Java] 김영한의 실전 자바 - 고급 3편 섹션1,2 람다 (2) | 2025.03.31 |
[Java] 김영한의 실전 자바 - 고급 2편 섹션15 HTTP 서버 활용 (0) | 2024.12.02 |