Published on

[ JAVA ] Just-In-Time (JIT) 컴파일러 이해하기

Authors
  • avatar
    Name
    유사공대생
    Twitter

JIT 컴파일러란?

image

기존의 자바는 인터프리터 방식으로 명령어를 하나씩 실행하게끔 이루어져 있어 실행 속도가 느렸다. 하지만 하드웨어가 발전하면서 자바 컴파일러도 JIT 컴파일러 방식으로 개선되어 속도적인 측면에서 상당한 개선을 이루었다. JVM은 JIT(Just In Time) 컴파일러라고 한다. 또한, JIT 컴파일러는 같은 코드를 매번 해석하지 않고, 실행할 때 컴파일을 하면서 해당 코드를 캐싱해버린다. 이후에는 바뀐 부분만 컴파일하고, 나머지는 캐싱된 코드를 사용한다. 이렇게 JIT 컴파일러는 운영체제에 맞게 바이트 실행 코드로 한 번에 변환하여 실행하기 때문에 이전의 자바 해석기(Java interpreter) 방식보다 성능이 10배 ~ 20배 정도 더 좋다.

개요

자바의 핵심 기능 중 하나인 "한 번 작성하고, 어디에서나 실행"의 핵심은 바로 바이트코드이다. 하지만 이러한 바이트코드를 적절한 네이티브 명령어로 변환하는 과정은 애플리케이션의 실행 속도에 막대한 영향을 미친다.

바이트코드는 해석되거나 네이티브 코드로 컴파일될 수 있다. 또는 바이트코드 명세를 따르는 프로세서에서 직접 실행될 수 있다. 그러나 바이트코드를 해석하는 것은 Java Virtual Machine (JVM)의 표준 구현 방식이다. 이 경우 프로그램 실행이 느려지게 됩다.

성능을 향상시키기 위해서는, JIT 컴파일러가 런타임 시 JVM과 상호 작용하여 적절한 바이트코드 시퀀스를 네이티브 기계 코드로 컴파일한다. JIT 컴파일러를 사용하면 하드웨어가 네이티브 코드를 실행할 수 있기 때문에, 동일한 바이트코드 시퀀스를 반복적으로 JVM이 해석하는 것보다 실행 속도가 빨라진다.

그러나 JIT 컴파일러가 바이트코드를 컴파일하여 네이티브 코드로 변환하는 데 걸리는 시간은 실행 시간에 추가된다. 또한 JIT에 의해 컴파일되는 메소드가 자주 호출되지 않으면 해석기보다 바이트 코드 실행에 더 많은 실행 시간이 소요될 수 있다.

JIT 컴파일러는 바이트코드를 네이티브 코드로 컴파일하는 과정에서 여러 가지 최적화를 수행한다. 이러한 최적화에는 메모리 캐싱, 레지스터 할당, 상수 연산의 최적화 등이 포함된다. 그러나 JIT 컴파일러는 프로그램의 제한된 시야 때문에 정적 컴파일러에서 수행하는 모든 최적화를 수행할 수 없다.

JIT 컴파일러는 자주 실행되는 코드에 대해 최적화를 수행하는 것이 목표이다. 이는 프로그램 실행 시간을 줄이는 데 도움이 된다. 그러나 드물게 실행되는 코드에 대해서는 최적화 작업을 수행하지 않기 때문에 JIT 컴파일러를 사용할 때 주의해야 한다.

작동 방식

Just-In-Time(JIT) 컴파일러는 Java Runtime Invironment의 구성 요소 중 하나로, Java 어플리케이션의 실행 시간 성능을 향상시킨다. Java 프로그램은 플랫폼 중립적인 바이트코드를 포함하는 클래스로 구성되어 있으며, 이는 다양한 컴퓨터 아키텍처에서 JVM에서 해석될 수 있다. 실행 시간에 JVM은 클래스 파일을 로드하고 각각의 바이트코드의 의미를 결정하며, 적절한 계산을 수행한다. 해석 과정에서 추가적인 프로세서와 메모리 사용으로 인해 Java 어플리케이션은 네이티브 어플리케이션보다 느리게 동작한다. JIT 컴파일러는 실행 시간에 바이트 코드를 네이티브 기계 코드로 컴파일하여 Java 프로그램의 성능을 향상시키는데 도움을 준다.

image

JIT 컴파일러는 기본적으로 활성화되어 있으며, Java 메소드가 호출될 때 활성화된다. JIT 컴파일러는 해당 메소드의 바이트 코드를 네이티브 기계 코드로 컴파일하며, 이를 "실행하기 직전"에 컴파일한다. 메소드가 컴파일된 후에는, JVM은 해당 메소드의 컴파일된 코드를 직접 호출하여 해석하지 않는다. 이론적으로는 컴파일이 프로세서 시간과 메모리 사용을 요구하지 않는다면 모든 메소드를 컴파일함으로써 Java 프로그램의 속도를 네이티브 애플리케이션과 유사하게 접근할 수 있을 것이다.

그러나 JIT 컴파일은 프로세서 시간과 메모리 사용을 요구한다. JVM이 처음 시작될 때 수천 개의 메소드가 호출된다. 이러한 모든 메소드를 컴파일하면 시작 기간에 큰 영향을 미칠 수 있으며, 프로그램이 매우 좋은 성능을 발휘하더라도 시작 시간이 지연될 수 있다.

다른 어플리케이션에는 다른 컴파일러

