티스토리 뷰

Java & Kotlin

[JVM] 리플렉션(Reflection)을 포함한 다양한 코드 접근 방식들의 성능

망나니개발자 2024. 4. 23. 10:00
반응형

아래의 내용은 해외의 다음 포스팅을 변역 및 정리한 내용입니다.

 

 

 

1. 리플렉션(Reflection)을 포함한 다양한 코드 접근 방식들의 성능 


[ 코드 접근 방식의 성능 요구사항 ]

우리는 특정 클래스를 요청/응답에 사용하거나 혹은 ORM에 사용하는 등의 많은 경우에 직렬화/역직렬화 과정을 거쳐야 하며, 이를 위해 특정 클래스의 Getter에 접근해야 할 때가 있다. 제네릭 혹은 Object 등을 사용하면 컴파일 타입이 존재하지 않으므로 어떤 클래스에 대한 직렬화/역직렬화를 하는지 모르는데, 이때 특정 클래스에서 Getter를 읽는 가장 빠른 방법은 무엇일까?

예를 들어 다음과 같은 Person 클래스가 있다고 하자.

public class Person {
   ...

   public String getName() {...}
   public Address getAddress() {...}

}

 

 

해당 객체를 Jackson의 ObjectMapper를 사용하여 직렬화한다고 할 때, objectMapper는 제네릭을 사용하므로 어떤 클래스에 대해 역직렬화 하는지 모르므로 해당 클래스의 Getter들 역시 모른다. 따라서 단순히 person.getName()을 호출할 수 없는 상황이다.

objectMapper.readValue(value, Person.class)

 

 

 

이를 위해 Reflection이나 Method handles 및 code generation 등의 방법을 사용할 수 있다. 이러한 코드는 엄청나게 많이 호출될 것이다. 예를 들어 1000개의 Person 객체를 데이터베이스에 저장한다고 하면, JPA/Hibernate는 해당 코드를 2000번 실행하게 될 것이다.

  • name을 얻기 위해 Person.getName() 호출
  • address를 얻기 위해 Person.getAddress() 호출

 

 

따라서 코드 접근 방식은 많이 호출되므로 성능이 중요하다.

 

 

 

[ 다양한 접근 방법에 대한 벤치마크 결과 ]

초당 N번 호출되는 경우에는 Getter에 접근하는 성능 역시 중요해질 것이므로, 각각의 방법에 대한 성능을 살펴볼 필요가 있다. 다음은 64비트 8코어 Intel i7-4790 데스크톱(32GB RAM)에서 Linux의 OpenJDK 1.8.0_111을 사용하여 일련의 마이크로 벤치마크를 실행한 결과이다. 벤치마크는 3개의 포크, 1초의 warm-up 5회 반복, 1초의 측정 20회 반복 실행되었습니다. 모든 워밍업 비용은 사라졌고, 반복 시간을 5초로 늘려도 여기에 보고된 수치에는 거의 영향을 미치지 않습니다.

먼저 실행 결과를 요약하면 다음과 같다. 그러면 setAccessabile(true)와 같은 부분에서 어떤 트릭을 적용했는지 살펴보도록 하자.

  • Reflection은 느림
  • MethodHandles도 느림
  • javax.tools.JavaCompiler로 생성된 코드는 빠름
  • LambdaMetafactory도 빠름

 

 

Direct access(직접 호출) 방식

다음과 같이 직접 접근하는 방식부터 살펴보도록 하자.

public final class MyAccessor {

    public Object executeGetter(Object object) {
        return ((Person) object).getName();
    }

}

 

 

해당 연산은 1회당 2.6ns 소요되었다.

Benchmark           Mode  Cnt  Score   Error  Units
===================================================
DirectAccess        avgt   60  2.590 ± 0.014  ns/op

 

 

직접 호출하는 방식은 부트스트랩 비용 없이 런타임에 가장 빠른 접근 방법이다. 하지만 컴파일 시점에 타입이 Person으로 정해지기 때문에 모든 프레임워크에서 사용할 수 없다.

 

 

 

Reflection 방식

프레임워크가 타입을 미리 알지 못한 경우, 런타임에서 해당 게터를 읽을 수 있는 확실한 방법은 Reflection을 사용하는 것이다.

public final class MyAccessor {

    private final Method getterMethod;

    public MyAccessor() {
        getterMethod = Person.class.getMethod("getName");
        // Skip Java language access checking during executeGetter()
        getterMethod.setAccessible(true);
    }

    public Object executeGetter(Object bean) {
        return getterMethod.invoke(bean);
    }
}

 

 

setAccessible(true)의 호출을 추가함으로써 reflection 호출을 빠르게 만들 수 있지만, 그럼에도 불구하고 연산 1회당 5.5ns가 소요된다. Reflection은 직접 접근 방식보다 약 2배나 느리며, 웜업까지도 오래 걸린다.

