-
Java Lambdajava 2019. 5. 12. 13:33
개요
자바 람다식은 자바8부터 도입된 함수형 인터페이스를 구현한 코드를 간결하게 쓸 수 있게 해주는 문법이다. 여기서는 람다식의 문법 혹은 람다가 어떻게 도입되었는지, 얼마나 혁명적인지를 논의하기 보다는 람다식이 어떻게 컴파일되고 처리되는지 그 내부 구현을 기존 자바의 익명 클래스와 비교하면서 살펴본다.
내용
람다식의 특징
람다식이 어떻게 도입되었는지에 대한 설명은 생략하려 하나, 이후 논의를 위해서 특징적인 내용만 짚고 넘어가려 한다. 함수형 패러다임의 영향으로 자바에 람다식이 도입되기는 하였지만, 람다식은 자바의 새로운 함수 타입 체계는 아니다. 람다식은 단지 함수형 인터페이스(추상 메소드가 한 개만 존재하는 인터페이스)를 간결한 문법으로 구현할 수 있도록 한 것이다. '인터페이스'와 대등한 개념의 무언가 새로운 함수 타입이 아니라는 뜻이다. 이는 자바의 내부 구현 효율성을 높이기 위함인데, JVM의 바이트코드 수준에서 새로운 함수 타입(ex. ‘int 한 개를 파라미터로 받아 int를 반환’과 같은 함수 타입)을 구현할만한 명세가 없다. 자바8의 람다식은 JVM의 내부 복잡도를 높이는 대신, 기존 명세를 이용하는 방식으로 도입되었다.
람다식과 익명 클래스의 차이
그렇다면, 결국 람다식은 기존 자바의 익명 클래스 문법으로 치환되는 것일까? 그렇지 않다. 이를 단적으로 보여주는 예제를 살펴보자.
public class ThisDifference { public static void main(String[] args) { new ThisDifference().print(); } public void print() { Runnable anonClass = new Runnable(){ @Override public void run() { verifyRunnable(this); } }; anonClass.run(); Runnable lambda = () -> verifyRunnable(this); lambda.run(); } private void verifyRunnable(Object obj) { System.out.println(obj instanceof Runnable); } }
위 코드를 돌려보면, 익명 클래스로 구현한 Runnable 객체 anonClass는 true를 출력하고, 람다로 구현한 Runnable 객체 lambda는 false를 return한다. 즉, 익명 클래스로 구현한 Runnable 객체의 this는 Runnable의 instance이고, 람다로 구현한 Runnable 객체의 this는 추후 이야기하겠지만 일단 Runnable의 instance는 아니다. 이처럼 람다식과 익명 클래스는 내부 구현이 서로 다르다.
람다식이 어떻게 다른지 살펴보기 위해 JVM의 바이트코드를 살펴보자.
public class SimpleLambda { public static void main(String[] args) { Runnable lambda= () -> System.out.println(1); lambda.run(); } }
javap 명령어로 disassemble해보면, 위의 샘플 코드는 아래와 같이 변한다.
Compiled from "SimpleLambda.java" public class SimpleLambda { public SimpleLambda(); Code: 0: aload_0 // Method java/lang/Object."<init>":()V 1: invokespecial #8 4: return public static void main(java.lang.String[]); Code: // InvokeDynamic #0:run:()Ljava/lang/Runnable; 0: invokedynamic #19, 0 5: astore_1 6: aload_1 // InterfaceMethod java/lang/Runnable.run:()V 7: invokeinterface #20, 1 12: return private static void lambda$0(); Code: // Field java/lang/System.out:Ljava/io/PrintStream; 0: getstatic #29 3: iconst_1 // Method java/io/PrintStream.println:(I)V 4: invokevirtual #35 7: return }
람다식으로 객체를 생성하는 코드는 invokedynamic이라는 명세로 변환되었다. invokedynamic은 이름에서 유추할 수 있듯이 컴파일 시점이 아닌 런타임에 동적으로 클래스를 정의하고 그 인스턴스를 생성해서 반환하는 명세이다. invokedynamic은 자바7부터 도입되었다. 이렇듯 람다식은 새로운 함수 타입 체계를 만든 것은 아니었지만, 그렇다고 단순히 익명 클래스의 문법을 치환한 존재는 아니며, 기존의 inovokedynamic이라는 바이트코드 명세를 활용해 새로운 차이를 만들어낸 것이다.
람다식의 처리 과정
During Compile Time
앞서 잠시 언급했듯이, 람다식은 컴파일 타임에는 객체를 생성하지 않는다. 다만 런타임에 JVM이 객체를 생성할 수 있도록 람다식을 invokedynamic으로 변환하여 그 '레시피'만을 생성해둔다. invokedynamic은 다음과 같은 3가지 정보를 필요로 한다.
- bootstrap method
- static args
- dynamic args
각각에 대해 간단히 설명하자면, bootstrap method는 동적으로 객체를 생성하는 메소드이다. 람다식에서는 LambdaMetafactory.metafactory()가 bootstrap method에 해당된다. static args, dynamic args는 용어 그대로 정적 인수, 동적 인수이다. 람다식으로 구현한 인터페이스와 람다 body 코드가 static args로 전달되고, dynamic args로는 free variable이 전달된다.
* free variable
잠시 free variable의 개념을 짚고 넘어가자. free variable은 자신을 감싼 영역(closure) 외부에 존재하는 변수를 의미한다.
ex.
int offset = 10; Function<Integer, Integer> func = (a) -> offset * a;
위의 경우에서 람다식 영역 기준에서 offset 변수는 free variable이 된다.
다시 돌아와서, 예제를 통해 위 내용들을 살펴보자.
public class SimpleLambda { public static void main(String[] args) { String str = "hello"; Runnable lambda= () -> System.out.println(str); lambda.run(); } }
위 class를 컴파일하면, 람다식은 invokedynamic 바이트코드로 변환된다. 그리고 앞서 언급한 3가지 정보에는 다음과 같은 데이터가 전달된다.
- bootstrap method: LambdaMetafactory.metafactory()
- static args: Runnable, SimpleLambda.lambda$0()
- dynamic args: str 변수
1번 내용은 앞서 설명했고, 2번 static args 내용은 추가 설명이 필요해보인다. 우선, Runnable 인터페이스를 구현하는 람다식이므로 Runnable 인터페이스를 넘긴다는 점은 당연해보인다. 그렇다면 SimpleLambda.lambda$0()는 무엇일까? 이를 이해하기 위해서 컴파일된 클래스를 disassemble한 코드를 다시 보자.
Compiled from "SimpleLambda.java" public class SimpleLambda { public SimpleLambda(); Code: 0: aload_0 // Method java/lang/Object."<init>":()V 1: invokespecial #8 4: return public static void main(java.lang.String[]); Code: // InvokeDynamic #0:run:()Ljava/lang/Runnable; 0: invokedynamic #19, 0 5: astore_1 6: aload_1 // InterfaceMethod java/lang/Runnable.run:()V 7: invokeinterface #20, 1 12: return private static void lambda$0(); Code: // Field java/lang/System.out:Ljava/io/PrintStream; 0: getstatic #29 3: iconst_1 // Method java/io/PrintStream.println:(I)V 4: invokevirtual #35 7: return }
private static void lambda$0()이라는 private static 메소드가 새로 생겼다. 이 메소드의 정체는 람다식의 body이다. 컴파일 타임에 람다식 body를 그대로 복사한 static 메소드가 람다식을 포함하고 있는 클래스에 추가된다. 위에서 Runnable의 람다식 내부에서 this를 호출했을 때, Runnable 타입이 아니었던 이유가 바로 여기에 있다. 람다식 내부에서 this는 람다식을 포함하고 있는 그 클래스 객체를 가리키고, 위의 예제에서는 SimpleLambda가 된다. 람다식 코드는 컴파일 타임에 그 클래스의 private static 메소드로 추가되기 때문이다.
정리
람다식의 컴파일 타임에 일어나는 일들을 정리해보자.
1. 람다식은 컴파일 타임에 클래스가 정의되지 않고 런타임에 JVM이 하도록 떠넘긴다. 대신, 그 레시피만을 제공한다.
2. 그 마법의 레시피는 invokedynamic 바이트코드이다. 여기에 3가지 준비물이 필요하다. bootstrap method, static args, dynamic args가 그에 해당된다.
3. bootstrap method는 LambdaMetafactory.metafactory()이다.
4. static args로는 람다식이 구현하는 인터페이스와 람다식 코드를 그대로 복사한 새 private static method를 생성해 이를 전달한다.
5. dynamic args로는 자유 변수들을 전달한다.
여기까지가 컴파일 단계에서 일어나는 일들이다. 이제 남은 것들은 런타임에 JVM의 몫이다.
During Run Time
컴파일 타임에 제시해둔 레시피대로 런타임에 JVM이 람다 객체를 생성한다. 앞서 언급한 bootstrap method인 LambdaMetafactory.metafactory()가 람다식의 클래스를 동적으로 정의하고 객체를 반환하는 역할을 한다. 람다 클래스 정의 및 구현에 대한 구체적인 규약은 없다. 동적으로 정의하는 만큼, 그 시점에서 효율적인 방식으로 JVM이 유연하게 객체를 생성할 수 있다.
그런데 여기서 한 가지 의문점이 생긴다. 앞서 분명히 람다식의 코드는 그 클래스의 private static method로 생성되고, Runnable의 run()이 수행되면, 이 메소드가 실행되는 것이라고 했다. 그 증거가 this가 가리키는 것이 SimpleLambda 객체라는 점이었다. 그러면 JVM이 동적으로 생성한 람다식의 객체, Runnable 인터페이스를 구현한 그 객체는 무엇일까? 아래 예제를 보자.
public class Test { public static void main(String[] args) { Test test = new Test(); test.testMethod(); } public void testMethod() { Runnable runnable = () -> { System.out.println("this: " + this); throw new RuntimeException(); }; System.out.println("class: " + runnable.getClass()); runnable.run(); } }
람다식 내에서 this를 출력하고, 일부러 RuntimeException을 던져 콜 스택을 확인하고, 람다로 구현된 runnable 객체의 class를 출력해보는 코드이다. 결과는 다음과 같다.
class: class Test$$Lambda$1/1078694789 this: Test@682a0b20 Exception in thread "main" java.lang.RuntimeException at Test.lambda$testMethod$0(Test.java:10) at Test.testMethod(Test.java:13) at Test.main(Test.java:4) Process finished with exit code 1
위의 결과에서 볼 수 있듯이, 람다로 구현한 runnable의 this는 Test class의 객체이다. 또한, 람다식 내에서 예외를 던졌더니 콜 스택의 마지막에 Test.lambda$testMethod$0 이라는 앞서 언급한 새로 생긴 private static method가 찍혀 있다. 그런데, runnable의 class는 'Test$$Lambda$1/1078694789' 라는 새롭게 정의된 클래스이다. 이것이 바로 JVM이 동적으로 정의한 클래스이다. this가 Test인 것도 그렇고, 콜 스택 상으로도 Test의 static method이고, 모든 것이 Test를 가리키는데 대체 JVM은 런타임에 무엇을 하는 Runnable 람다 객체를 생성한 걸까?
자바 프로그램을 실행할 때, -Djdk.internal.lambda.dumpProxyClasses 옵션을 붙이면 람다식으로 새롭게 정의되는 동적 클래스를 파일로 저장한다. 이 옵션을 사용해 저장한 Test class 예제에서 새롭게 생성된 람다 클래스는 아래와 같다.
import java.lang.invoke.LambdaForm.Hidden; // $FF: synthetic class final class Test$$Lambda$1 implements Runnable { private final Test arg$1; private Test$$Lambda$1(Test var1) { this.arg$1 = var1; } private static Runnable get$Lambda(Test var0) { return new Test$$Lambda$1(var0); } @Hidden public void run() { this.arg$1.lambda$testMethod$0(); } }
이것이 바로 람다식 객체의 정체이다. 람다식으로 구현된 Runnable 객체는 Runnable interface를 구현하고 있다. 그러나 그 어디에도 내가 작성한 람다식의 내용은 없다. Runnable의 run()은 args$1의 lambda$testMethod$0()를 호출하고 있을 뿐이다. 이제 의문점이 풀릴 것이다.
JVM이 동적으로 정의한 람다 클래스는 생성자에서 Test 객체를 전달 받아, 멤버 변수에 대입한 다음, run()이 호출되면 람다식 내부 코드가 복사되어 있는 Test객체의 private static method를 호출한다.
왜 이러한 방식으로 람다를 구현했는지는 다음 섹션에서 살펴보기로 하자.
정리
람다식의 런타임에 일어나는 일들을 정리해보자.
1. JVM은 컴파일 타임에 만들어진 레시피대로 bootstrap method에 static args, dynamic args를 넘겨, 람다식이 실행되는 시점에 동적으로 클래스를 정의하고 그 객체를 생성해 반환한다.
2. JVM이 동적으로 정의한 람다 클래스는 static args로 넘겨 받은 타겟 인터페이스를 구현한다.
3. JVM이 동적으로 정의한 람다 클래스는 실행 메소드(Runnable의 경우 run)에서, 컴파일 타임에 본래 클래스(예제에서는 Test)에 생성된, 람다식 내부 코드를 담은 private static method(예제에서는 lambda$testMethod$0)을 호출한다.
4. 호출된 본래 클래스의 private static method가 실제 람다식 내부 코드를 수행한다.
람다식의 효율성
람다식의 효율성을 살펴보기 전에 추후 나올 용어의 개념을 먼저 살펴보자.
Stateless(=Non Capturing) Lambda VS Stateful(=Capturing) Lambda
Stateless 람다는 free variable을 참조하지 않는 람다식이다.
Stateful 람다는 free variable을 참조하는 람다식이다.
람다식의 효율성은 invokedynamic에 의한 동적 객체 생성에 기인한다. 아래의 예제 코드를 보자.
public void loopLambda() { myStream.forEach(item -> item.doSomething()); } public void loopAnonymous() { myStream.forEach(new Consumer<Item> () { @Override public void accept(Item item) { item.doSomething(); } } }
첫 번째 메소드는 람다식으로 구현한 stream forEach문이고, 두 번째 메소드는 익명 클래스로 구현한 stream forEach문이다. 익명 클래스로 구현한 forEach문은 매 iteration마다 익명 클래스를 정의하고, 그 객체를 생성한다. 만일 myStream의 요소가 100만개라면, 100만개의 새로운 익명 클래스가 생겨난다. 하지만 람다식으로 구현된 forEach문은 단 한 개의 클래스만이 생겨난다. 람다식으로 생성된 클래스는 그 실행 메소드에서 단순히 본래 클래스의 private static method만 호출하기 때문에, 구현 객체만 생성해도 무한하게 재사용할 수 있기 때문이다. 따라서, 익명 클래스에 비해 성능과 자원 면에서 훨씬 효율적이다. 실제로, 어떤 실험 케이스에 따르면, 람다가 익명 클래스에 비해 인스턴스 생성 비용이 1/67 밖에 들지 않는다고 한다.
위의 예시들은 모두 stateless 람다였으므로, 아래는 stateful 람다의 예시이다.
public class Test { public static void main(String[] args) { Test test = new Test(); test.testMethod(); } public void testMethod() { int a = 10; Runnable runnable = () -> { System.out.println("a: " + a); }; runnable.run(); } }
위 예제를 -Djdk.internal.lambda.dumpProxyClasses 옵션을 사용해 동적 클래스를 저장해 보면,
import java.lang.invoke.LambdaForm.Hidden; // $FF: synthetic class final class Test$$Lambda$1 implements Runnable { private final int arg$1; private Test$$Lambda$1(int var1) { this.arg$1 = var1; } private static Runnable get$Lambda(int var0) { return new Test$$Lambda$1(var0); } @Hidden public void run() { Test.lambda$testMethod$0(this.arg$1); } }
stateless 람다일 때(이전 섹션 예제 참고)와 달리, 생성자를 통해 free variable의 값을 받아 멤버 변수에 대입한다. free variable이 local variable이면(stack에 존재) 값이 copy되고, heap variable이면(인스턴스 필드와 같이) reference를 갖는다. 람다에서 참조할 수 있는 free variable은 final이거나 effectively final 이어야 하므로 그 값이 변경되지 않는다. 따라서 stateful 람다일 때도 마찬가지로 하나의 동적 클래스만으로 계속 재사용이 가능하다.
(stateless람다와 stateful람다일 때의 성능 비교가 궁금하다면 여기를 참고)
주의
람다식을 invokedynamic으로 처리하는 것은 불변의 사실이나, 앞서 언급했듯이 동적으로 객체를 생성하는 만큼 그 방식이 정해진 것이 아니라 JVM이 유연하게 처리하도록 하였다. 따라서, 위의 예제들에서 나오는 동적 클래스의 외형은 JVM마다 다를 수 있다. (위 예제는 Oracle JVM으로 실행)
결론
람다를 단순히 익명클래스 문법으로 변환하는 방식으로 구현하는 것이 가장 간단할 것이다. 그러나, 익명 클래스는 위의 예제처럼 매 번 새 클래스를 정의하고 객체를 생성하는 문제점이 있었다. 따라서 람다는 그 문제점을 해결하는 방식으로 구현되었다. 또한, 람다는 동적 구현 방식이 매우 유연하기 때문에 더욱 효율적으로 발전할 수 있는 여지가 있다. 실제로 자바8의 각 소소한 release마다 람다에 대한 내용이 계속 변해왔다고 한다. 결론적으로 자바8의 람다는 완전한 함수형 패러다임에 부응하는 새로운 함수 타입은 아니지만, 뻔하지만은 않은 방식으로 구현되었다고 할 수 있다.
참고
https://d2.naver.com/helloworld/4911107
https://www.logicbig.com/tutorials/core-java-tutorial/java-8-enhancements/java-capturing-lambda.html
https://www.infoq.com/articles/Java-8-Lambdas-A-Peek-Under-the-Hood
'java' 카테고리의 다른 글
Java equals() & hashcode() (0) 2019.05.13 Java 11 Features (0) 2019.05.12 Java 10 Features (0) 2019.05.12