람다는 어떻게 바이트코드가 되는가
invokedynamic 명령어의 생성부터 LambdaMetafactory의 런타임 합성, 박싱 회피 함수형 인터페이스의 설계 철학까지, 자바 람다의 내부를 추적한다.
- 01 람다는 어떻게 바이트코드가 되는가
- 02 Java Stream은 왜 Terminal 전까지 아무것도 하지 않는가
- 03 parallelStream()은 왜 항상 빠르지 않은가
- 04 Optional은 왜 메서드 반환 타입으로만 써야 하는가
- 05 CompletableFuture는 왜 Future를 버렸는가
- 06 Java 인터페이스는 왜 이렇게 진화했는가
- 07 Java 날짜/시간 API는 왜 이렇게 설계됐을까
- 08 Java는 왜 Record, Sealed, Pattern을 함께 설계했을까
- 09 Virtual Thread는 왜 수백만 개가 가능한가
- 10 자바 함수형 프로그래밍의 다섯 가지 기둥
자바 8의 람다는 “익명 클래스의 편한 문법”이 아니다. 컴파일러는 람다를 전혀 다른 바이트코드 명령어인 invokedynamic으로 변환하고, JVM은 런타임에 함수형 인터페이스 구현체를 동적으로 합성한다. 익명 클래스와 메모리 사용량이 131배 차이 나는 이유도, effectively final 제약이 존재하는 이유도, 박싱 회피 변형 인터페이스가 수십 개나 존재하는 이유도 여기서 나온다. 이 모든 설계 결정의 뿌리는 무엇인가?
컴파일러가 람다를 처리하는 방식
javac는 람다식을 두 단계로 변환한다. 첫째, 람다 본체를 원본 클래스 내부의 private static 합성 메서드로 추출한다.
// 소스
Comparator<String> comp = (a, b) -> a.length() - b.length();
// javac가 생성하는 합성 메서드
private static int lambda$0(String a, String b) {
return a.length() - b.length();
}
둘째, 람다가 있던 자리에 invokedynamic 명령어를 삽입하고, 클래스의 BootstrapMethods 테이블에 LambdaMetafactory.metafactory() 참조를 기록한다. 클래스 파일은 하나다. Outer$1.class 같은 추가 파일은 생성되지 않는다.
javap -c -v -p MyClass.class로 바이트코드를 열면 이 구조를 직접 확인할 수 있다.
BootstrapMethods:
0: invokestatic LambdaMetafactory.metafactory
Method arguments:
#31 (Ljava/lang/String;Ljava/lang/String;)I // SAM 시그니처
#32 invokestatic MyClass.lambda$0 // 합성 메서드
#33 (Ljava/lang/String;Ljava/lang/String;)I // 인스턴트화 타입
invokedynamic과 LambdaMetafactory
invokedynamic이 처음 실행되는 순간 JVM은 BootstrapMethods 테이블을 찾아 LambdaMetafactory.metafactory()를 호출한다. 이 메서드는 lambda$0을 구현체로 삼는 Comparator 인스턴스를 메모리 내에 동적으로 합성하고, 그 결과를 CallSite에 링크(캐싱)한다. 두 번째 호출부터는 캐시된 CallSite에서 구현체를 바로 꺼낸다. 부트스트랩 비용은 첫 호출에만 발생한다.
여기서 중요한 최적화가 하나 나온다. 캡처 변수가 없는 람다는 CallSite 하나가 싱글톤 인스턴스를 반환한다. 루프 안에서 같은 람다를 백만 번 실행해도 인스턴스는 하나다. 반면 캡처 변수가 있으면 invokedynamic 호출마다 새 인스턴스가 생성된다.
익명 클래스 100개는 Outer$1.class부터 Outer$100.class까지 별도 파일을 생성하고, JVM 클래스로더가 각각의 메타데이터(~150KB)를 유지한다. 합계 약 15MB. 캡처 없는 람다 100개는 싱글톤 재사용으로 합계 약 115KB. 131배 차이다.
SAM과 effectively final — 두 제약의 이유
람다가 함수형 인터페이스로만 변환 가능한 이유는 invokedynamic의 구조 때문이다. LambdaMetafactory는 “단 하나의 추상 메서드”를 구현하는 클래스를 합성하도록 설계됐다. 추상 메서드가 두 개면 어느 것을 구현해야 할지 알 수 없다. @FunctionalInterface는 이 제약을 컴파일 타임에 검증해주는 어노테이션이다.
effectively final 제약은 JVM 스펙이 아니라 javac의 선택이다. 캡처 변수는 스택에 산다. 람다 인스턴스는 힙에 산다. 메서드가 반환되면 스택은 해제되지만 람다는 남는다. javac는 이 문제를 “캡처 시점의 값을 복사해 힙에 저장”으로 해결하고, 복사 이후 변경이 일어나면 어느 값을 써야 하는지 불명확해지므로 재할당 자체를 금지한다.
배열이나 AtomicReference를 이용한 “가변 캡처” 트릭은 이 제약을 우회하지만, 멀티스레드 환경에서는 가시성 문제가 남는다. volatile이 아닌 배열의 원소는 다른 스레드의 쓰기를 보장하지 않는다.
메서드 참조의 4가지 형태
메서드 참조는 람다의 특수 형태가 아니라, BootstrapMethods의 MethodHandle 코드가 달라지는 네 가지 독립적 변환이다.
| 형태 | 예시 | MethodHandle 코드 | 캡처 |
|---|---|---|---|
| 정적 메서드 | Math::abs | REF_invokeStatic | 없음 |
| 비바인딩 인스턴스 | String::length | REF_invokeVirtual | 없음 (첫 인자가 receiver) |
| 바인딩 인스턴스 | str::length | REF_invokeVirtual | str 캡처 |
| 생성자 | ArrayList::new | REF_newInvokeSpecial | 없음 |
바인딩 인스턴스 참조(str::length)는 내부적으로 str을 캡처 변수처럼 저장한다. str이 GC되지 않는다. 메서드 참조 하나가 큰 객체를 캡처하고 있으면 메모리 누수가 된다.
박싱 회피 — JDK가 수십 개의 인터페이스를 정의한 이유
Function<Integer, Integer>는 int를 Integer로 감싸고(박싱), 다시 꺼낸다(언박싱). 100만 개 원소 스트림이라면 100만 개 Integer 객체가 힙에 생겼다 사라진다. GC가 이를 정리하는 비용이 실제 연산 비용보다 크다.
IntStream과 IntFunction, ToIntFunction, IntPredicate 같은 박싱 회피 변형은 이 문제에 대한 JDK의 답이다. int 배열을 IntStream으로 처리하면 중간 Integer 객체가 전혀 생성되지 않는다.
// 박싱 발생 — Stream<Integer>
long sum = Arrays.stream(boxedArray)
.map(n -> n * 2) // Integer → Integer
.mapToLong(Integer::longValue)
.sum();
// 박싱 없음 — IntStream
long sum = IntStream.of(primitiveArray)
.map(n -> n * 2) // int → int
.asLongStream()
.sum();
JMH 벤치마크 기준으로 100만 원소에서 약 30배 차이가 난다. IntFunction<R>(int → R)과 ToIntFunction<T>(T → int)의 방향이 헷갈리면, 반환 타입이 기본형이면 To 접두사라고 기억하면 된다.
박싱 회피 변형은 int, long, double 세 타입만 지원한다. short, byte, char는 없다. 또한 인터페이스 수가 많아 코드가 장황해질 수 있다. 소규모 데이터나 프로토타입 단계에서는 일반 Function을 써도 무방하다. 박싱 회피 최적화는 대용량 스트림 처리나 타이트한 루프에서 의미 있다.
정리
javac는 람다를lambda$N합성 메서드 +invokedynamic명령어로 변환한다. 익명 클래스 파일은 생성되지 않는다.LambdaMetafactory는 첫 호출 시 함수형 인터페이스 구현체를 메모리에 합성하고CallSite에 캐싱한다. 캡처 없는 람다는 싱글톤으로 재사용된다.effectively final제약은 스택 변수를 힙에 복사하는 방식의 필연적 결과다. JVM 규칙이 아니라javac의 안전성 선택이다.- 메서드 참조 4가지는
BootstrapMethods의MethodHandle코드가 다른 독립적 변환이다. 바인딩 인스턴스 참조는 receiver를 캡처한다. IntStream과 박싱 회피 함수형 인터페이스는 성능 결정적 경로에서 GC 압력을 제거하기 위한 JDK의 의도적 설계다.
다음 글에서는 스트림 파이프라인의 source → intermediate → terminal 3단계 구조와, 게으른 평가(lazy evaluation)가 실제로 어떤 바이트코드를 만들어내는지 추적한다.