람다의 비밀: 자바의 특별한 감정 표현식

redjen·2024년 3월 31일
7

월간 딥다이브

목록 보기
3/11
post-thumbnail

서론

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이 익명 클래스를 편리하게 람다 표현식으로 바꿔준 경험이 있을 것이다.

자바의 람다는 내부 익명 클래스의 syntactic sugar일까?

ide 단에서 최적화 해준 코드고, 동일하게 동작하는 것처럼 보이기 때문에 단지 문법 상에만 차이가 있다고 생각하게 된다.

하지만 바이트 코드로 컴파일된 자바 람다 표현식은 내부 익명 클래스를 가지지 않는다. 즉 내부 익명 클래스와는 완전히 다른 방식으로 동작하지 않는다.

람다는 익명 클래스일까?

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

자바의 람다 표현식은 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이다.
  • 위 코드에서의 invokedynamicRunnable을 implement하는 어떤 타입의 인스턴스를 리턴한다.
    - 이 때 정확한 타입은 바이트 코드 상에는 존재하지 않지만 근본적으로 상관 없다.
  • 즉 컴파일 타임에 invokedynamic의 실제 타입은 존재하지 않고, 런타임에 필요한 시점이 되었을 때 생성된다.

Call Sites

메서드 호출 명령이 발생하는 바이트 코드 상의 위치를 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가 한번도 호출된 적이 없으면 생성하고
  • 존재하는 call site를 연결해준다.

이때 call site 객체는 실제로 호출할 메서드를 대표하는 객체인 method handle을 가지고 있다.

Call Site 내부 구현은 크게 3가지 하위 클래스로 이루어진다.

  • ConstantCallSite
  • MutableCallSite
  • VolatileCallSite

CallSite 클래스는 패키지 내부 생성자만 가지고 있지만 3개의 하위 클래스들은 public한 생성자를 가지고 있다.
CallSite 자체는 사용자 코드로 직접 하위 클래스를 만들 수 없지만 앞서 언급한 3가지 하위 클래스를 상속하여 다루는 것이 가능하다.

어떤 invokedynamic call site들은 lazy하게 결정되어 정해진 메서드 대상이 한번 실행된 이후에는 변하지 않는다는 것을 유의해야 한다. ConstantCallSite가 사용되는 많은 경우에 그러하며, 이는 람다 표현식을 포함한다.

Method handle

초창기 자바에서의 리플렉션 API의 한계

리플렉션은 런타임에 묘기를 부리기 좋은 기술이지만, 디자인적인 결함이 분명히 존재한다.

리플렉션의 큰 문제 중 하나는 성능이다. reflective한 호출은 JIT 컴파일러가 인라이닝 하기 까다롭다는데 그 이유가 있다. (예측이 거의 불가능하기 때문이리라)

인라이닝은 JIT 컴파일 과정에서 가장 첫 번째로 일어나는 중요한 최적화 과정이기 때문에 인라이닝이 일어나지 않는다면 DCE와 같은 다른 기법을 사용하도록 유도된다.

두 번째 문제는 Method.invoke()의 call site가 실행될 때마다 reflective한 호출이 링크된다는 점이다.
예를 들어 보안을 위해 AccessControl 등으로 접근 제어를 실행하는 코드가 있다고 가정해보자.

  • 첫 번째 호출에서 성공 또는 실패가 결정된다.
  • 성공하더라도 프로그램이 실행되는 동안에는 계속해서 리플렉션 호출을 수행한다.
  • 때문에 이렇게 발생하는 리플렉션은 재링크를 하는데 있어 불필요하게 CPU 타임을 낭비한다.

Java 7에서부터의 리플렉션

Java 7에서는 이러한 문제를 해결하기 위해 새로운 api인 java.lang.invoke를 도입하였다.
java.lang.invoke는 도입한 메인 클래스의 이름 때문에 종종 메서드 핸들이라고 불리기도 한다.

메서드 핸들은 자바 버전의 타입 세이프한 함수 포인터이다.

메서드 핸들은 코드가 호출하고 싶은 메서드를 레퍼런스함과 동시에 invoke() 메서드를 가지고 있어 연결된 메서드를 실행할 수 있다. (이 부분은 리플렉션과 동일하다)

하지만 메서드 핸들은 기계에 조금 더 가까운, 효과적인 리플렉션 방식이다.
이를 위해 리플렉션 API를 통해 표현된 모든 객체는 동등한 메서드 핸들로 변환될 수 있게 만들어졌다.

리플렉션으로부터 생성된 메서드 핸들은 그 기저의 메서드를 접근하기 위한 더 효과적인 방법을 제공한다.

  1. 메서드 핸들은 MethodHandles 클래스의 헬퍼 메서드를 통해 메서드 합성이나 메서드 인수의 부분 바인딩 (커링)과 같은 다양한 방법으로 조정될 수 있다.
  2. 일반적으로 메서드 링킹은 정확하게 타입 디스크립터가 일치되어야 하지만 메서드 핸들의 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 이후부터는 대부분의 경우에서 리플렉션을 사용하는 것보다 메서드 핸들을 사용하는 것이 훨씬 드라마틱한 성능 향상을 제공한다.

(성능 비교 영상은 꽤 길고 지루하며, 영어 발음이 좀 알아듣기 힘들어서 별도로 정리하진 못했다 🥲)

정리

  • 자바7 이전의 리플렉션 API는 최적화하기 어려운 이슈가 있었고, 이를 해결하기 위해 메서드 핸들이라는 개념을 도입하였다.
  • 자바8에서 도입된 람다 폼은 메서드 핸들을 사용한다.
  • 때문에 우리가 알고 있는 람다는 익명 클래스를 런타임에 생성할 필요 없이 실행 시 컴파일러 단에서 최적화 이점을 가질 수 있다.
profile
make maketh install

0개의 댓글

관련 채용 정보