JIT 컴파일러에는 두 가지 유형이 있으며, 애플리케이션을 실행할 때 사용할 컴파일러를 선택하는 것이 종종 필요한 유일한 컴파일러 튜닝이다. 사실, 어떤 컴파일러를 선택할 지 알아야 하는 것은 Java를 설치하기 전에 고려해야 하는 사항이다. 왜냐하면 다른 Java 바이너리에는 다른 컴파일러가 포함되어 있기 때문이다.

Client-side 컴파일러

잘 알려진 최적화 컴파일러는 C1이다. -client JVM 시작 옵션을 통해 활성화되는 컴파일러이다. 이름에서 알 수 있듯이, C1은 클라이언트 측 컴파일러ㄷ. 자원이 적은 클라이언트 측 어플리케이션을 위해 설계되어 있으며, 많은 경우 어플리케이션 시작 시간에 민감하다. C1은 코드 프로파일링을 위해 성능 카운터를 사용하여 간단하고 비침해적인(unintrusivq) 최적화를 가능하게 한다.

Server-side 컴파일러

장기간 실행되는 서버 측 엔터프라이즈 자바 어플리케이션 같은 경우에는 클라이언트 측 컴파일러만으로는 충분하지 않을 수 있다. 대신 C2와 같은 서버 측 컴파일러를 사용할 수 있다. C2는 일반적으로 시작 명령줄에 -server JVM 시작 옵션을 추가하여 활성화된다. 대부분의 서버 측 프로그램은 오랜 시간동안 실행될 것으로 예상되므로 C2를 사용하면 짧은 실행 시간을 가진 경향 클라이언트 어플리케이션보다 더 많은 프로파일링 데이터를 수집 할 수 있다. 따라서 더 고급적인 최적화 기술과 알고리즘을 적용할 수 있다.

Tiered compilation

Tiered compilation은 클라이언트 측과 서버 측 컴파일을 결합한다. Tiered compilation은 JVM에서 클라이언트 및 서버 컴파일러의 이점을 모두 활용한다. 클라이언트 컴파일러는 어플리케이션 시작 중 가장 활동적이며 성능 카운터 임계값으로 트리거된 최적화를 처리한다. 클라이언트 측 컴파일러는 또한 성능 카운터를 삽입하고 더 나은 최적화를 위해 명령어 집합을 준비한다. 이후 서버 측 컴파일러가 처리할 것이다. Tiered compilation은 컴파일러가 저영향 컴파일러 활동 중 데이터를 수집하므로 리소스를 효율적으로 사용하는 프로파일링 방법이다. 이 방법은 해석된 코드 프로파일 카운터만 사용하는 것보다 더 많은 정보를 제공한다.

코드 최적화

메소드가 컴파일되기로 선택되면, JVM은 해당 bytecode를 Just-In-Time(JIT) 컴파일러에게 전달한다. JIT는 해당 메소드를 올바르게 컴파일하기 위해 bytecode의 의미와 구문을 이해해야 한다.

JIT 컴파일러가 메소드를 분석하는 데 도움을 주기 위해, 먼저 해당 bytecode는 트리(Tree)라고 불리는 내부 표현법으로 재구성된다. 이 트리는 bytecode보다 기계 코드와 더 유사한 형태를 가지며, 해당 메소드의 트리에 대한 분석과 최적화가 수행된다. 최종적으로, 이 트리는 네이티브 코드로 변환된다.

JIT 컴파일러는 JIT 컴파일 작업을 수행하기 위해 하나 이상의 컴파일 스레드를 사용할 수 있다. 여러 스레드를 사용하면 Java 애플리케이션의 시작 시간을 더 빠르게 할 수 있다.

실제로는, 여러 JIT 컴파일 스레드는 시스템에서 사용되지 않는 처리 코어가 있는 경우에만 성능 향상을 보인다. 컴파일 스레드의 기본 수는 JVM에 의해 식별되며, 시스템 구성에 따라 달라진다. 만약 결과적으로 스레드 수가 최적이 아니라면, XcompilationThreads 옵션을 사용하여 JVM의 결정을 무시할 수 있다.

이 옵션 사용에 대한 자세한 정보는 JIT 및 AOT 명령행 옵션을 참조!

Inlining

Inlining은 작은 메소드의 trees를 호출하는 메소드의 trees에 병합하거나 "inline"하는 과정입니다. 이렇게 함으로써 빈번하게 실행되는 메소드 호출이 빨라집니다.

로컬 최적화

로컬 최적화는 한 번에 코드 일부분을 분석하고 개선합니다. 많은 로컬 최적화 기법이 전통적인 정적 컴파일러에서 사용되는 것과 같습니다.

제어 흐름 최적화(control flow optimizations)

제어 흐름 최적화는 메소드 내부의 제어 흐름을 분석하여 코드 경로를 개선합니다.

전역 최적화(GLobal optimization)

전역 최적화는 메소드 전체에 대해 작동합니다. 이들은 "비싸다"고 여겨지며 더 많은 컴파일 시간이 필요하지만, 성능을 크게 향상시킬 수 있습니다.

네이티브 코드 생성(Native code generation)

네이티브 코드 생성 과정은 플랫폼 아키텍처에 따라 다릅니다. 일반적으로 이 단계에서 메소드의 trees가 기계 코드 명령어로 변환되며, 아키텍처 특성에 따라 일부 작은 최적화가 수행됩니다.

출처

JIT 컴파일러 이해하기