Java 람다의 실체

내 이 세상 도처에서 쉴 곳을 찾아보았으나, 마침내 찾아낸, 컴퓨터가 있는 구석방보다 나은 곳은 없더라.

Java 람다의 실체

Java에 람다 표현식이 추가된 지 꽤 됐지만, 람다 표현식이 컴파일러를 통해 익명 클래스로 변환되는 편의 문법 정도로 생각하는 경우가 많은 것 같다. 컴파일러가 생성한 클래스 파일을 살펴보고, 람다 표현식이 실제로 어떻게 컴파일 되는지 확인해보려 한다.

먼저, 간단한 람다 표현식을 포함하는 클래스를 작성해 컴파일해보자.

import java.util.function.IntBinaryOperator;

public class Lambda {
  public static void main(String[] args) {
    IntBinaryOperator add = (a, b) -> a + b;            // lambda
    System.out.println(add.applyAsInt(10, 20));
  }
}

위와 동일한 코드를 익명 클래스를 사용해 작성하면 다음과 같다.

import java.util.function.IntBinaryOperator;

public class InnerClass {
  public static void main(String[] args) {
    IntBinaryOperator add = new IntBinaryOperator() {  // anonymous class
      @Override
      public int applyAsInt(int a, int b) {
        return a + b;
      }
    };
    System.out.println(add.applyAsInt(10, 20));
  }
}

람다 표현식은 함수형 인터페이스의 추상 메서드를 구현하는 것과 동일해 보이므로, 컴파일러가 람다 표현식을 익명 클래스로 변환하는 것이 타당해 보인다. 정말 위 두 코드는 동일한 바이트코드로 컴파일될까?

클래스 파일

익명 클래스가 있는 소스 코드를 컴파일하면 익명 클래스를 위한 별도의 클래스 파일이 생성된다. 위 경우에는 InnerClass$1.class 파일이 생성될 것이다. 람다 표현식이 익명 클래스로 변환된다면 Lambda$1.class 생성되어야 할 것이다. 클래스 파일이 저장된 디렉터리로 가서 클래스 파일을 확인해보자.

$ ls
InnerClass$1.class InnerClass.class   Lambda.class

예상대로 InnerClass의 경우는 InnerClass.class와 함께 InnerClass$1.class파일이 함께 생성되었다. 그러나 Lambda의 경우는 Lambda.class만 생성되었을 뿐 Lambda$1.class는 보이지 않는다. 람다 표현식은 익명 클래스와 다르게 컴파일되는 것이 분명하다.

InnerClass 바이트코드

javap 명령을 이용하면 클래스 파일의 바이트코드를 살펴볼 수 있다.

$ javap -c -p ClassName

역어셈블된 코드를 보고 싶으면 -c 옵션을 추가해야 한다. -p 옵션을 주면 private을 포함한 모든 멤버와 클래스를 보여준다.

역어셈블된 InnerClass의 코드는 다음과 같다. new 연산을 이용해 InnerClass$1의 인스턴스를 생성하는 코드가 보인다. 또한 invokeinterface를 이용해 applyAsInt 메서드를 호출하는 코드도 보인다.

Compiled from "InnerClass.java"
public class InnerClass {
  public InnerClass();
    Code:
       0: aload_0
       1: invokespecial #1         // Method java/lang/Object."":()V
       4: return

  public static void main(java.lang.String[]);
    Code:
       0: new           #2         // class InnerClass$1
       3: dup
       4: invokespecial #3         // Method InnerClass$1."":()V
       7: astore_1
       8: getstatic     #4         // Field java/lang/System.out:Ljava/io/PrintStream;
      11: aload_1
      12: bipush        10
      14: bipush        20
      16: invokeinterface #5,  3   // InterfaceMethod
                                      java/util/function/IntBinaryOperator.applyAsInt:(II)I
      21: invokevirtual #6         // Method java/io/PrintStream.println:(I)V
      24: return
}

