인터프리터와 JIT 컴파일러

블러거·2026년 2월 4일

JVM

목록 보기
20/26

JVM 이 클래스 파일을 컴퓨터에서 실행하기 위해서는 네이티브 코드로 해석해야 한다.
JVM 명세는 바이트 코드의 해석 및 실행의 구현을 명세하지 않는다.

따라서 JVM 들은 각자의 방식으로 이를 구현할 수 있는데, 가장 많이 사용되는 HotSpot 에서는 세가지 방식으로 사용할 수 있다.

  1. 인터프리터
  2. JIT 컴파일러
  3. AOT 컴파일러

인터프리터는 클래스 파일의 내용을 그대로 해석 실행하는것에 비해
JIT, AOT 컴파일러는 바이트코드에서 네이티브 코드로 해석하는 과정에서 많은 최적화가 이뤄진다.

인터프리터

  • Hotspot 이 바이트 코드를 실행하는 기본적인 방법
  • 단순히 바이트 코드를 해석하여 실행
  • 사용 case
    • 빨리 끝나는 프로그램을 빨리 실행해야할 때
      • 따로의 최적화 과정이 없기에 JIT 에 비해 시작 시간이 빠르다.
    • 가용 메모리가 적을 때
      • JIT 컴파일러는 최적화 과정에 필요한 코드 캐시 등 메모리를 사용한다.
    • JIT 컴파일의 탈출구
      • JIT 컴파일은 속도의 이유로 (코드가 전체적으로는 어떤 형태일 것이다라는)가정에 의해 컴파일한다.
      • 새로운 클래스가 로드되고 하면서 코드 구조(상속 구조 등등)이 바뀌며 최적화 가정이 깨져 최적화를 취소해야할 경우, 인터프리터로 실행하게 된다.

JIT 컴파일러

Hotspot 코드를 발견하면 그때그때 컴파일하여 실행(Just-In-Time)

Hotspot 의 계층형 컴파일


https://www.infoq.cn/article/java-10-jit-compiler-graal

HotSpot JVM 의 JIT 컴파일러는 C1, C2 가 있다.
C1, C2, 인터프리터는 각자 다른 최적화 레벨을 가지고 있다.
실행중인 바이트 코드를 시스템 상태와 호출 횟수 등에 따라 최적화 레벨을 달리하여 실행하는데, 이를 계층형 컴파일 이라 한다.

  1. 인터프리터 : 최초 HotSpot 구동시 인터프리터 모드로 실행
    • 인터프리터로 실행 중 자주 실행되는 HotSpot 코드는 C1 으로 컴파일 실행
  2. C1(클라이언트 컴파일러) : C2 보다 빠른 컴파일, 간단한 최적화.
    • C1 으로 실행될 때에는 호출 횟수, 컴파일 결과등을 모니터링할 수 있다. 이는 더 높은 최적화 레벨인 C2 컴파일러로 컴파일할지를 판단하는 기준이 된다.
      • no-profiling : 빠르게 끝나는(최적화가 딱히 필요없는) 메서드의 경우 사용할 수 있다.
      • limited-profiling : C2 가 이미 바쁠때에는 일부 제한적인 프로파일링을 하여 C2 로 넘어가는 코드를 줄일 수 있다.
      • full-profiling : 기본적으로는 할 수 있는 모든 프로파일링을 진행하여 필요한 경우 C2 로 보낸다.
  3. C2(서버 컴파일러, Opto) : C1 보다 느린 컴파일, 공격적인 최적화(높은 런타임 성능)
    • C1 컴파일러가 바쁜 경우 일부 인터프리터에서 C2 컴파일러로 직행할 수 있다.

위 그림과 같이 보자.

컴파일러 선택 관련 옵션들

  • -XX:+TieredCompilation : 계층형 컴파일 활성화(JDK 7 부터 기본값). 인터프리터, C1, C2 모두 사용
  • -XX:-TieredCompilation : 계층형 컴파일 비활성화. 인터프리터&C2 사용
    • JDK 8 까지는 -client 로 C2 대신 C1 으로 고정 가능
  • -XX:+TieredCompilation -XX:TieredStopAtLevel=1 : 인터프리터&C1 사용
  • -XInt : 인터프리터만 사용
  • -XComp : JIT 컴파일러만 사용. 시작 시간이 오래 걸릴 수 있다.

