이 게시글은 자바 8을 기준으로 작성되었습니다.

Invokedynamic

아래의 코드를 떠올려봅시다. 람다의 타입은 무엇일까요? 람다는 int, double 같은 기본 타입이 아니므로 참조 타입, 즉 객체의 참조여야 합니다. 다시 말해서, Runnable을 구현하는 클래스의 인스턴스에 대한 참조여야 합니다.

package com.company;

public class InvokeDynamicExample {
    public static void main(String [] args) {
        Runnable r = () -> System.out.println("Hello");
        r.run();
    }
}

이를 javap로 뜯어보면 아래와 같은 바이트코드를 살펴볼 수 있습니다. 여기서는 명령 5~6에서 스택에 푸시된 람다 인스턴스의 참조를 지역 변수 1(즉,r)에 저장하는 것을 볼 수 있습니다. 이어서 명령 7에서 r.run()이 호출되는 것을 볼 수 있습니다. 관심사는 invokeinterface가 아니므로 invokedynamic 위주로 집중적으로 살펴봅시다.

public static void main(java.lang.String[]);
	// InvokeDynamic #0:run:()Ljava/lang/Runnable;
	0: invokedynamic #2
	5: astore_1
	6: aload_1
	// // InterfaceMethod java/lang/Runnable.run:()V
	7: invokeinterface #3, 1
	12: return

JLS에서 확인할 수 있듯이 #20의 #0은 부트스트랩 메서드 테이블에 있는 부트스트랩 메서드(bootstrap_methods) 배열의 인덱스입니다. #30에는 메서드명과 메서드 디스크립터가 옵니다. 참고로 메서드 디스크립터는 매개변수 타입과 메서드의 반환 타입을 하나의 문자열로 나타낸 것입니다. 주석을 보면 쉽게 확인할 수 있습니다.

이어서 내려보면 아래와 같은 내용을 확인할 수 있습니다. 이 부트스트랩 메서드는 동적으로 대상 메서드(target method)를 연결하는 invokedynamic의 핵심 부분입니다. 여기서 java.lang.invoke.LambdaMetafactory#metaFactory()가 어떤 메서드인지 살펴보기 전에 MethodHandle를 먼저 살펴봅시다.

InnerClasses:
  // Lookup=class java/lang/invoke/MethodHandles$Lookup of class java/lang/invoke/MethodHandles
  public static final #57= #56 of #60;
BootstrapMethods:
  // java.lang.invoke.LambdaMetafactory.metafactory()가 바로 부트스트랩 메서드이다.
  0: #27 REF_invokeStatic java/lang/invoke/LambdaMetafactory.metafactory:
  (Ljava/lang/invoke/MethodHandles$Lookup // caller
     ;Ljava/lang/String // invokedName
     ;Ljava/lang/invoke/MethodType // invokedType
     ;Ljava/lang/invoke/MethodType // samMethodType
     ;Ljava/lang/invoke/MethodHandle // implMethod
     ;Ljava/lang/invoke/MethodType // instantiatedMethodType
  ;)Ljava/lang/invoke/CallSite; // 반환 타입이 CallSite
    // 각각 samMethodType, implMethod, instantiatedMethodType으로 넘어간다.
    Method arguments:
      #28 ()V
      #29 REF_invokeStatic com/company/InvokeDynamicExample.lambda$main$0:()V
      #28 ()V

MethodHandle

자바독을 살펴보면 "메서드 핸들은 내재된 메서드, 생성자, 필드 혹은 이와 유사한 저수준의 연산에 대한 타입화된(typed) 참조이다."라고 나와 있습니다. 그리고 "이를 직접 실행할 수도 있다"는 내용도 찾아볼 수 있습니다. 다시 말해서 메서드, 생성자, 필드 등을 가리킬 수 있으며 직접 실행할 수도 있는 참조를 MethodHandle 이라는 타입으로 만든 것입니다. 그러면 이 메서드 핸들을 어떻게 가져올 수 있을까요? 자바 매거진에서 다음과 같은 내용을 확인할 수 있었습니다.

... 메서드 핸들을 가져오려면 룩업 컨텍스트(lookup context)를 통해서 조회해야 합니다. 컨텍스트를 가져오는 일반적인 방법은 정적 헬퍼 메서드인 MethodHandles.lookup()을 호출하는 것입니다. 이 메서드는 현재 실행 중인 메서드를 기반으로 룩업 컨텍스트를 반환합니다. 이 컨텍스트에서 find*() 메서드 중 하나를 호출해서 메서드 핸들을 얻을 수 있습니다(예: findVirtual(), findConstructor()). ...

