개발 일기

[Java] JIT 컴파일러(Just-in-Time Compiler)란? - 컴파일러와 인터프리터의 비교 본문

Back-End/Java

[Java] JIT 컴파일러(Just-in-Time Compiler)란? - 컴파일러와 인터프리터의 비교

개발 일기장 주인 2024. 3. 6. 19:43

처음 특정 언어를 배울때 "Python, JavaScript는 인터프리터 언어이다.", "C, C++ 등은 컴파일 언어이다." 이런식으로 분류하는 경우를 봤었는데 그냥 그렇게 알기만 알았지 이것에 대해 깊게 생각해보진 않았다. 

또한 이전 게시글에서 자바 소스 코드가 JDK의 자바 컴파일러인 javac에 의해 바이트 코드로 변환되고 그 바이트 코드는 인터프리터에 의해 해석되거나 JVM에서 JIT 컴파일러를 통해 기계어로 변환한다고 했었다. 이때 JVM은 바이트 코드를 해석하고 실행하는 인터프리터 역할을 수행하고 JVM 내부의 JIT 컴파일러를 통해서도 바이트 코드를 기계어로 변환한다고 했다. 이렇게 컴파일러와 인터프리터가 어떤 차이가 있는지 자바에서 왜 JIT 컴파일러를 도입했고 JIT 컴파일러에 대해서도 이해해보고자 이 게시글을 쓰게 됐다.

우선 인터프리터와 컴파일러가 각각 무엇인지 어떻게 다른지에 대해서 알아보자

 

컴파일러(Compiler) 와 인터프리터(Interpreter) 비교

컴파일 언어와 인터프리터 언어 비교

컴파일 언어

컴파일러는 소스 코드를 컴파일 타임에 한꺼번에 기계어로 변환하고 이 후에 변환된 기계어를 한번에 실행한다.

컴파일 자체는 오래 걸릴 수 있기 때문에 시작 시간은 매우 느릴 수 있지만 이미 컴파일 된 실행 파일이 있다면 빠른 속도로 실행 가능하며 수정을 할 확률이 낮아 수정으로 인한 컴파일이 드물게 발생한다면 실행 파일을 계속 사용할 수 있어 시작 시간 없이 빠르게 실행할 수 있다.

대신에 OS 이식성이 낮다. 컴파일된 실행 파일은 특정 운영 체제에서 작동하도록 설계되었기 때문에, 다른 운영 체제에서 실행하려면 해당 운영 체제에 맞는 컴파일러로 다시 컴파일해야 한다.

 

인터프리터 언어

인터프리터를 통해 런타임에 소스 코드를 한 줄씩 해석하고 실행한다.

코드를 실행할 때마다 다시 해석해야하기 때문에 실행이 느리다. 그러나 오류를 발견하면 해당 코드 밑으로는 번역 및 실행을 못하기 때문에 오류 발견이 쉽다. 그러나 오류가 코드의 마지막 부분에 있을 경우 그 오류를 발견하기까지 모든 코드를 실행해야만 알 수 있기 때문에 단점으로 작용할 수 있다. 인터프리터 언어는 컴파일 언어와는 반대로 이식성이 좋다.


그렇다면 자바는?

결론부터 말하자면 자바는 컴파일 언어와 인터프리터 언어 둘 다에 속한다고 볼 수 있다.

  1. 컴파일 타임에 자바 소스 코드(.java 파일)는 JDK(Java Development Kit)의 자바 컴파일러인 javac에 의해 바이트 코드(.class 파일)로 변환이 - 바이트 코드는 기계어가 아닌 중간 코드로, JVM(Java Virtual Machine)이 이해할 수 있는 언어
  2. 이 바이트 코드는 런타임에 JVM 상에서 실행될 때 두 가지 방식으로 기계어로 변환됩니다.
    - 자바 인터프리터: JVM은 바이트 코드를 한 줄씩 읽어서 해당하는 기계어로 변환하고 실행.
    - JIT(Just-In-Time) 컴파일러: JVM은 프로그램 실행 중에 자주 사용되는 부분의 바이트 코드를 한번에 기계어로 변환하여 실행 속도를 향상시킵니다.