Benchmark           Mode  Cnt  Score   Error  Units
===================================================
DirectAccess        avgt   60  2.590 ± 0.014  ns/op
Reflection          avgt   60  5.275 ± 0.053  ns/op

 

 

리플렉션을 사용하는 방식은 ModelMapper와 같은 여러 라이브러리나 프레임워크 등을 통해 성능에 대한 문제가 계속해서 알려져왔기 때문에 어느 정도 알려져있을 것이다.

 

 

 

MethodHandles 방식

MethodHandle은 invokedynamic 명령어를 지원하기 위해 Java 7에 도입되었다. Javadoc에 따르면, 이는 메서드에 대해 직접 실행 가능한 타입의 참조(typed, directly executable reference to an underling method)에 해당한다.

public final class MyAccessor {

    private final MethodHandle getterMethodHandle;

    public MyAccessor() {
        MethodHandles.Lookup lookup = MethodHandles.lookup();
        // findVirtual() matches signature of Person.getName()
        getterMethodHandle = lookup.findVirtual(Person.class, "getName", MethodType.methodType(String.class))
            // asType() matches signature of MyAccessor.executeGetter()
            .asType(MethodType.methodType(Object.class, Object.class));
    }

    public Object executeGetter(Object bean) {
        return getterMethodHandle.invokeExact(bean);
    }
}

 

 

설명만 보면 성능이 좋아 보이지만, 실제로는 JDK8 기준으로 Reflection보다 성능이 안좋다. 이는 연산 1회당 6.1ns가 소요된다. lookup.findVirtual(…) 대신 lookup.unreflectGetter(Field)를 사용해도 눈에 띄는 차이는 없다. 향후 Java 업데이트에 따라 빨라지길 바란다.

Benchmark           Mode  Cnt  Score   Error  Units
===================================================
DirectAccess        avgt   60  2.590 ± 0.014  ns/op
Reflection          avgt   60  5.275 ± 0.053  ns/op
MethodHandle        avgt   60  6.100 ± 0.079  ns/op

 

 

MethodHandle을 static한 필드로 두고 테스트 하는 경우에는 더 많은 최적화가 가능하기 때문에 더 많은 성능 향상을 기대할 수 있다.

Benchmark           Mode  Cnt  Score   Error  Units
===================================================
DirectAccess        avgt   60  2.590 ± 0.014  ns/op
MethodHandle        avgt   60  6.100 ± 0.079  ns/op
StaticMethodHandle  avgt   60  2.635 ± 0.027  ns/op

 

 

static MethodHandle은 직접 접근하는 방식 만큼이나 빠르지만 유용하지 못하다. 왜냐하면 다음과 같이 코드를 짤 수는 없기 때문이다. 또한 인스턴스 객체가 static 필드로 접근하는 것도 바람직하지 못하다.

public final class MyAccessors {

    private static final MethodHandle handle1; // Person.getName()
    private static final MethodHandle handle2; // Person.getAge()
    private static final MethodHandle handle3; // Company.getName()
    private static final MethodHandle handle4; // Company.getAddress()
    private static final MethodHandle handle5; // ...
    private static final MethodHandle handle6;
    private static final MethodHandle handle7;
    private static final MethodHandle handle8;
    private static final MethodHandle handle9;
    ...
    private static final MethodHandle handle1000;

}

 

 

 

javax.tools.JavaCompiler 코드 생성 방식

자바에서는 런타임에 생성된 자바 코드를 컴파일하고 실행할 수 있다. 따라서 javax.tools.JavaCompiler API를 사용하면 런타임에 직접 접근하는 코드를 생성할 수 있다.

public abstract class MyAccessor {

    // Just a gist of the code, the full source code is linked in a previous section
    public static MyAccessor generate() {
        final String String fullClassName = "x.y.generated.MyAccessorPerson$getName";
        final String source = "package x.y.generated;\\n"
                + "public final class MyAccessorPerson$getName extends MyAccessor {\\n"
                + "    public Object executeGetter(Object bean) {\\n"
                + "        return ((Person) object).getName();\\n"
                + "    }\\n"
                + "}";
        JavaFileObject fileObject = new ...(fullClassName, source);

        JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
        ClassLoader classLoader = ...;
        JavaFileManager javaFileManager = new ...(..., classLoader)
        CompilationTask task = compiler.getTask(..., javaFileManager, ..., singletonList(fileObject));
        boolean success = task.call();
        ...
        Class compiledClass = classLoader.loadClass(fullClassName);
        return compiledClass.newInstance();
    }

    // Implemented by the generated subclass
    public abstract Object executeGetter(Object object);

}

 

 