InnerClass$1를 역어셈블한 코드는 다음과 같다. InnerClass$1IntBinaryOperator 인터페이스를 구현하는 클래스며 applyAsInt 메서드가 정의되어 있는 것을 확인할 수 있다.

Compiled from "InnerClass.java"
class InnerClass$1 implements java.util.function.IntBinaryOperator {
  InnerClass$1();
    Code:
       0: aload_0
       1: invokespecial #1         // Method java/lang/Object."":()V
       4: return

  public int applyAsInt(int, int);
    Code:
       0: iload_1
       1: iload_2
       2: iadd
       3: ireturn
}

Lambda 바이트코드

람다 표현식을 포함한 소스 코드를 컴파일 했을 때 ClassName$1과 같이 내부 클래스를 컴파일했을 때 생기는 클래스 파일이 생성되지 않음을 이미 확인했다. 람다 표현식은 어떻게 Java 바이트코드로 변환될까?

다음은 javapLambda를 역어셈블한 코드다. 익명 클래스의 바이트코드와는 상당히 다르다. 별도 클래스의 인스턴스를 생성하던 부분이 invokedynamic으로 바뀌어 있는 것을 볼 수 있다. invokedynamic은 JVM에서 다이나믹 언어를 지원하기 위해 JDK7에 처음 도입된 바이트코드 명령이지만, 여기서는 조금 다른 목적으로 사용되었다.

Compiled from "Lambda.java"
public class Lambda {
  public Lambda();
    Code:
       0: aload_0
       1: invokespecial #1         // Method java/lang/Object."":()V
       4: return

  public static void main(java.lang.String[]);
    Code:
       0: invokedynamic #2,  0     // InvokeDynamic
                                      #0:applyAsInt:()Ljava/util/function/IntBinaryOperator;
       5: astore_1
       6: getstatic     #3         // Field java/lang/System.out:Ljava/io/PrintStream;
       9: aload_1
      10: bipush        10
      12: bipush        20
      14: invokeinterface #4,  3   // InterfaceMethod
                                      java/util/function/IntBinaryOperator.applyAsInt:(II)I
      19: invokevirtual #5         // Method java/io/PrintStream.println:(I)V
      22: return

  private static int lambda$main$0(int, int);
    Code:
       0: iload_0
       1: iload_1
       2: iadd
       3: ireturn
}

invokedynamic은 람다 표현식의 바이트코드 변환 전략을 지연하기 위한 용도로 사용되었다. 즉, 람다 표현식 구현을 위한 코드 생성을 실제 런타임까지 연기한다. 이렇게 하면 몇 가지 좋은 점이 있지만, 여기서는 설명하지 않겠다.

이 코드에서는 람다 표현식이 정적 메서드로 변환되었다. 이렇게 외부 상태를 캡쳐하지 않는 람다 표현식은 가장 단순한 형태로, 컴파일러는 람다 표현식을 동일한 시그니처의 메서드로 변환할 수 있다. 람다 표현식이 정적 메서드로 변환된 것은 호출부인 main 메서드가 정적 메서드기 때문이다. main 메서드를 인스턴스 메서드로 바꾸면 람다 표현식을 변환한 메서드 또한 인스턴스 메서드로 바뀔 것이다.

결론

람다 표현식은 익명 클래스의 편의 문법이 아니다. 람다 표현식은 컴파일 시에 익명 클래스로 변환되지 않는다. 대신 invokedynamic을 이용해 람다 변환 전략을 런타임으로 미루며, 런타임은 람다 표현식을 평가할 때 적절한 전략을 동적으로 선택할 수 있다.

참고

  • Raoul-Gabriel Urma, Mario Fusco, and Alan Mycroft. (2015) Java 8 in Action: Lambdas, streams, and functional-style programming, Appendix D Lambdas and JVM bytecode, Manning
  • Translation of Lambda Expressions