java -version
java -XInt -version
java -XComp -version

를 실행시켜 보자. 각각 Mixed mode, interpreted mode, compiled mode 를 볼 수 있다.

디버그를 위해 JIT 컴파일러 동작을 배제하고 싶은 경우 -XInt 로 인터프리터만 쓸 수 있고,
AWS lambda 같이 빨리 끝나고 가벼운 애플리케이션 실행의 경우 C2 를 배제할 수도 있고,
C2 를 대체한 Graal Compiler 를 사용하려는 경우 C1 을 배제할 수도 있다.

JIT 컴파일 대상 코드

JIT 컴파일러의 컴파일 대상이 되는 코드 유형은 두가지가 있다.

  1. 여러 번 호출되는 메서드
    • 표준 JIT 컴파일 방식(메서드 전체를 컴파일)
  2. 여러 번 실행되는 순환문의 본문(블록)
    • 촉발은 순환문의 블록이지만, 컴파일 자체는 메서드 전체를 컴파일
    • 온스택 치환(OSR) : 메서드 내부의 순환문 실행 도중 메서드 자체가 컴파일 되며 스택의 메서드가 대체되기 때문에 온스택 치환이라고도 한다.

핫스팟 코드 탐지 방식(호출 횟수 카운팅 방식)

  • 샘플 기반 : 스레드들의 호출 상단 스택을 샘플링.
    • 쉽고 효율적임
    • 스레드 스택을 보면 메서드 호출관계를 쉽게 파악 가능
    • 정확한 횟수 측정 불가
    • 스레드가 블로킹되면 탐지 불가
    • J9 jvm 에서 사용하는 방식
  • 카운터 기반 : 메서드와 코드 블록에 카운터 설정
    • 정확한 실행 횟수 기록 가능
    • 메서드 각각 카운터 설정해야함. 어려움
    • 메서드 호출 관계를 직접 얻을 수 없음
    • 핫스팟 jvm 에서 사용하는 방식

C1 컴파일러로 넘어가는 호출 횟수 임계점(핫스팟 기준)

이 기준은 인터프리터 -> C1 이다.
C1 -> C2 는 복잡한 모니터링 결과에 의해 전환된다.

HotSpot 은 카운터를 기반으로 핫스팟 코드를 감지한다.
JIT 컴파일 대상 코드 유형이 2가지이므로, 카운터도 2가지가 존재한다.

메서드 호출 카운터

  • 클라이언트 모드(Int -> C1) : 1500회
  • 서버 모드(C1 -> C2) : 10000회
  • -XX:CompileThreshold 로 설정 가능
  • 시간당 호출 회수를 측정한다.
  • 감쇠 카운터(counter decay method invocation) : 단위 시간(반감기. counter half life time)안에 임계값에 도달하지 못한다면 카운터를 절반으로 줄임.
    • -XX:-UseCounterDecay 감쇠 카운터 비활성화. 호출 카운터를 감쇠하지 않고 절대값으로 측정
    • -XX:CounterHalfLifeTime 반감기를 초 단위로 설정 가능

back edge 카운터(순환문 블록 카운터)

  • 클라이언트 모드(Int -> C1) : 13995회
    • (메서드 호출 카운터 임계값 * OSR 비율)/100
    • 메서드 호출 카운터 임계값(-XX:CompileThreshold) : 디폴트 1500
    • OSR 비율(-XX:OnstackReplacePercentage) : 디폴트 933
  • 서버 모드(C1 -> C2) : 10700회
    • (메서드 호출 카운터 임계값 * OSR 비율 - 인프라 모니터링 비율)/100
    • 메서드 호출 카운터 임계값(-XX:CompileThreshold) : 디폴트 10000
    • OSR 비율(-XX:OnstackReplacePercentage) : 디폴트 140
    • 인터프리터 모니터링 비율(-XX:InterpreterProfilePercentage) : 디폴트 33
  • 감쇠 없이 절대 시행 횟수 측정