그러면 메서드 핸들을 이용해서 정적 메서드인 String.format(String, Object...)를 호출해보도록 하겠습니다. 우선 룩업으로 메서드를 조회하고, String.format()의 디스크립터, 즉 반환 타입과 매개변수 타입을 순서대로 작성합니다. 이러한 타입 정보는 다음 행에서 String 클래스 내의 format() 메서드를 찾을 때 사용됩니다. 그 후에는 invokeExact()로 넘겨준 타입과 클래스, 메서드명과 정확히 일치하는 정적 메서드를 호출하여 결과를 돌려주게 됩니다.

public class InvokeDynamicExample {
    public static void main(String [] args) throws Throwable {
        MethodHandles.Lookup lookup = MethodHandles.lookup();
        // String.format(String format, Object... args)
        MethodType type = MethodType.methodType(String.class, String.class, Object[].class);
        MethodHandle mh = lookup.findStatic(String.class, "format", type);

		// String.format("Hello, %s!", "World");
        String s = (String) mh.invokeExact("Hello, %s!", new Object[]{"World"});
        System.out.println(s); // Hello, World!
    }
}

CallSite

이번에는 이어서 CallSite를 살펴봅시다. 보다시피 CallSite는 MethodHandle을 담아두는 홀더 역할을 합니다. 이 메서드 핸들을 매개변수로 받는 생성자도 보입니다. 참고로, 여기서 타겟은 우리가 실행하길 원하는 대상 메서드(target method)를 말하는 것이며, 아직은 무엇인지 알 수 없으나 우리가 필요한 람다의 인스턴스를 반환하는 메서드가 될 것입니다.

package java.lang.invoke;

abstract public class CallSite {
    static { MethodHandleImpl.initStatics(); }

	// 주의: JVM이 이 필드를 알고 있습니다. 변경하지 마세요.
    MethodHandle target;

	...

    CallSite(MethodHandle target) {
        target.type();  // 널 체크
        this.target = target;
    }

	public abstract MethodHandle getTarget();
	public abstract void setTarget(MethodHandle newTarget);
	...
}

이어서 CallSite의 자바독을 살펴보면 다음과 같은 내용을 알 수 있습니다. 여기서 CallSite를 호출하면 MethodHandle을 통해서 이를 우리가 원하는 타겟 메서드에 위임합니다. 여기서 상수 콜사이트는 CallSite의 자식 클래스인 ConstantCallSite를 말하고, 이 클래스는 콜사이트에 연결된 타겟이 변경될 수 없고 영구적일 때 사용됩니다.

CallSite에 연결된 invokedynamic 명령은 모든 호출을 사이트의 현재 타겟에 위임합니다. CallSite는 여러 개의 invokedynamic 명령과 연결될 수 있으며, 아무 명령과도 연결되지 않을 수도 있습니다. 어떤 경우건, "동적 호출기(dynamic invoker)"라고 불리는 연결된 메서드 핸들을 통해 호출될 수 있습니다. ... 가변 타겟이 필요하지 않은 경우(타겟 메서드가 변하지 않는 경우), invokedynamic 명령은 상수 콜사이트(constant call site)를 통해 영구적으로 바인딩될 수 있습니다. ...

이어서 CallSite 추상 클래스 내부에 아래와 같이 콜사이트를 만드는 정적 메서드를 찾아볼 수 있습니다. 우리가 MethodHandle 예시에서 봤던 것처럼 호출자 정보를 가지고 룩업을 수행하며, 그 후 MethodHandle.invoke()에 룩업 컨텍스트와 메서드 이름, 메서드 디스크립터를 넘기고 이를 호출하는 것을 볼 수 있습니다.

