티스토리 뷰

반응형

 

JVM이 Lambda를 어떤 방식으로 다루는지 궁금해서 찾아보던 중 Oracle 블로그에서 Red Hat의 시니어 엔지니어인 Ben Evans가 쓴 글을 찾을 수 있었다. 이를 이해하며 Lambda가 JVM에서 어떻게 다뤄지는지 살펴보려 한다.

 

https://blogs.oracle.com/javamagazine/post/behind-the-scenes-how-do-lambda-expressions-really-work-in-java

 

https://blogs.oracle.com/javamagazine/post/behind-the-scenes-how-do-lambda-expressions-really-work-in-java

 

blogs.oracle.com

 

javac, javap 모두 19.0.1을 사용하였다.


Bytecode 레벨에서 살펴보기

 

람다는 단순히 익명(내부) 클래스 구현을 대신하는 눈속임일까?

 

 

먼저 람다를 활용하지 않는 경우를 보자

람다를 활용하지 않는 다음의 코드를 컴파일한 후 디컴파일해서 어떻게 바이트코드가 구성되는지 살펴보자.

public class NonLambda {
    private static final String HELLO = "Hello World!";

    public static void main(String[] args) throws Exception {
        Runnable r = new Runnable() {
            @Override
            public void run() {
                System.out.println(HELLO);
            }
        };
    
        Thread t = new Thread(r);
        t.start();
        t.join();
    }
}

 

javac로 컴파일한 결과 두 개의 클래스로 컴파일되었다.

 

각각을 디컴파일한 결과는 아래와 같다.

javap -c -p NonLambda.class

 

 

javap -c -p NonLambda.class$1.class

 

익명 클래스는 내부 클래스를 컴파일할 때와 같이 두 개의 클래스 파일로 나눠지고, 각각을 디컴파일한 결과는 당연히 각자가 수행할 내용만을 포함한다.

 

 

 

이번에는 람다를 활용하는 다음의 코드를 컴파일한 후 디컴파일해서 어떻게 바이트코드가 구성되는지 살펴보자.

 

public class Lambda {
    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();
    }
}

 

javap -c -p

 

조금은 다른 결과가 나왔다.

컴파일 시 하나의 클래스파일만이 생성되며 디컴파일 시 람다로 동작할 내용이 모두 포함됨을 볼 수 있다.

람다로 선언한 내용은 private static 메서드로 만들어졌다.

반환형은 람다가 구현한 인터페이스 메서드의 반환형을 따른다

 

익명 객체와는 다른 스타일로 처리됨을 알 수 있다.

 

 

invokedynamic

 

바이트코드는 다르다는 것을 알았다. 하지만 그것만이 궁금한 것은 아닐 것이다.

익명 클래스와 처리 과정의 차이는 어디에서 기인하는가?

 

단순히 클래스 파일이 나눠지냐 합쳐지냐만 다른 것이 아니다.

람다 사용 시의 메인 메서드의 바이트코드를 자세히 보면 invokedynamic라는 opcode로 시작하는 것을 볼 수 있다.

 

 

invokedynamic은 어떠한 팩토리 메서드를 실행하는 것으로 이해하면 된다.

어떤 메서드가 main 메서드가 실행될 때 가장 먼저 실행되고 그 결과가 지역 변수에 저장되어(astore_1) 스택에 가장 먼저 쌓인다.

 

특이한 점은 컴파일 시점에는 이 opcode가 어떤 메서드를 호출하는지는 여기에서 결정되지 않는다는 것이다.

 

이는 몇 가지의 추가 정보를 포함하여 런타임에 실행된다. 리플렉션과 유사하다.

invokedynamic 옆에 써 있는 '#7, 0'을 참고하여 Constant Pool(상수 풀)과 Bootstrap Method 정보를 살펴보자.

 

 

Constant Pool

 

먼저 #7을 Constant Pool에서 찾아보면 다음의 정보가 나타난다.

 

javap -v -p

 

상수 풀에 #7 InvokeDynamic과 관련된 정보가 존재하는 것을 볼 수 있다.

InvokeDynamic은 다시 0번과 8번을 가리키는데,

상수 풀은 1번부터 시작하므로 0번에 대한 정보는 상수 풀에 존재하지 않으며,

8번은 NameAndType, 이름과 타입 정보가 #9, #10에 포함됨을 알린다.

 

9번의 이름은 임의로 결정되는 것이며(run)

10번에는 유의미한 정보인 람다의 타입 정보인 Runnable이 적혀있다.

이 타입 정보는 런타임에 invokedynamic factory가 반환할 정보가 된다.

 

 

Bootstrap Method

 

InvokeDynamic이 가리켰던 0번에 대한 정보는 Bootstrap Method에서 찾을 수 있다.

 

 

