[Java] JIT 컴파일러가 대체 뭔데?
안녕하세요
오늘은 Java 개발자라면 많이 들어보셨을 JIT 컴파일러에 대해서 소개하려고 합니다.
먼저 Java 프로그램이 실행되는 과정을 설명하겠습니다.
Java 코드를 자바 컴파일러가 바이트코드로 컴파일합니다. 그 결과물은 클래스파일입니다.
그리고 JVM이 실행을 하면서 해당 바이트코드를 한 줄씩 읽으면서 기계어로 해석(interpret)합니다.
JIT 컴파일러는 자주 호출되는 메소드나 자주 실행되는 코드 블럭을 미리 기계어로 번역하여 캐싱을 해둡니다.
그리고 이 자주 실행되는 부분을 'HotSpot'이라고 합니다.
그럼 JIT 컴파일은 언제 수행될까요?
컴파일 타임에 수행될까요? 런타임에 수행될까요?
런타임에 실행됩니다.
보통 '컴파일'이라고 하면 말 그대로 컴파일 타임에 수행이 됩니다.
하지만 JIT 컴파일러는 런타임에 수행됩니다.
왜 런타임에 수행하는 것일까요?
그 전에 컴파일 방식과 인터프리터 방식의 장단점에 대해서 살펴보겠습니다.
컴파일 방식의 단점
컴파일 방식의 단점은 시작 시간이 오래 걸린다는 것입니다. 프로그램의 성능은 좋아지겠지만 시작 시간이 너무 오래 걸립니다.
실행을 하려면 컴파일(기계어로의 컴파일) 과정을 거쳐야 합니다. 개발자가 코드를 한 줄만 수정해도 다시 컴파일을 해야 하기 때문에 프로그램이 시작 시간이 느리다는 단점이 있습니다.
('개발자가 코드를 한 줄만 수정해도 자바 컴파일러의 컴파일(javac)은 해야 하는데 그럼 이거는?'이라고 생각하실 수 있습니다. 하지만 Java 코드를 바이트코드로 컴파일하는 것은 아주 빠르게 수행됩니다. 또한 자바 컴파일러는 수정된 부분의 코드만 다시 컴파일을 하고 기존 코드는 재사용하기 때문에 일반적인 컴파일(기계어로의 컴파일)보다 훨씬 빠릅니다.)
컴파일 방식의 장점
하지만 컴파일을 한 번만 하고 실행을 많이 한다면 컴파일 방식이 유리할 것입니다.
예를 들어, 어떤 프로그램을 CD에 담아서 대량 생산을 한다면 컴파일 방식이 유리할 것입니다.
한 번만 컴파일 해놓으면 그 실행파일을 CD에 담기만 하면 되기 때문입니다. 해당 프로그램은 실행 시간 중에 기계어로 번역하는 작업을 하지 않고 이미 번역되어 있는 기계어를 바로 실행하기 때문에, 컴파일을 할 때는 오래 걸려서 답답할 수도 있겠지만 사용자는 아주 빠른 성능의 프로그램을 사용할 수 있을 것입니다.
즉, 컴파일 방식의 장점은 실행 속도가 빠르다는 것입니다.
이러한 장점 때문에 대규모 계산이 필요한 고성능 컴퓨팅 분야에서는 여전히 Fortran과 C언어를 사용하는 것입니다.
(참고로 Fortran은 최초의 프로그래밍 언어입니다😉)
그렇다면 인터프리터 방식은 어떨까요?
인터프리터 방식은 실행하기 전에 전체 코드를 기계어로 번역하는 작업(컴파일)을 하지 않아도 되므로 시작 시간은 컴파일 방식에 비해서 빠를 것입니다. 하지만 실행 중에 코드를 기계어로 번역하는 작업을 해야 하기 때문에 실행 속도는 컴파일 방식보다 느릴 것입니다.
예를 들어서 설명하겠습니다.
예를 들어 A라는 메소드가 있습니다.
컴파일 방식일 때, A 메소드는 컴파일하는데 1초가 걸리고 실행하는데 1초가 걸린다고 합시다.
인터프리트 방식일 때, A 메소드는 인터프리터가 한 줄씩 기계어로 해석했을 때 총 해석시간이 1초이며 한 줄씩 실행할 때 총 실행 시간이 1초가 걸린다고 합시다.
(사실 이렇지 않습니다. 컴파일은 코드 전체를 번역하면서 최적화 작업을 진행하기 때문에 컴파일러가 기계어로 번역을 하는 시간은 인터프리터가 기계어로 한 줄씩 해석하는 총 시간보다 오래 걸릴 확률이 높고, 최적화가 되었기 때문에 인터프리터가 실행 중에 기계어로 해석하는 시간을 제외하더라도 실행시간은 인터프리트 방식보다 컴파일 방식이 더 빠를 확률이 높습니다. 이해를 돕기 위해, 계산을 쉽게 하기 위해 1초로 정한 점 양해 바랍니다.)
A 메소드가 딱 1번 호출된다면
컴파일 방식 | 인터프리트 방식 |
기계어로 번역 (1초) | 기계어로 해석 (총 1초) |
실행 (1초) | 실행 (1초) |
총 2초 | 총 2초 |
컴파일 방식이든 인터프리트 방식이든 총 시간은 2초로 똑같습니다.
A 메소드가 1000번 호출된다면
컴파일 방식 | 인터프리트 방식 |
기계어로 번역 (1초) | 기계어로 해석 (총 1초) |
실행 (1초) | 실행 (1초) |
실행 (1초) | 기계어로 해석 (총 1초) |
실행 (1초) | 실행 (1초) |
실행 (1초) | 기계어로 해석 (총 1초) |
실행 (1초) | 실행 (1초) |
실행 (1초) | 기계어로 해석 (총 1초) |
실행 (1초) | 실행 (1초) |
실행 (1초) | 기계어로 해석 (총 1초) |
실행 (1초) | 실행 (1초) |
실행 (1초) | 기계어로 해석 (총 1초) |
. . . |
. . . |
총 1001초 | 총 2000초 |
실행 중에 인터프리트 방식으로 해석을 하면 해당 코드를 기계어로 해석하는 똑같은 작업을 1000번 하는 것입니다.
따라서 컴파일 방식은 1001초, 인터프리트 방식은 2000초가 걸릴 것입니다.
그리고 이 차이는 A 메소드가 많이 호출될수록 점점 커질 것입니다.
따라서 자주 실행되는 코드를 미리 기계어로 컴파일 해두면 실행시간을 훨씬 줄일 수 있을 것입니다.
그래서 JIT 컴파일러가 런타임에 동작하는 이유가 뭔데?
이러한 단점을 극복하기 위해서 Java 1.3 HotSpot VM부터 JIT 컴파일러를 도입하여 자주 실행되는 메소드는 실행 중에 기계어로 번역을 하는 방법을 채택합니다. 이 방법으로는 시작 시간은 여전히 빠르면서, 실행 중에 자주 실행되는 코드를 기계어로 번역을 하여 인터프리터 방식의 실행 속도가 느리다는 단점을 어느 정도 극복할 수 있습니다.
JIT 컴파일러가 런타임에 실행됨으로써 얻을 수 있는 장점은 이 뿐만이 아닙니다.
아래의 코드를 봅시다.
for (int i = 0; i < 1000; i++) {
// 코드 생략
}
위의 반복문을 보면 해당 반복문이 1000번 실행될 것이라는 것을 컴파일 시점에 알 수 있습니다.
int count = scanner.nextInt(); // 사용자의 입력을 받음
for (int i = 0; i < count; i++) {
// 코드 생략
}
하지만 이 코드는 반복문이 몇 번 실행될지 예측을 할 수 없습니다.
사용자의 입력값에 따라 반복문의 실행 횟수가 달라지기 때문입니다.
또한 서버 애플리케이션의 경우, 클라이언트가 어떤 API를 많이 호출하는지에 따라 특정 메소드의 실행 횟수가 달라지기 때문에 컴파일 시점에 더욱 더 예측을 하기가 어렵습니다.
하지만 런타임에는 이러한 정보들을 알 수 있습니다.
이렇게 런타임에 컴파일을 하게 되면 동적 최적화를 할 수 있습니다. 컴파일 타임에는 알 수 없었던 정보들을 알 수 있기 때문에 프로그램의 실행 패턴 등 더 많은 정보를 기반으로 최적화를 할 수 있다는 장점이 있습니다.
따라서 자주 실행되는 코드를 미리 기계어로 컴파일 해두면 실행시간을 훨씬 줄일 수 있을 것입니다.
하지만 이 메소드가 자주 호출되는지 어떻게 아냐고!!
여기에서 JIT 컴파일러가 런타임에 수행됨으로써 얻을 수 있는 장점이 드러납니다.
그러면 특정 메소드가 어느 정도로 자주 호출되어야 JIT 컴파일러가 컴파일을 하게 되는 것일까요?
메소드가 몇 번 호출되어야 JIT 컴파일러가 컴파일을 할지 그 임계값은 -XX:CompileThreshold 옵션으로 지정할 수 있습니다.
만약 Test.class라는 클래스파일을 실행시킬 때, 메소드가 5000번 이상 호출되었을 때 JIT 컴파일을 하고 싶다면
java -XX:CompileThreshold=5000 Test
이렇게 해주면 됩니다.
Java 8 이상의 HotSpot JVM에서 CompileThreshold 옵션의 기본값은 10,000입니다.
따라서 아무런 설정을 해주지 않는다면 특정 메소드가 10,000번 호출되었을 때 JIT 컴파일러가 기계어로 번역을 해서 따로 캐싱해두는 것입니다.
위에서 말한 것처럼 JIT 컴파일러가 런타임에 동작하기 때문에 얻을 수 있는 장점이 많이 있습니다. 하지만 단점도 있습니다. JIT 컴파일을 한 후에는 성능이 빨라지겠지만 컴파일을 런타임에 하기 때문에 JIT 컴파일러가 컴파일을 하는 동안에는 응답시간이 지연될 수도 있습니다. 따라서 컴파일을 빠르게 하는 것도 중요합니다.
최적화를 잘 하면 컴파일 후의 코드는 성능이 매우 좋아지겠지만 컴파일 시간이 오래걸립니다.
최적화를 엄청 잘 하지는 않으면 컴파일 시간은 덜 걸려서 실행 중에 지연 시간은 덜 발생하겠지만 컴파일 후의 코드가 성능이 그렇게 좋아지지는 않을 것입니다.
지연 시간도 줄이고 최적화도 잘하면 매우 좋겠지만 하나를 개선하려면 하나를 포기해야 합니다.
JVM은 두 가지의 장점을 최대한 살릴 수 있도록 Tiered 컴파일이라는 방식을 채택합니다.
Tiered 컴파일러
Tiered 컴파일러는 한마디로 다단계 컴파일러라고 할 수 있습니다.
최적화가 아주 잘 된 코드를 '짱 좋은 코드', 그럭 저럭 최적화가 되어 컴파일된 코드를 '보통 코드'라고 하겠습니다.
(여기에서 '코드'는 기계어(machine code)를 말합니다)
특정 메소드가 CompileThreshold 만큼 호출되어 JIT 컴파일러가 실행될 때 JIT 컴파일러는 바로 짱 좋은 코드로 컴파일하지 않습니다. 일단은 보통 코드로 컴파일합니다. 그러면 짱 좋은 코드로 컴파일 하는 것보다는 실행 중에 지연 시간이 덜 발생하겠죠?
그러다가 그 메소드가 더 많이 호출되어 일정 횟수를 초과하면 그 때 짱 좋은 코드로 다시 최적화를 하는 것입니다.
바이트코드에서 바로 짱 좋은 코드로 컴파일 하는 것보다 보통 코드에서 짱 좋은 코드로 최적화 하는 것이 시간이 훨씬 덜 소요됩니다.
이렇게 하면 지연시간을 최소화하면서 많이 호출되는 코드를 최적화가 아주 잘 된 코드로 컴파일 할 수 있는 것입니다.
Tiered 컴파일러에서 보통 코드로 컴파일하는 컴파일러를 C1, 짱 좋은 코드로 컴파일하는 컴파일러를 C2라고 합니다.
Java 9 이전에는 서버 컴파일러, 클라이언트 컴파일러라는 개념이 있었습니다.
Tiered 컴파일러로 따지면 보통 코드로 컴파일하는 C1 컴파일러가 클라이언트 컴파일러, 짱 좋은 코드로 컴파일하는 C2 컴파일러가 서버 컴파일러입니다.
실행을 할 때 서버 컴파일러 또는 클라이언트 컴파일러를 지정할 수 있었습니다.
하지만 Java 9부터 Tiered 컴파일러가 기본 옵션이 되었고 명시적으로 서버 컴파일러만을, 또는 클라이언트 컴파일러만을 사용할 수 없습니다.
(-XX:TieredCompilation 옵션으로 Tiered 컴파일러를 비활성화하여 C2 컴파일러(서버 컴파일러)만을 사용하도록 할 수는 있습니다.)
Code Cache
그러면 JIT 컴파일러가 이렇게 컴파일한 기계어를 어디에 저장할까요?
JVM 안의 Code Cache라는 곳에 저장됩니다.
Code Cache의 크기는 -XX:ReservedCodeCacheSize 옵션을 통해 지정할 수 있습니다.
Code Cache의 크기를 256MB로 설정하고 싶다면 -XX:ReservedCodeCacheSize=256m이라고 하면 됩니다.
만약에 코드 캐시 영역이 꽉 차면 더 이상 JIT 컴파일러가 동작하지 않습니다. 컴파일을 하더라도 컴파일된 기계어를 저장할 공간이 없기 때문입니다.
따라서 Code Cache 영역의 크기를 적절히 설정하는 것이 중요합니다.
그러면 GC가 돌 때 Stop-the-world가 발생하는 것처럼 JIT 컴파일러가 실행될 때 Stop-the-world가 발생하지 않을까요?
그렇지 않습니다. GC가 동작할 때는 살아있는 객체를 판별하기 위해서는 일단 애플리케이션의 실행을 멈춰야 합니다.
인구 조사를 예로 들어봅시다.
예를 들어 총 20층이고 각 층에는 10개의 가구가 있는 아파트가 있습니다.
인구 조사 담당자는 1층부터 20층까지 순차적으로 각 집을 돌면서 인구 조사를 합니다.
그런데 1층을 이미 조사하고 2층을 조사하고 있는 중에 1층에서 아기가 태어난다면 정확한 인구 조사를 못 하겠죠?
그렇기 때문에 GC가 돌 때는 애플리케이션의 실행을 멈추고 지금 살아있는 객체를 알아내는 것이고,
그래서 Stop-the-world가 발생하는 것입니다.
하지만 JIT 컴파일러는 상황이 다릅니다. 굳이 애플리케이션 실행을 중단할 필요가 없습니다.
따라서 백그라운드 스레드로 애플리케이션 스레드와 동시에 실행됩니다.
https://www.oracle.com/java/technologies/javase/vmoptions-jsp.html
https://www.geeksforgeeks.org/just-in-time-compiler/
https://www.baeldung.com/jvm-tiered-compilation
https://www.baeldung.com/jvm-code-cache
https://www.oreilly.com/library/view/java-performance-2nd/9781492056102/ch04.html