OSR 의 특징 - back edge 카운터는 값 조정

위 다이어그램 두개를 비교해 보면 back edge 카운터에 의한 OSR 컴파일 요청은 메서드 호출 카운터와 다른점이 있다.

back edge 카운터는 C1 컴파일이 요청된 후에 back edge 카운터 값을 줄인다는 것이다.

둘 다 공통적으로 임계값을 넘겼을 때 컴파일이 요청되었으면 더 이상 요청하지 않아야 할 것이다.
단 OSR 컴파일의 경우 반복문 안에서 그 동작이 발생하기 때문에, 컴파일이 요청되었는가를 체크하는 일 자체가 메서드 호출 카운터에 비해 훨씬 부담되는 일이다.

따라서 back edge 카운터에 의한 OSR 컴파일 요청은 호출 횟수가 임계값을 넘은 경우 호출 횟수를 일정량 줄인다. 다시 임계치에 오르는 동안 요청이 되었는지 체크를 하지 않을 수 있다.

컴파일 과정

컴파일은 비동기

컴파일은 기본적으로 메서드 컴파일, OSR 컴파일 둘 다 백그라운드 스레드에서 비동기로 진행.
-XX:-BackgroundCompilation 옵션으로 동기적으로 컴파일하도록 변경 가능. 이러면 컴파일 진행중 일시 정지됨.

C1 컴파일 과정


https://nothingcosmos.github.io/OpenJDKOverview/src/overview.html

C1 컴파일에서 바이트 코드는 다음의 과정을 거쳐 네이티브 머신 코드로 변환된다.

BytesCode -> HIR -> LIR -> Native Code(Machine Code)

위 그림을 보면 컴파일 과정에서 HIR, LIR 각각에 적용되는 최적화들이 다름을 볼 수 있다.

IR (Intermediate Representation)

최적화를 위해 컴파일 과정에서 중간에 변환되는 중간 코드.

  • HIR(High IR) 은 좀 더 바이트 코드와 비슷한 고수준의 중간 언어이며, 코드 레벨의 최적화를 하기에 적합한다.
  • LIR(Low IR) 은 좀 더 기계어와 비슷한 중간 언어이며, 기계어 수준의 최적화를 하기 적합하다.

바이트코드는 네이티브 머신 코드가 되기 전 중간 코드(IR. Intermediate Representation)를 거친다. JIT 컴파일 과정의 최적화는 모두 IR 을 대상으로 이뤄진다.

C2 컴파일 과정

C2 는 C1 컴파일 과정에서 수집된 모니터링 정보를 토대로, 공격적인 예측 최적화 를 적용한다.

예를 들면 다음과 같은 최적화들을 진행한다. C2 컴파일의 컴파일 과정은 상당히 복잡하다.

  • 죽은 코드 제거
  • 순환문 언롤링
  • 순환문 표현식 호이스팅
  • 공통 하위 표현 제거
  • 상수 전파
  • 기본 블록 재정렬

공격적 예측 최적화
확실하지 않지만 모니터링 결과를 토대로 어떠한 상황임을 예측, 가정 한 후 최적화하는 것.
가정을 확실하게 알기 위해서는 비용이 많이 들기에 지금까지의 모니터링 결과로 예측하는 트레이드 오프를 적용한 결과다.

대부분의 상황은 자바가 가상 메서드이기 때문에 발생. 런타임에 리시버가 확정되지 않아 최적화하기 힘듦. 모니터링의 결과로 대부분이 하나의 리시버로 사용됨을 알게 되면, 미래에도 하나의 리시버일 것임을 가정한 후 최적화. 다른 리시버가 들어오면 인터프리터로 실행.

해당 가정이 틀린 경우는 interpreter 모드로 도망가서 일단 실행한 후 다시 컴파일한다.