내용이 많아보이지만 천천히 살펴보면 복잡하지 않다.

시그니처를 보면 LambdaMetafactory 클래스의 metafactory라는 정적 메서드를 호출하는 것을 알 수 있다.

 

또한 이 정적 메서드의 argument로 세 개의 정보가 넘겨지는 것을 볼 수 있다.

이들은 람다에서 사용되는 시그니처에 대한 정보이다. #59는 람다의 입력, #60은 람다 바디 정보이다.

여기에서는 Void를 의미하는 ()V가 들어가 있다.

 

 

Runnable 구현은 너무 간단한 람다이니 비교를 위해 Function을 디컴파일하여 BSM(Bootstrap Method) 예시도 한번 보자.

 

Function<Integer, Integer> f = a -> a + 1;

 

 

람다의 입력은 타입을 명시하지 않았으므로 Object, 출력은 Integer이며 바디에서 사용되는 Integer를 포함한다.

 

 

 

bootstrap method에서 호출하는 LambdaMetaFactory의 metafactory로 넘어가 보자.

 

    public static CallSite metafactory(MethodHandles.Lookup caller,
                                       String interfaceMethodName,
                                       MethodType factoryType,
                                       MethodType interfaceMethodType,
                                       MethodHandle implementation,
                                       MethodType dynamicMethodType)
            throws LambdaConversionException {
        AbstractValidatingLambdaMetafactory mf;
        mf = new InnerClassLambdaMetafactory(Objects.requireNonNull(caller),
                                             Objects.requireNonNull(factoryType),
                                             Objects.requireNonNull(interfaceMethodName),
                                             Objects.requireNonNull(interfaceMethodType),
                                             Objects.requireNonNull(implementation),
                                             Objects.requireNonNull(dynamicMethodType),
                                             false,
                                             EMPTY_CLASS_ARRAY,
                                             EMPTY_MT_ARRAY);
        mf.validateMetafactoryArgs();
        return mf.buildCallSite();
    }

 

자바 버전에 따라 파라미터의 이름이 조금 다를 수 있다.

MethodType 클래스는 리턴 타입과 파라미터 타입 배열을 가지고 있다.

 

파라미터 중 위의 세 정보는 JVM에 의해 입력되고 마지막 세 개의 정보들은...

interfaceMethodType은 구현해야 할 메서드의 리턴 타입과 파라미터들의 타입을, (runnable은 리턴이 void)

MethodHandle은 실제 람다 내에서 실행하고자 하는 내용이 들어간다(System.out.pritnln(HELLO))

dynamicMethodtype에는 interfaceMethodType에서 제네릭 메서드를 사용했다면 제외했을 실제 타입을 넣는다.

 

이 메서드를 통하여 CallSite 객체를 얻어낼 수 있다.

 

 

CallSite

 

CallSite는 MethodHandle을 들고 있는 Holder 클래스이다.

javadoc을 보면 MethodHandle은 실행 가능한 람다 인스턴스의 참조이다.

 

/** MethodHandle
 * A method handle is a typed, directly executable reference to an underlying method,
 * constructor, field, or similar low-level operation, with optional
 * transformations of arguments or return values.
 * ...
 **/

 

 

 

dynamicInvoker()를 통해 MethodHandle을 얻어낼 수 있다.

인스턴스를 알고 있으므로 JVM은 람다를 실체화하여 실행할 수 있다.

 

 

매 람다 호출마다 전 과정이 반복되는 것은 아니며, 추상 클래스인 CallSite의 구현 중 ConstantCallSite의 형태로 람다 인스턴스를 저장하고 캐싱하여 사용한다.

 

 Some invokedynamic call sites are effectively just lazily computed, and the method they target will never change after they have been executed the first time. This is a very common use case for ConstantCallSite, and this includes lambda expressions.

 

 


 

왜 이렇게 복잡한 과정을 거쳐야만 했을까?

 

이러한 런타임에 동작하는 리플렉션과 같은 로직은 유용하나 JIT 컴파일러에 의해 최적화되기 어렵다는 점이 치명적이다.

런타임에 매번 Method.invoke()와 같은 로직을 동적으로 수행해야 하기 때문이다.

 

invokedynamic을 지원함으로써 람다에 대한 인스턴스화를 런타임에 1회 진행, 상수화하고 캐싱하여 JIT 컴파일러가 최적화할 수 있는 형태로 만들게 되었다.

 

 

 

참고 자료

https://blogs.oracle.com/javamagazine/post/behind-the-scenes-how-do-lambda-expressions-really-work-in-java

https://www.oracle.com/a/ocom/docs/corporate/java-magazine-nov-dec-2017.pdf#page=67

 

 

반응형