이렇게 자바는 '컴파일러'와 '인터프리터'를 혼합한 방식을 사용한다.

그렇다면 왜 자바가 JIT 컴파일러 방식을 같이 사용하게 됐을까?


JIT(Just-In-Time) 컴파일러

JIT 컴파일러는 결과적으로 보면 실행 속도를 향상시키기 위해 도입했다.

기존에 자바는 자바 컴파일러를 통해 변환된 중간 코드를 JVM에서 자바 인터프리터를 통해 코드를 해석하고 실행했는데 해당 방법으로 인해 인터프리터 언어의 특징과 같이 실행 속도가 상대적으로 느려진다는 단점이 있었다. 

이것을 보완하기 위해서 자바는 JIT 컴파일러를 도입했다. Java 1.3부터 HotSpot VM을 통해 JIT 컴파일러를 도입됐고 HotSpot VM안에 c1(Client Compiler)과 c2(Server Compiler) 이렇게 두가지 JIT 컴파일러가 포함됐다. 

c1 (Client Compiler)
이 컴파일러는 즉시 실행 속도에 초점을 맞췄다. 최적화 수준은 낮지만, 프로그램을 빠르게 시작할 수 있어서 즉시 실행이 필요한 데스크톱 애플리케이션 등에 적합하다.

c2 (Server Compiler)
이 컴파일러는 코드 최적화에 초점을 맞췄다. 시작 속도는 비교적 느리지만, 한번 "warm-up" 되고 나면 실행 속도가 매우 빨라 장시간 실행되는 서버 애플리케이션 등에 적합하다.

 

JIT 컴파일러가 런타임에 동작하는 이유?

런타임에 컴파일러가 필요할까?

우선은 실행 중에 자주 실행되는 코드를 기계어로 번역하기 위함과 런타임에만 알 수 있는 정보들이 있기 때문이다.

어느 한 블로그에서 본 예시이다.

// 1
for (int i = 0; i < 5000; i++) {
	/*
    	코드 블록
        */
}

// 2
int count = scanner.nextInt(); // 사용자의 입력
for (int i = 0; i < count; i++) {
	/*
    	코드 블록
        */
}

1번 반복문의 경우 해당 방복문이 5000번이 반복된다는 사실을 컴파일 타임시점에 알 수 있지만 2번의 반복문의 경우 사용자의 입력에 따라 반복문이 몇번 반복되는지 알 수 있기 때문에 런타임이 되어야만 알 수 있다.

또한 서버 애플리케이션의 경우, 클라이언트가 어떤 API를 얼마나 많이 호출하는지에 따라 특정 코드 블록의 실행 횟수가 달라지기 때문에 컴파일 타임에 그것을 알 수 있는 방법이 없지만 런타임 환경이라면 알 수 있다.

 

그렇기 때문에 런타임에 컴파일을 하게 되면 동적 최적화가 가능해 진다. 따라서 자주 실행되는 코드를 미리 기계어로 컴파일 해두면 실행시간을 훨씬 줄일 수 있는 것이다.

특정 코드블록이 얼마나 자주 호출되어야 JIT 컴파일러가 컴파일을 하게 되나?

바로 임계값을 지정해줄 수 있다. jitcompile.class라는 파일을 실행시킬때 해당 클래스의 특정 메소드가 3,000번 이상 반복되어야 JIT컴파일러가 컴파일하도록 하려면  아래와 같이하면 된다.

java -XX:CompileThreshold=3000 jitcompile

 

Java 8 이상의 HotSpot JVM에서 CompileThreshold 옵션의 기본값은 10,000이다.

이렇게 지정해준 임계값 이상 호출될 경우 JIT 컴파일러가 기계어로 번역을 해서 따로 캐싱해 둔다.

 

이때 또 무작정 최적화 해서는 안된다!

최적화의 정도를 높게 하면 컴파일 후의 코드 성능이 향상되지만, 컴파일 시간이 길어져 실행 중에 시간 지연이 발생할 수 있다. 그렇기 때문에 최적화의 정도를 높게 하지 않음로써 컴파일 시간은 단축되어 실행 중에 지연 시간이 줄어들지만, 컴파일 후의 코드 성능이 그렇게 좋아지지는 않게 할 수도 있다.

 