전체 코드는 다음의 레포지토리에서 참고할 수 있으며, javax.tools.JavaCompiler API를 사용하는 방법은 이 문서의 2페이지 또는 이 문서에서 참고할 수 있다.

javax.tools.JavaCompiler 외에도 유사한 접근 방식으로 ASM 또는 CGLIB를 사용할 수 있지만, 성능 결과는 다를 수 있다.

Benchmark           Mode  Cnt  Score   Error  Units
===================================================
DirectAccess        avgt   60  2.590 ± 0.014  ns/op
JavaCompiler        avgt   60  2.726 ± 0.026  ns/op

 

 

하지만 그럼에도 불구하고 이러한 방식을 도입하면 전반적인 성능 향상을 이뤄낼 수 있었다. 하지만 런타임에 코드를 생성하는 방식의 단점으로 특히 생성된 코드가 대량으로 컴파일되지 않는 경우 눈에 띄는 부트스트랩 비용이 발생한다는 점이 있다.

 

 

 

LambdaMetafactory 방식

non-static 메서드에서 LambdaMetafactory를 동작시키는 것은 (문서와 StackOverflow 질문이 부족해서) 어려웠지만, 작동한다.

public final class MyAccessor {

    private final Function getterFunction;

    public MyAccessor() {
        MethodHandles.Lookup lookup = MethodHandles.lookup();
        CallSite site = LambdaMetafactory.metafactory(lookup,
                "apply",
                MethodType.methodType(Function.class),
                MethodType.methodType(Object.class, Object.class),
                lookup.findVirtual(Person.class, "getName", MethodType.methodType(String.class)),
                MethodType.methodType(String.class, Person.class));
        getterFunction = (Function) site.getTarget().invokeExact();
    }

    public Object executeGetter(Object bean) {
        return getterFunction.apply(bean);
    }

}

 

 

그리고 실험 결과에 따르면 LambdaMetafactory는 직접 접근하는 방식 만큼이나 빠르다. 해당 방식은 직접 접근 방식보다 33% 정도 느리며, 리플렉션보다 훨씬 빠르다.

Benchmark                    Mode  Cnt        Score        Error  Units
=======================================================================
Reflection Bootstrap         avgt   60      268.510 ±     25.271  ns/op //    0.3µs/op
MethodHandle Bootstrap       avgt   60     1519.177 ±     46.644  ns/op //    1.5µs/op
JavaCompiler Bootstrap       avgt   60  4814526.314 ± 503770.574  ns/op // 4814.5µs/op
LambdaMetafactory Bootstrap  avgt   60    38904.287 ±   1330.080  ns/op //   39.9µs/op

 

 

 

Bootstrap 비용

초당 수천 개의 인스턴스에서 Getter를 검색하는 경우가 빈번하기 때문에 런타임 비용이 가장 중요하다. 그러나 부트스트랩 비용도 중요한데, 이는 반영하려는 모든 클래스의 모든 게터(예: Person.getName(), Person.getAddress(), ...)에 대해 MyAccessor를 만들어야 하기 때문입니다.

실험 결과를 보면 Reflection과 MethodHandle은 무시할 수 있는 부트스트랩 비용을 가지고 있는 반면, LambdaMetafactory는 괜찮은 것을 확인할 수 있다.

Benchmark           Mode  Cnt  Score   Error  Units
===================================================
DirectAccess        avgt   60  2.590 ± 0.014  ns/op
Reflection          avgt   60  5.275 ± 0.053  ns/op
LambdaMetafactory   avgt   60  3.453 ± 0.034  ns/op

 

 

 

[ 결론 ]

결국 Reflection과 MethodHandle은 OpenJDK 8에서 직접 액세스보다 두 배나 느렸고, 코드 생성 방식은 직접 액세스만큼 빠르지만 번거로웠다. 하지만 람다 메타팩토리는 직접 액세스만큼 빠름을 확인할 수 있었다.

Benchmark           Mode  Cnt  Score   Error  Units
===================================================
DirectAccess        avgt   60  2.590 ± 0.014  ns/op
Reflection          avgt   60  5.275 ± 0.053  ns/op // 104% slower
MethodHandle        avgt   60  6.100 ± 0.079  ns/op // 136% slower
StaticMethodHandle  avgt   60  2.635 ± 0.027  ns/op //   2% slower
JavaCompiler        avgt   60  2.726 ± 0.026  ns/op //   5% slower
LambdaMetafactory   avgt   60  3.453 ± 0.034  ns/op //  33% slower

 

 

 

 

위의 내용은 해외의 다음 포스팅을 변역 및 정리한 내용입니다.

 

 

 

참고 자료

 

 

반응형
댓글
반응형
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
TAG more
«   2024/05   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
글 보관함