https://www.baeldung.com/java-lambdas-vs-anonymous-class
자바를 사용하여 프로젝트를 진행해본 경험이 있는 사람들이라면 아래와 같은 코드를 작성하게 되는 경우가 있다.
public class AnonymousClassExample{
public static void main(String[] args){
Thread t1 = new Thread(new Runnable(){
@Override
public void run() {
System.out.println("Thread: "+Thread.currentThread().getName()+" started");
}
});
t1.start();
}
}
이런 방식으로 클래스를 정의함과 동시에 인스턴스화 시키는 기법을 '내부 익명 클래스' (anonymous inner class) 라고 한다.
여기서 더 나아가 Intellij 와 같은 ide를 사용하는 경우 내부 익명 클래스를 작성해 본 사람들은 한번쯤 can be replaced with lambda expression
suggestion이 익명 클래스를 편리하게 람다 표현식으로 바꿔준 경험이 있을 것이다.
ide 단에서 최적화 해준 코드고, 동일하게 동작하는 것처럼 보이기 때문에 단지 문법 상에만 차이가 있다고 생각하게 된다.
하지만 바이트 코드로 컴파일된 자바 람다 표현식은 내부 익명 클래스를 가지지 않는다. 즉 내부 익명 클래스와는 완전히 다른 방식으로 동작하지 않는다.
자바의 람다 표현식은 JVM 내부에서 어떻게 실행할까?
public class LambdaExample {
private static final String HELLO = "Hello World!";
public static void main(String[] args) throws Exception {
Runnable r = () -> System.out.println(HELLO);
Thread t = new Thread(r);
t.start();
t.join();
}
}
상기 코드는 간단히 hello world를 출력하는 runnable을 쓰레드로 돌리는 예시이다.
즉 컴파일된 결과는 단 하나의 클래스로, 내부 익명 클래스를 전혀 생성하지 않았다.
위 자바 파일을 컴파일 한 후, javap -c -p
옵션을 통해 바이트 코드를 다시 디컴파일하면 다음과 같은 결과를 얻을 수 있다.
Compiled from "LambdaExample.java"
public class LambdaExample {
private static final java.lang.String HELLO;
public LambdaExample();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public static void main(java.lang.String[]) throws java.lang.Exception;
Code:
0: invokedynamic #7, 0 // InvokeDynamic #0:run:()Ljava/lang/Runnable;
5: astore_1
6: new #11 // class java/lang/Thread
9: dup
10: aload_1
11: invokespecial #13 // Method java/lang/Thread."<init>":(Ljava/lang/Runnable;)V
14: astore_2
15: aload_2
16: invokevirtual #16 // Method java/lang/Thread.start:()V
19: aload_2
20: invokevirtual #19 // Method java/lang/Thread.join:()V
23: return
private static void lambda$main$0();
Code:
0: getstatic #22 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #30 // String Hello World!
5: invokevirtual #32 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
}
(로컬 Mac에서 Java 17 Temurin 버전으로 디컴파일 하였으며, 결과는 자바 버전마다 조금 상이할 수 있다)
java --version
openjdk 17.0.8 2023-07-18
OpenJDK Runtime Environment Temurin-17.0.8+7 (build 17.0.8+7)
OpenJDK 64-Bit Server VM Temurin-17.0.8+7 (build 17.0.8+7, mixed mode)
주목해야 할 것은 main
메서드가 invokedynamic
호출로 시작한다는 것이다.
invokedynamic
는 자바 7버전부터 추가된 opcode이다.invokedynamic
은 Runnable
을 implement하는 어떤 타입의 인스턴스를 리턴한다.invokedynamic
의 실제 타입은 존재하지 않고, 런타임에 필요한 시점이 되었을 때 생성된다.메서드 호출 명령이 발생하는 바이트 코드 상의 위치를 Call site라고 부른다.
자바 바이트 코드는 원래 4가지의 opcode들을 가지고 있었다.
1. 정적 메서드 호출을 위한 opcode
2. 평범한 메서드 호출을 위한 opcode (메서드 오버라이딩을 포함한 virtual call도 포함)
3. 인터페이스 lookup을 위한 opcode
4. '특별한' 메서드 호출을 위한 opcode (상위 클래스 메서드 호출과 private 메서드 호출처럼 오버라이딩을 필요로 하지 않는 경우)
invokedynamic
의 call sites는 자바 힙 메모리의 CallSite
객체로 표현된다. (스택에 존재하지 않는다!)
자바의 아주 초창기 버전의 리플렉션 API가 존재했을 때부터 이와 비슷한 일을 해오고 있었다. 다르게 말하면 자바는 런타임에 이뤄지는 동적 행위들을 많이 가지고 있다.
invokedynamic
명령에 도달하면 jvm은 상응하는 call site 객체에 대해
이때 call site 객체는 실제로 호출할 메서드를 대표하는 객체인 method handle을 가지고 있다.
Call Site 내부 구현은 크게 3가지 하위 클래스로 이루어진다.
ConstantCallSite
MutableCallSite
VolatileCallSite
CallSite
클래스는 패키지 내부 생성자만 가지고 있지만 3개의 하위 클래스들은 public한 생성자를 가지고 있다.
즉 CallSite
자체는 사용자 코드로 직접 하위 클래스를 만들 수 없지만 앞서 언급한 3가지 하위 클래스를 상속하여 다루는 것이 가능하다.
어떤 invokedynamic
call site들은 lazy하게 결정되어 정해진 메서드 대상이 한번 실행된 이후에는 변하지 않는다는 것을 유의해야 한다. ConstantCallSite
가 사용되는 많은 경우에 그러하며, 이는 람다 표현식을 포함한다.
리플렉션은 런타임에 묘기를 부리기 좋은 기술이지만, 디자인적인 결함이 분명히 존재한다.
리플렉션의 큰 문제 중 하나는 성능이다. reflective한 호출은 JIT 컴파일러가 인라이닝 하기 까다롭다는데 그 이유가 있다. (예측이 거의 불가능하기 때문이리라)
인라이닝은 JIT 컴파일 과정에서 가장 첫 번째로 일어나는 중요한 최적화 과정이기 때문에 인라이닝이 일어나지 않는다면 DCE와 같은 다른 기법을 사용하도록 유도된다.
두 번째 문제는 Method.invoke()
의 call site가 실행될 때마다 reflective한 호출이 링크된다는 점이다.
예를 들어 보안을 위해 AccessControl 등으로 접근 제어를 실행하는 코드가 있다고 가정해보자.
Java 7에서는 이러한 문제를 해결하기 위해 새로운 api인 java.lang.invoke
를 도입하였다.
java.lang.invoke
는 도입한 메인 클래스의 이름 때문에 종종 메서드 핸들이라고 불리기도 한다.
메서드 핸들은 자바 버전의 타입 세이프한 함수 포인터이다.
메서드 핸들은 코드가 호출하고 싶은 메서드를 레퍼런스함과 동시에 invoke()
메서드를 가지고 있어 연결된 메서드를 실행할 수 있다. (이 부분은 리플렉션과 동일하다)
하지만 메서드 핸들은 기계에 조금 더 가까운, 효과적인 리플렉션 방식이다.
이를 위해 리플렉션 API를 통해 표현된 모든 객체는 동등한 메서드 핸들로 변환될 수 있게 만들어졌다.
리플렉션으로부터 생성된 메서드 핸들은 그 기저의 메서드를 접근하기 위한 더 효과적인 방법을 제공한다.
MethodHandles
클래스의 헬퍼 메서드를 통해 메서드 합성이나 메서드 인수의 부분 바인딩 (커링)과 같은 다양한 방법으로 조정될 수 있다.invoke()
메서드는 특별해서 호출되는 메서드의 시그니쳐와 상관 없이 링킹을 진행할 수 있게 해준다.결국 메서드 핸들은 (리플렉션을 사용한 호출처럼) 타입 변환이나 autoboxing을 수행하는 것을 피하기 때문에 런타임에 invoke()
call site의 시그니쳐는 레퍼런스된 메서드를 마치 직접 호출하는 것처럼 보인다.
자바는 정적 타입 언어이기 때문에, (레퍼런스된 메서드를 직접 호출하는 것처럼 보이는 파격적인) 이런 동적 메커니즘이 얼마나 타입 세이프할 수 있을까? 에 대한 궁금증을 낳는 건 어쩔 수 없나보다.
메서드 핸들 API는 메서드의 시그니쳐에 대한 immutable한 표현인 MethodType
이라는 타입 개념을 도입했다.
https://docs.oracle.com/javase/8/docs/api/java/lang/invoke/MethodType.html
MethodType
의 구조체는 매개변수 타입과 리턴 타입을 포함한다. Class
객체로 표현된다.MethodType
의 모든 인스턴스는 immutable하기 때문에 두 인스턴스가 동일하다면 interchangeable하다.메서드 핸들의 내부 구현은 자바 8을 거치면서 lambda form이라고 부르는 새로운 개념을 도입하였다.
람다 표현식의 성능 비교 영상
해당 영상에서 사용한 테스트 파일
때문에 자바 8 이후부터는 대부분의 경우에서 리플렉션을 사용하는 것보다 메서드 핸들을 사용하는 것이 훨씬 드라마틱한 성능 향상을 제공한다.
(성능 비교 영상은 꽤 길고 지루하며, 영어 발음이 좀 알아듣기 힘들어서 별도로 정리하진 못했다 🥲)