컴파일 로그로 컴파일 과정 눈으로 보기

  • -XX:+PrintCompilation : 컴파일 로그
  • -XX:+PrintCompilation -XX:+PrintInlining : 인라이닝 포함 컴파일 로그
  • -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly : 컴파일러 어셈블리 코드
  • -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:+PrintOptoAssembly : 좀 더 최종 결과에 가까운 중간 코드 표현(서버 모드 가상 머신용)
  • -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:+PrintLIR : 좀 더 최종 결과에 가까운 중간 코드 표현(클라이언트 모드 가상 머신용)
  • -XX:PrintIdealGraphLevel=2 -XX:PrintIdealGraphFile=ideal.xml : IGV 에서 컴파일러 보기 위함
    • FastDebug, SlowDebug 로 빌드된 JVM 만 가능

-XX:+PrintCompilation 의 정보

예시

// javac Test.java
// java -XX:+PrintCompilation Test
public class Test {
    public static final int NUM = 15000;

    public static int doubleValue(int i) {
        for(int j = 0; j < 100000; j++);  // JIT 코드 최적화 과정 시연용 '빈' 순환문
        return i * 2;
    }

    public static long calcSum() {
        long sum = 0;
        for (int i = 1; i <= 100; i++) {  // doubleValue()를 100번 호출
            sum += doubleValue(i);
        }
        return sum;
    }

    public static void main(String[] args) {
        for (int i = 0; i < NUM; i++) {  // calcSum()을 15,000번 호출
            calcSum();
        }
    }
}

java -XX:+PrintCompilation Test 결과

190  306 %     3       servercompiler.Test::doubleValue @ 2 (18 bytes)
194  307 %     4       servercompiler.Test::doubleValue @ 2 (18 bytes)
194  306 %     3       servercompiler.Test::doubleValue @ 2 (18 bytes)   made not entrant
194  308       4       servercompiler.Test::doubleValue (18 bytes)
195  309       3       servercompiler.Test::calcSum (26 bytes)
195  310 %     4       servercompiler.Test::calcSum @ 4 (26 bytes)
196  311       4       servercompiler.Test::calcSum (26 bytes)
198  309       3       servercompiler.Test::calcSum (26 bytes)   made not entrant
183  303 %     3       servercompiler.Test::doubleValue @ 2 (18 bytes)
184  304       3       servercompiler.Test::doubleValue (18 bytes)
184  305 %     4       servercompiler.Test::doubleValue @ 2 (18 bytes)
185  303 %     3       servercompiler.Test::doubleValue @ 2 (18 bytes)   made not entrant
185  305 %     4       servercompiler.Test::doubleValue @ 2 (18 bytes)   made not entrant
185  306       4       servercompiler.Test::doubleValue (18 bytes)
185  304       3       servercompiler.Test::doubleValue (18 bytes)   made not entrant
185  307 %     4       servercompiler.Test::doubleValue @ 2 (18 bytes)
185  308       3       servercompiler.Test::calcSum (26 bytes)
186  308       3       servercompiler.Test::calcSum (26 bytes)   made not entrant
186  309       3       servercompiler.Test::calcSum (26 bytes)
186  310 %     4       servercompiler.Test::calcSum @ 4 (26 bytes)
187  311       4       servercompiler.Test::calcSum (26 bytes)
188  309       3       servercompiler.Test::calcSum (26 bytes)   made not entrant

IGV

Ideal Graph Viewer.

JIT 컴파일 과정에서 코드는 Ideal Graph 라는 형태로 표현된다.
IGV 플러그인을 사용하면 이 그래프를 컴파일 단계별로 시각적으로 볼 수 있다. 이를 이용하면 컴파일 과정에서 최적화가 어떻게 되는지 시각적으로 볼 수 있다.

IGV 는 JDK 소스 코드에 이미 포함되어 있다. 다만 제공되는 런타임에는 없기에, 소스코드를 받아서 직접 빌드하여 실행하도록 한다.

IGV 는 JDK 소스코드에 포함. 빌드하여 사용
$ cd src/utils/IdealGraphVisualizer
$ mvn install
$ chmod u+x igv.sh
$ ./igv.sh


이 그림에서는 빨간부분이 위 테스트 코드 doubleValue() 의 빈 순환문이며, 없어졌다는 뜻이다.

profile
안녕하세요!

0개의 댓글