티스토리 뷰
이번에는 GraalVM이 제공하는 네이티브 이미지(Native Image)에 대해 알아보도록 하겠습니다.
1. GraalVM의 AoT 컴파일러(Ahead-of-Time 컴파일러)
[ GraalVM이 제공하는 새로운 기술 ]
GraalVM은 자바와 다른 JVM 언어들로 작성된 애플리케이션의 실행을 가속화하는 동시에 JavaScript, Python 등과 같은 런타임을 제공하도록 설계된 고성능 JDK이다. GraalVM의 다국어 기능을 사용하면 단일 애플리케이션에서 여러 프로그래밍 언어를 혼합하는 동시에 다른 언어를 호출하는 비용을 제거할 수 있다.
GraalVM은 자바 애플리케이션을 실행할 수 있는 두 가지 방법을 제공한다.
- JIT(just-in-time) 컴파일러를 이용해 런타임 중에 컴파일 및 최적화
- AoT(ahead-of-time) 컴파일러로 미리 컴파일된 네이티브 실행 파일을 실행
JIT(just-in-time) 컴파일러 방식은 앞선 포스팅에서 살펴본대로 기존의 Java 실행 방식과 동일하다.
차이가 있는 부분은 바로 AoT 컴파일러를 이용하는 부분이다. AoT 컴파일러를 이용하면 우리는 C언어와 유사하게 자바 프로그램의 실행 파일을 만들고, 해당 실행 파일만 실행시키면 애플리케이션을 구동할 수 있다. GraalVM은 이를 위해 AOT 컴파일러를 도입한 것이다.
[ AoT 컴파일러란? ]
AoT(ahead-of-time) 컴파일러란 미리 특정 디바이스에 맞는 기계어로 컴파일을 해두는 것이다. 최적화 역시 컴파일하는 과정에서 함께 진행된다. AoT 컴파일러의 결과로 즉시 실행 가능한 네이티브 실행 파일이 만들어진다.
AoT 컴파일러는 JIT 컴파일러와 달리 실행 파일이 구동되면서는 최적화가 되지 않는다. JIT 컴파일러는 애플리케이션이 실행되면서 중간 언어인 바이트 코드를 기게어로 번역한다. 그리고 그 과정에서 프로파일링을 통해 얻어낸 실행 환경 정보를 바탕으로 최적화를 진행한다. 예를 들어 Intel CPU를 사용중이라면 Intel CPU에 맞게 바이트 코드가 기게어로 번역되면서 최적화가 되는 것이다. 이러한 이유로 JIT 컴파일러는 동적 컴파일러라고도 불린다.
하지만 AoT 컴파일러는 이미 특정 환경에 맞게 기계어로 컴파일을 진행하므로 실행 환경 정보 수집이 어렵다. 또한 컴파일 과정에서 무겁고 복잡한 분석 및 최적화를 수행한다. 이러한 이유로 AoT 컴파일러는 정적 컴파일러라고도 불린다.
2. 네이티브 이미지(Native Image)의 특징과 한계
[ 네이티브 이미지란? ]
네이티브 이미지는 자바 코드를 바이너리, 즉 네이티브 실행 파일로 ahead-of-time(미리) 컴파일하는 기술이다. 네이티브 실행 파일에는 런타임에 필요한 코드(애플리케이션 클래스, 표준 라이브러리 클래스, 언어 런타임, JDK에서 정적으로 링크된 네이티브 코드)만 포함된다.
GraalVM은 AoT 컴파일러를 새롭게 추가하였고, 이를 통해 네이티브 이미지를 지원하기 시작하였다. 이제 즉시 실행 가능한 자바 실행 파일(네이티브 자바)을 만들어낼 수 있는 것이고, 우리는 이제 JVM 없이 자바 애플리케이션을 바로 실행할 수 있는 것이다. 자바 없이 실행할 수 있도록, 바이너리 실행 파일에는 간소화된 Substrate VM이 포함된다.
[ 네이티브 이미지의 특징 ]
네이티브 이미지로 생성된 실행 파일에는 다음의 몇 가지 중요한 장점들이 있다.
- JVM에 필요한 리소스의 일부를 사용하므로 실행 비용이 저렴함
- 밀리초 내에 시작됨
- 워밍업 없이 즉각적으로 최고 성능을 제공함
- 빠르고 효율적인 배포를 위해 경량 컨테이너 이미지로 패키징 가능
- 기타 등등
네이티브 실행 파일은 네이티브 이미지 빌더 또는 native-image 도구에 의해 생성되며, 애플리케이션 클래스 및 기타 메타데이터를 처리하여 특정 운영 체제 및 아키텍처를 위한 바이너리를 생성한다. 네이티브 이미지 도구는 아래와 같은 순서대로 작업을 처리한다. 이 프로세스는 자바 코드를 바이트코드로 컴파일하는 것과 명확하게 구분하기 위해 빌드 타임(build time)이라고 한다.
- 정적 분석을 수행하여 애플리케이션이 실행될 때 도달할 수 있는 클래스와 메서드를 결정함
- 클래스와 메서드 및 리소스를 바이너리로 컴파일함
GraalVM을 사용하면 자바 바이트코드를 platform-specific, self-contained 한 네이티브 실행 파일로 컴파일하여 애플리케이션을 더 빠르게 시작하며 설치 공간을 줄일 수 있다. 네이티브 이미지 기능은 기본적으로 제공되지 않아서, GraalVM 업데이터를 통해 쉽게 설치할 수 있다. 참고로 GraalVM은 리눅스와 맥 만을 집중적으로 지원하고 있다.
gu install native-image
[ 네이티브 이미지의 한계 ]
위의 설명들을 보면 네이티브 이미지가 상당히 좋고 뛰어나 보이지만 실제로 테스트를 해보면 예상치 못한 상황을 만나게 된다. 아래는 AoT 컴파일러와 JIT 컴파일러를 사용한 경우에 대한 벤치마크 내용이다.
초기에는 AoT 컴파일러가 훨씬 뛰어난 성능을 보인다. 왜냐하면 AoT 컴파일러는 특정 환경에 맞게 기계어로 번역하면서 최적화까지 진행된 상태이기 때문이다. 하지만 시간이 지나고 요청을 계속 처리하다 보면 JIT 컴파일러가 뛰어난 성능을 보이기 시작한다. 그 이유는 JIT 컴파일러가 애플리케이션을 실행하면서 얻어낸 실행 환경 정보를 바탕으로 최적화를 진행하기 때문이다.
JIT 컴파일러는 오랜 시간에 걸쳐 수많은 최적화 기법을 제공하고 있다. JIT 컴파일러의 최적화가 완료된 상태라면 JIT 컴파일러에 의한 성능은 AoT 컴파일러에 의한 성능을 뛰어넘기도 한다. 즉, JIT 컴파일러는 스태틱 컴파일러보다 느리지 않으며, 네이티브 이미지는 JIT 컴파일러가 느려서 나온 것이 아니다.
즉, 네이티브 이미지는 무조건 빠른 것이 아니라 처음 실행(스타트) 빠른 것이다. 초기에 빠른 실행과 처리에 포커스를 맞춘 것이다. 그래서 전반적으로는 아직 더 많은 최적화 기술의 적용이 필요하다. 심지어 특정 환경에 맞는 컴파일과 최적화를 해야 하므로 네이티브 빌드도 오랜 시간이 소요되는 것도 문제이다.
참고로 네이티브 이미지의 스타트가 빠른 이유 중 하나는 힙 이미지 때문이다. 예를 들어 static 블록이나 클래스 로딩 등을 이미지 형태로 만들어둠으로써, 조금 더 빠른 실행이 가능해진 것이다.
하지만 이로 인한 문제도 있다. 바로 리플렉션과 클래스 로딩, 동적 프록시 등과 같은 동적 기술과 관련된 부분이다. AoT 컴파일러가 정적으로 실행 파일을 생성하다보니, 동적 기술과 관련된 부분에서 문제가 많이 있다. 스프링과 같은 프레임워크는 동적 기술을 극한으로 사용하고 있는데, 이와 관련해 GraalVM과 많은 협력으로 문제를 해결중이다.
[ 네이티브 이미지의 사용 ]
앞서 살펴보았듯 네이티브 이미지의 특징과 장점은 명확하다. AoT 컴파일러와 JIT 컴파일러를 비교하면 다음과 같다.
AoT 컴파일러는 빠른 실행, 적은 용량, 낮은 메모리 사용에 장점이 있다. 이러한 특징을 갖는 네이티브 이미지는 서버리스 아키텍처에 적합할 것이다. 네이티브 이미지 기술이 더욱 발달되고, 더 많은 최적화 기술이 도입되면 얘기가 달라질 수 있겠지만, 아직까지는 서버리스에 가장 적합한 것으로 얘기되고 있다.
결국 JIT 컴파일러와 AoT 컴파일러는 모두 우리에게 필요한 기술이며, 필요한 상황이 다르다고 볼 수 있다. 서버리스처럼 빠른 실행이 필요한 경우에는 AoT 컴파일러를, 오랜 시간 애플리케이션을 유지하는 서버 환경에서는 JIT 컴파일러가 더욱 적합할 것이다.
하지만 이러한 내용은 Java 21을 지원하는 GraalVM부터 다른 양상을 보이는 듯 하다. GraalVM for JDK 21 포스팅에 따르면 이제는 profile-guided된 최적화를 적용하면 JIT보다 일관적으로 뛰어난 성능을 보이고 있다고 한다. 물론 해당 내용은 OracleVM의 네이티브 이미지를 바탕으로 하지만, 중요한 포인트는 이제 JRE JIT을 능가할 수 있다는 점이다.
3. 네이티브 이미지(Native Image)의 메모리와 최적화 기법
아래는 GraalVM의 공식 문서의 내용을 그대로 번역한 내용입니다.
[ 최적화와 성능(Optimizations and Performance) ]
네이티브 이미지는 생성된 바이너리를 더욱 최적화할 수 있는 고급 메커니즘을 제공한다.
- Profile-Guided Optimizations (PGO)는 추가적인 성능 향상과 높은 처리량을 제공할 수 있다.
참고: Optimize a Native Executable with PGO. - 적절한 Garbage Collector(GC)를 선택하고, 가비지 수집 정책을 조정하면 GC 시간을 줄일 수 있다.
참고: Memory Management. - 이미지 빌드 중에 애플리케이션 구성을 로드하면 애플리케이션 시작 속도를 높일 수 있다.
참고: Class Initialization at Image Build Time.
[ 메모리 관리(Memory Management) ]
네이티브 이미지는 실행될 때 Java HotSpot VM에서 실행되는 것이 아니라 GraalVM과 함께 제공되는 런타임 시스템에서 실행된다. 이 런타임에는 필요한 모든 구성요소들이 포함되어 있으며, 그 중 하나가 바로 메모리 관리이다.
네이티브 이미지가 런타임에 할당하는 자바 객체는 “자바 힙(Java Heap)” 영역에 상주한다. 자바 힙은 네이티브 이미지가 시작될 때 생성되며, 네이티브 이미지가 실행되는 동안 크기가 증가하거나 감소할 수 있다. 힙이 가득 차면 가비지 컬렉션이 트리거되어 더 이상 사용되지 않는 객체의 메모리를 회수한다. 그리고 자바 힙을 관리하기 위해 네이티브 이미지는 다양한 가비지 컬렉터(GC, Garbage Collector)를 제공한다.
- Serial GC
- G1 GC
- Epsilon GC
Serial GC
Serial GC는 작은 설치 공간과 자바 힙 사이즈에 최적화되었다. 만약 별 다른 GC 설정이 없다면, Serial GC가 커뮤니티 및 엔터프라이즈 에디션에서 기본으로 사용된다. 네이티브 이미지 빌더에 옵션을 명시할 수도 있다.
# Build a native image that uses the serial GC with default settings
native-image --gc=serial HelloWorld
만약 최대 자바 힙 크기를 지정하지 않으면 Serial GC를 사용하는 네이티브 이미즈의 최대 자바 힙 크기는 실제 메모리 크기의 80%로 설정된다. 참고로 이는 최대값일 뿐이다. 애플리케이션에 따라 실제로 사용되는 자바 힙 메모리의 양은 훨씬 적을 수도 있다.
GC를 수행할 때 추가 메모리 공간이 필요할 수 있다는 것을 잊으면 안된다. 일반적으로는 훨씬 적게 들지만, 최악의 경우 최대 힙 크기의 2배까지 필요로할 수 있다. 따라서 RSS(Resident Sent Size)가 일시적으로 증가할 수 있으며, 이는 메모리 제약이 있는 환경(예를 들면 컨테이너)에서 문제가 될 수 있다. Serial GC에 대한 퍼포먼스 튜닝을 위해서는 공식 문서를 참고하도록 하자.
G1 GC
GraalVM의 엔터프라이즈 에디션(Enterprise Edition)은 Java HotSpot VM의 G1 GC를 기반으로 하는 Garbage-First (G1) garbage collector를 제공한다. 현재 G1 GC는 AMD64용 Linux에서 빌드된 네이티브 이미지에서만 사용할 수 있으며, 이를 활성화하려면 --gc=G1 옵션을 전달해야 한다.
# Build a native image that uses the G1 GC with default settings
native-image --gc=G1 HelloWorld
만약 최대 자바 힙 크기를 지정하지 않으면 G1 GC를 사용하는 네이티브 이미즈의 최대 자바 힙 크기는 실제 메모리 크기의 25%로 설정된다. G1 GC에 대한 퍼포먼스 튜닝을 위해서는 공식 문서를 참고하도록 하자.
[ 클래스 초기화(Class Initialization) ]
일반적인 JVM의 자바 애플리케이션이라면, 클래스는 런타임에 처음 액세스할 때 초기화되어야 한다. 따라서 클래스 초기화는 자바 애플리케이션을 미리(ahead-of-time) 컴파일하는 데 부정적인 영향을 미친다.
- 네이티브 실행 파일의 성능이 크게 저하된다. 필드 또는 메소드를 통해 클래스에 액세스할 때마다 클래스가 이미 초기화되었는지 확인해야 한다. 최적화하지 않으면 성능이 두 배 이상 저하될 수 있다.
- 애플리케이션을 시작하는데 필요한 연산량과 시간이 늘어난다. 예를 들어 간단한 Hello World 애플리케이션의 경우 300개 이상의 클래스를 초기화해야 한다.
클래스 초기화의 부정적인 영향을 줄이기 위해, 네이티브 이미지는 빌드 시에 클래스 초기화를 지원한다. 실행 파일을 빌드할 때 클래스를 초기화하여 런타임 초기화 및 검사를 불필요하게 만들 수 있다. 초기화된 클래스의 모든 정적인 상태는 실행 파일에 저장된다. 빌드 시점에 초기화된 클래스의 정적 필드에 대한 액세스는 애플리케이션에 투명하게 표시되며 런타임에 초기화된 것처럼 작동한다.
이러한 자바 클래스의 초기화를 복잡하게 만드는 여러 가지 정책들이 있는데, 네이티브 이미지는 아래의 두 가지를 통해 이를 해결한다.
GraalVM과 네이티브 이미지를 리서치하게 된 배경에는 급증하는 트래픽에 대응하기 위한 빠른 스타트와 컨테이너 환경 기반의 인프라 구성 등이 있었습니다. 하지만 GraalVM은 완전히 최적화된 JIT 컴파일러보다 성능이 떨어지며, 무엇보다 커뮤니티 버전(Community Edition)의 경우 Serial GC 밖에 사용할 수 없었습니다. 그래서 기존의 JIT 컴파일러를 이용하는 방식과 이를 위한 웜업을 준비하는 방향으로 작업을 진행하게 되었습니다.
관련 포스팅
- Hotspot VM의 한계와 이를 극복하기 위한 GraalVM의 등장
- GraalVM이 제공하는 네이티브 이미지(Native Image)
참고 내용
- https://www.graalvm.org/latest/docs/introduction/
- https://www.graalvm.org/latest/reference-manual/java/compiler/
- https://github.com/oracle/graal/tree/master/compiler
- https://www.graalvm.org/latest/reference-manual/java-on-truffle/
- http://taewan.kim/post/position_of_graalvm/
- https://youtu.be/juTEZZ3v4Ws
- https://www.youtube.com/watch?v=pR5NDkIZBOA
- https://www.baeldung.com/graal-java-jit-compiler
- https://docs.spring.io/spring-boot/docs/current/reference/html/native-image.html
- https://www.graalvm.org/22.1/reference-manual/native-image/Limitations/#reflection
- https://www.infoq.com/presentations/graalvm-performance/
- https://wiki.openjdk.org/display/HotSpot/PerformanceTacticIndex
'Java & Kotlin' 카테고리의 다른 글
[Java] 기존 자바 스레드 모델의 한계와 자바 21의 가상 스레드(Virtual Thread)의 도입 (10) | 2023.09.26 |
---|---|
[Java] Java 21에 추가될 새로운 기능들(Java 21 Features) (22) | 2023.09.19 |
[Java] Hotspot VM의 한계(JIT, Just-In-Time 컴파일러)와 이를 극복하기 위한 GraalVM의 등장 (3) | 2023.07.11 |
[Java] 자바의 컨테이너 환경을 위한 XX:+UseContainerSupport 옵션 (0) | 2023.05.23 |
[Java] JUnit의 진화 과정과 public 접근 제어자 (2) | 2023.03.07 |