이러한 상황을 위해 JVM은 'Tiered 컴파일'이라는 방식을 사용한다.

 

계층형 컴파일(Tiered Compliation)

Tiered 컴파일은 다단계 컴파일 방식으로, 특정 메소드가 일정 횟수 이상 호출되면, 먼저 보통 수준의 최적화를 거친 코드로 컴파일하고, 그 메소드가 더 많이 호출되면 다시 최적화를 깊게 진행하여 성능이 좋은 코드로 컴파일하는 것이다.

 

이 때 보통 코드로 컴파일하는 컴파일러를 위에서 언급한 c1 (Client Compiler), 성능이 좋은 코드로 컴파일하는 컴파일러를 c2 (Server Compiler)라고 한다.

 

Java 9 이후 버전에서는 기본적으로 Tiered 컴파일이 활성화되어 있지만, -XX:-TieredCompilation 옵션을 사용하여 Tiered 컴파일을 비활성화하고 C2 컴파일러(서버 컴파일러)만을 사용하도록 설정할 수 있다.

java -XX:-TieredCompilation ProgramName

 

Code Cache

Code Cache는 JVM이 JIT 컴파일러를 통해 컴파일한 기계어 코드를 저장하는 공간이다.

이 공간의 크기는 -XX:ReservedCodeCacheSize 옵션을 통해 조절할 수 있다.

// Code Cache의 크기를 256MB로 설정
XX:ReservedCodeCacheSize=256m


기계어 코드를 저장하는 Code Cache의 용량이 충분하지 않으면, JIT 컴파일러는 새로운 기계어 코드를 생성하더라도 이를 저장할 공간이 없게 되어 더 이상 작동하지 않는다. 따라서, 적절한 크기의 Code Cache를 설정하는 것은 프로그램의 성능에 큰 영향을 미칠 수 있다.

Code Cache의 크기를 설정할 때는 프로그램의 규모와 복잡성, 그리고 실행 환경의 리소스 등을 고려해야 하는데 크기가 너무 작으면 JIT 컴파일러의 작동을 제한되며, 너무 크면 불필요한 메모리 사용을 가져올 수 있어 적절한 균형을 찾는 것이 중요하다.

그러나 HotSpot VM의 C2 컴파일러가 한계에 직면했다고 한다.
C2 컴파일러는 일단 C++로 작성되어 개발자를 구하기 어렵고, 상당히 오래된 만큼 복잡하며 이러한 이유로 최근 몇 년 동안 개발된 중요한 최적화가 없었을 뿐만 아니라 전문가들 역시 유지보수가 어렵다고 판단하였다.
즉, C2 컴파일러가 이제 End-of-life에 직면한 것이다.
그래서 이에 대한 대안으로 등장한 것이 바로 GraalVM이 등장하게 됐다. 다음번에 GraalVM을 다루는 게시글을 작성해봐야겠다.

 

 

자바가 컴파일러와 인터프리터를 모두 채택한 이유

당연하지만 JIT 컴파일러는 JVM에서 동작하기 때문에 일반적인 컴파일러와 같이 컴파일 타임이 아닌 런타임 환경에서 동작한다.

일반적인 AOT(Ahead-of-Time) 컴파일러의 경우 시작 시간이 크다는 단점이 있지만 자바의 경우 컴파일 타임에 자바 컴파일러를 통해 기계어로 바꾸는 것이 아닌 중간 코드(바이트 코드)로 변환하기 때문에 상대적으로 시작 시간이 단축되며

런타임에서 바이트 코드를 기계어로 변환하는 단계에서도 자바 인터프리터만을 통해 해석하는 것이 아니라 위에서 언급한 JIT (Just-in-Time) 컴파일러도 같이 사용하여 실행시간을 줄일 수 있다.

 ➔ 처음에 자바도 어쨋든 컴파일러를 사용하게 되면 시작 시간이 다른 컴파일 언어들과 다를게 없지 않나? 라는 의문을 가졌는데 이것이 그        것에 대한 답이 될 수 있을 것 같다.