static CallSite makeSite(MethodHandle bootstrapMethod,  
						 // 피호출자 정보:  
						 String name, MethodType type,  
						 // (있는 경우) BSM을 위한 추가 인수  
						 Object info,  
						 // 호출자 정보:  
						 Class<?> callerClass) {  
	MethodHandles.Lookup caller = IMPL_LOOKUP.in(callerClass);
	CallSite site;  
	try {
		...
		// 네이티브 메서드인 MethodHandle.invoke()는 invokeExact()와 거의 유사하게 동작한다. 다만 invokeExact()처럼 타입 검사가 엄격하지는 않다. invoke()는 타입이 정확하게 일치하지 않으면 필요한 타입으로 변환을 수행한다.
		if (info == null) {  
			binding = bootstrapMethod.invoke(caller, name, type);  
		} else if (!info.getClass().isArray()) {  
			binding = bootstrapMethod.invoke(caller, name, type, info);  
		} else {
			Object[] argv = (Object[]) info;
			...
			switch (argv.length) {
			// 부트스트랩 메서드를 호출한다.
			case 0:  
				binding = bootstrapMethod.invoke(caller, name, type);  
				break;  
			case 1:  
				binding = bootstrapMethod.invoke(caller, name, type,  
												 argv[0]);  
				break;
			...
		}
		if (binding instanceof CallSite) {  
		    site = (CallSite) binding;  
		}  else {  
		    throw new ClassCastException("bootstrap method failed to produce a CallSite");  
		}
		...
	} catch (Throwable ex) { ... }
	return site;
}

여기서 bootstrapMethod는 부트스트랩 메서드에 대한 핸들입니다. 여기서 JVM은 LambdaMetafactory.metafactory(...)라고 하는 정적 부트스트랩 메서드를 호출합니다.

BootstrapMethods:
  0: #27 REF_invokeStatic java/lang/invoke/LambdaMetafactory.metafactory:
  (Ljava/lang/invoke/MethodHandles$Lookup // caller
     ;Ljava/lang/String // invokedName
     ;Ljava/lang/invoke/MethodType // invokedType
     ;Ljava/lang/invoke/MethodType // samMethodType
     ;Ljava/lang/invoke/MethodHandle // implMethod
     ;Ljava/lang/invoke/MethodType // instantiatedMethodType
  ;)Ljava/lang/invoke/CallSite; // 반환 타입이 CallSite
    Method arguments:
      #28 ()V
      #29 REF_invokeStatic com/company/InvokeDynamicExample.lambda$main$0:()V
      #28 ()V

LambdaMetafactory

아래는 java.lang.invoke.LambdaMetafactory 클래스에 있는 부트스트랩 메서드입니다. 여기서 마지막에 mf.buildCallSite()가 호출되면 내부에서 함수형 인터페이스를 구현하는 클래스가 동적으로 만들어지며, 최종적으로 타겟 메서드의 핸들이 들어간 CallSite를 반환합니다. 이렇게 반환된 CallSite(구체적으로는 ConstantCallSite)는 invokedynamic 명령과 연결되어 사용됩니다.

// 이 예시에선 invokedName은 run이고, invokedType은 MethodType(Runnable.class)이다.
// 즉, invokedName은 함수형 인터페이스의 메서드 이름이고, invokedType은 함수형 인터페이스 타입이다.
public static CallSite metafactory(MethodHandles.Lookup caller,
								   String invokedName,
								   MethodType invokedType,
								   MethodType samMethodType,
								   MethodHandle implMethod,
								   MethodType instantiatedMethodType)
		throws LambdaConversionException {
	AbstractValidatingLambdaMetafactory mf;
	mf = new InnerClassLambdaMetafactory(caller, invokedType,
		 invokedName, samMethodType,
		 implMethod, instantiatedMethodType,
		 false, EMPTY_CLASS_ARRAY, EMPTY_MT_ARRAY);
	// metafactory 인수에 오류가 있는지 검증한다.
	mf.validateMetafactoryArgs();
	return mf.buildCallSite();
}

마지막 세개를 먼저 살펴보면 samMethodType은 함수 객체에 구현될 메서드의 시그니처와 반환 타입입니다. 즉 함수형 인터페이스 Runnable에 있는 메서드 void run()이므로 ()V, 즉 MethodType(void.class)가 됩니다. 이는 아래의 부트스트랩 메서드 테이블의 메서드 인수에서도 확인했었습니다. 만약에 타겟 메서드가 제네릭 메서드라고 하면 samMethodType에는 타입이 소거된 후의 타입이 들어가는 반면에, instantiatedMethodType에는 실제 타입이 들어갑니다. void run()은 제네릭 메서드가 아니므로 samMethodType과 동일한 ()V, 즉 MethodType(void.class)가 됩니다. implMethod는 아래의 #29를 보면 짐작이 갈 것입니다.

    Method arguments:
      #28 ()V
      #29 REF_invokeStatic com/company/InvokeDynamicExample.lambda$main$0:()V
      #28 ()V

여기서 lambda$main$0은 InvokeDynamicExample 클래스에 생성된 private static 메서드입니다. 내용을 보면 System.out.println("Hello")이고 이는 람다 본문에서 사용됨을 쉽게 알 수 있습니다. 여기서 implMethod은 람다 인스턴스에서 호출되어야 하는 구현 메서드임을 확인할 수 있습니다.

InnerClassLambdaMetafactory

마지막으로 동적으로 클래스를 생성하고 정의하는 java.lang.invoke.InnerClassLambdaMetafactory 클래스를 잠깐만 살펴봅시다. 그 전에 이 클래스의 정적 초기화 블록을 살펴보면 아래와 같은 코드를 볼 수 있으며 함수형 인터페이스를 구현하는 클래스를 동적으로 생성하는 spinInnerClass()에서 만들어진 바이트 배열을 디스크로 덤프하는 것을 볼 수 있습니다.

// 생성된 클래스를 디스크에 덤프하거나 디버깅하기 위함
private static final ProxyClassesDumper dumper;

static {
	final String key = "jdk.internal.lambda.dumpProxyClasses";
	String path = AccessController.doPrivileged(
			new GetPropertyAction(key), null,
			new PropertyPermission(key , "read"));
	// 경로가 비어있으면 따로 덤프를 뜨지는 않는다.
	dumper = (null == path) ? null : ProxyClassesDumper.getInstance(path);
}

private Class<?> spinInnerClass() throws LambdaConversionException {
	...
	// 이 VM에서 생성된 클래스를 정의한다.
	final byte[] classBytes = cw.toByteArray();

	// 요청 시 디버깅을 위해 파일로 덤프
	if (dumper != null) {
		AccessController.doPrivileged(new PrivilegedAction<Void>() {
			@Override
			public Void run() {
				dumper.dumpClass(lambdaClassName, classBytes);
				return null;
			}
		}, null,
		new FilePermission("<<ALL FILES>>", "read, write"),
		new PropertyPermission("user.dir", "read"));
	}
	...
}

여기서 jdk.internal.lambda.dumpProxyClasses 프로퍼티에 적절한 경로를 지정하고 실행시켜봅시다.

그런 뒤에 프로그램을 실행하면 해당 경로에 덤프 파일이 만들어진 것을 볼 수 있습니다. 덤프된 클래스의 내부를 보면 final로 선언되어 있고 Runnable 인터페이스를 구현하는 것을 볼 수 있습니다. run() 메서드를 보면 InvokeDynamicExample.lambda$main$0이 여기서 호출된다는 사실을 알 수 있습니다.

package com.company;

import java.lang.invoke.LambdaForm.Hidden;

// $FF: synthetic class
final class InvokeDynamicExample$$Lambda$1 implements Runnable {
	// private 생성자지만 spinInnerClass()에서 리플렉션 API를 통해 생성자로의 접근 검사를 막아 인스턴스를 생성한다.
    private InvokeDynamicExample$$Lambda$1() {
    }

    @Hidden
    public void run() {
        InvokeDynamicExample.lambda$main$0();
    }
}

이어서 InnerClassLambdaMetafactory의 buildCallSite() 메서드 내부를 보면 매개변수의 수에 따라서 분기하는 것이 보입니다.

// 그저 이미 만들어진 인스턴스를 반환하는 메서드 혹은
// 정적 팩토리 메서드에 대한 메서드 핸들이 콜사이트로 들어간다.
@Override
CallSite buildCallSite() throws LambdaConversionException {
	// 클래스를 동적으로 생성하고 정의하고 반환하는 부분
	final Class<?> innerClass = spinInnerClass();
	// 매개변수가 없으면 (람다에서 참조하는 외부 변수가 없으면)
	if (invokedType.parameterCount() == 0) {
		final Constructor<?>[] ctrs = AccessController.doPrivileged(
				new PrivilegedAction<Constructor<?>[]>() {
			@Override
			public Constructor<?>[] run() {
				Constructor<?>[] ctrs = innerClass.getDeclaredConstructors();
				if (ctrs.length == 1) {
					// 내부 클래스 생성자를 구현하는 람다는 private이므로
					// 변함없는 단일 인스턴스를 만들기 위해 생성자에 접근할 수 있도록 리플렉션을 통해 자바의 접근 검사를 막는다.
					ctrs[0].setAccessible(true);
				}
				return ctrs;
			}
				});
		...
		try {
			// 여기서 만든 인스턴스를 반환하는 메서드 핸들이 콜사이트로 넘어간다.
			Object inst = ctrs[0].newInstance();
			return new ConstantCallSite(MethodHandles.constant(samBase, inst));
		}
		catch (ReflectiveOperationException e) { ...}
	} else { // 매개변수가 있으면
		try {
			UNSAFE.ensureClassInitialized(innerClass);
			// 정적 팩토리 메서드에 대한 메서드 핸들이 콜사이트로 넘어간다.
			return new ConstantCallSite(
					MethodHandles.Lookup.IMPL_LOOKUP
						 .findStatic(innerClass, NAME_FACTORY, invokedType));
		}
		catch (ReflectiveOperationException e) { ... }
	}
}

여기서 매개변수의 수는 생성된 클래스의 생성자로 넘어가는 매개변수를 말합니다. 보통 람다 내부에서 선언된 변수만 사용한다면 괜찮지만, 람다에서 외부 변수를 사용하는 경우에는 그 변수를 캡처해야 합니다. 보통 시시각각 변하는 화면을 캡처해서 정적인 화면을 얻을 수 있듯이, 람다(정확히는 클로저)도 자신을 둘러싸는 주변 환경을 캡처하여 람다가 사용하고 있는 외부 변수의 복사본을 만들어냅니다. 이는 람다 객체의 생존 범위와 지역 변수의 생존 범위가 다르기 때문이며, 사용 중이던 지역 변수가 자신을 둘러싸는 블록을 벗어나 소멸한다면 람다 내부에서 사용할 수 없게 되기 때문입니다.

int localVar = 3;

Function<Integer, Integer> f = (x) -> (int) (localVar * x);

위의 예시로부터 생성되는 클래스는 다음과 같습니다. 지역 변수의 복사본을 만들기 위해서 생성자에 정수 값을 넘겨받는 부분이 보입니다. 또한 람다 내부에서 캡처한 값, 즉 복사본 arg$1을 수정할 수는 없습니다. 내부에서 final로 선언되었기 때문입니다. get$Lambda는 보다시피 buildCallSite()에서 사용되는 정적 팩토리 메서드고 CallSite 내의 MethodHandle과 연결되어 있습니다.

// 컴파일러가 생성한 클래스 (InvokeDynamicExample$$Lambda$1.class)
// 생성된 클래스명은 룩업을 수행한 클래스의 이름 뒤에 ''$$Lambda$숫자'가 붙는다.
final class InvokeDynamicExample$$Lambda$1 implements Function {
    private final int arg$1;

    private InvokeDynamicExample$$Lambda$1(int var1) {
        this.arg$1 = var1;
    }

	// 이 정적 팩토리 메서드는 InnerClassLambdaMetafactory#spinInnerClass()에서 호출되는 InnerClassLambdaMetafactory#generateFactory()에서 만들어지며 이름은 NAME_FACTORY 상수에서 확인할 수 있다.
    private static Function get$Lambda(int var0) {
        return new InvokeDynamicExample$$Lambda$1(var0);
    }

    @Hidden
    public Object apply(Object var1) {
        return InvokeDynamicExample.lambda$main$0(this.arg$1, (Integer)var1);
    }
}

public class InvokeDynamicExample {
	public static void main(String [] args) throws Throwable {  
	    int localVar = 3;  

		// Function<Integer, Integer> f = InvokeDynamicExample$$Lambda$1.get$Lambda(localVar);와 비슷
	    Function<Integer, Integer> f = (x) -> (int) (localVar * x);
	    System.out.println(f.apply(5));
	}
	
    // 컴파일러가 생성한 메서드
	private static Integer lambda$main$0(int arg$1, Integer var1) {
	    return Integer.valueOf(arg$1 * var1.intValue());
	}
}

이렇게 런타임에 CallSite는 부트스트랩 메서드를 통해서 타겟 메서드와 동적으로 연결되는 것을 확인할 수 있었습니다. 세부적으론 다양한 최적화가 들어갈 수 있으나 대략적인 과정을 간단하게 정리해보면 다음과 같습니다.

그런데 여기서 같은 invokedynamic 명령을 만날 때마다 위와 같은 과정을 반복해야 할까요? 물론 아닙니다. JVM은 캐싱을 통해서 해당 invokedynamic 명령을 볼 때마다 부트스트랩 메서드를 호출하지 않고 바로 실제로 호출될 메서드, 즉 타겟 메서드를 호출할 수 있습니다. 여기서 변경 사항이 없는 한 JVM은 계속해서 처음에 거쳤던 단계를 건너뛰게 됩니다. 아래의 예시의 출력 결과를 참고해주세요.

public class InvokeDynamicExample {
    private static void hello() {
        Runnable r = () -> System.out.println("Hello");
        // (1): ...InvokeDynamicExample$$Lambda$1/1096979270@682
        System.out.println("(1): " + r);
        r.run();

		// (2): ...InvokeDynamicExample$$Lambda$2/1023892928@214
        r = () -> System.out.println("Hello");
        System.out.println("(2): " + r);
        r.run();
    }

    public static void main(String [] args) throws Throwable {
        System.out.println("첫 번째 hello() 호출: ");
        hello();
        System.out.println();

        System.out.println("두 번째 hello() 호출:");
        hello();
    }
}

참고