JVM에서 코드를 실행하는 방법
JVM은 바이트 코드(Byte Code)라 불리는 기계어 코드를 실행하는 가상 머신이다. 우리가 Java나 Kotlin으로 코드를 작성하면 코드들은 컴파일 타임에 바이트 코드로 변환되며, 바이트 코드들은 런타임에 기계어로 변환되어 기계어가 JVM 상에서 실행된다.
이때 Java와 Kotlin 코드는 프로그램 실행 시점 전인 컴파일 타임에 바이트 코드로 변환이 모두 완료된다. 하지만, JVM에서 바이트 코드를 실행하기 위해서는 바이트 코드를 기계어로 변환하는 단계를 하나 더 거쳐야 한다.
JVM 실행 초기에는 인터프리터에서 바이트 코드를 해석해 기계어를 실행하지만, 인터프리터만을 실행되는 바이트 코드는 성능이 좋지 않다. 때문에 JVM은 자주 실행되는 바이트 코드 실행의 최적화를 위해 몇가지 작업을 하게 되는데, 이때 JVM에서 바이트 코드들을 기계어로 번역하는 컴파일러가 바로 JIT 컴파일러이다.
JVM은 총 2가지 변환을 수행한다.
- Java, Kotlin 파일을 바이트 코드로 변환하는 과정
- 바이트 코드를 기계어로 변환하는 과정
정적 컴파일과 동적 컴파일의 한계점과 장점
컴파일러에서 프로그램 코드를 기계어로 변환할 수 있는 시점은 2가지가 있다. 하나는 프로그램 실행 전이고, 다른 하나는 프로그램 실행 중 이다. 실행 시점 전에 기계어로 변환하는 컴파일러를 정적 컴파일러라 하고, 실행 중 기계어로 변환하는 컴파일러를 동적 컴파일러(인터프리트 언어)라 한다.
C, C++와 같이 실행 시점 전에 모두 컴파일(정적 컴파일)을 하게 되면 컴파일 타임에 시간이 너무 오래 소요된다는 단점이 있는 대신 런타임에 성능이 뛰어나다는 장점이 있다. 반대로 javascript, python과 같이 실행 중에 컴파일을 하게 되면 컴파일 시간이 거의 필요없이 실행을 할 수 있게 되지만, 실행 중 리소스의 일부를 컴파일에 사용하게 되기 때문에 프로그램 성능이 떨어진다는 문제점이 있다.
이 같은 한계점을 극복하기 위해 이 둘의 단점을 최소화하고 장점을 극대화 하는 방향으로 설계된 컴파일러가 바로 JIT 컴파일러이다. JVM은 kt(kotlin)나 java 파일을 이용해 바이트 코드를 만들 때는 정적 컴파일러를 사용하지만, 바이트 코드를 기계어로 변환할 때는 동적 컴파일러를 사용하지 않는다. 동적 컴파일러를 더욱 효율적으로 동작시키기 위해 JIT(Just In Time) Compiler라 불리는 컴파일러를 지원한다.
JIT 컴파일러는 어떻게 동적 컴파일러를 최적화 하는가?
JIT컴파일러는 기본적으로 실행 중에 컴파일(동적 컴파일)을 한다. 하지만, 동적 컴파일을 하는 언어들보다 훨씬 좋은 성능을 내는데, 그 이유는 기계어로 변환된 코드를 캐시에 저장시켜 재사용시 컴파일을 다시 하지 않아도 되기 때문이다. 즉, 런타임에 바이트코드를 기계어로 변환하는 컴파일을 하면서 이미 캐시에 있는 기계어는 다시 변환하지 않고 사용하는 컴파일러가 바로 JIT 컴파일러이다. 만약 캐시된 코드를 사용한다면 마치 미리 컴파일된 코드를 사용하는 것과 같은 효과(정적 컴파일)를 내기 때문에 성능이 향상된다.
하지만 JVM의 캐시 공간은 매우 작기 때문에 모든 코드들을 캐시하는건 아니다. JVM은 내부에서 자주 수행되는 코드들을 선별하여 캐시 공간에 넣어둔다. 또한 이러한 캐시 공간에 넣는 코드들은 모두 높은 수준의 최적화를 하게 되면 많은 시간이 걸리기 때문에, 중요도에 따라 차등을 두어 캐싱을 수행한다.
JVM의 JIT 컴파일러 내부에는 2가지 컴파일러인 C1컴파일러와 C2컴파일러가 있다. C1 컴파일러는 런타임에 바이트 코드를 기계어로 변환하는 과정을 수행하며 낮은 수준의 최적화 후 코드 캐시에 저장하는 과정을 수행한다. C2 컴파일러는 런타임에 바이트 코드를 높은 수준의 최적화를 거쳐 기계어로 변환한다음 코드 캐시에 저장하는 과정을 수행한다. JVM은 코드들의 수행 빈도와 복잡도에 따라 총 4가지 레벨로 분류하여 코드를 수행하는데, 이 중 1~3레벨 코드는 C1 컴파일러를 이용해 변환을 실행하며, 4레벨에 코드는 C2 컴파일러를 사용해 변환을 실행한다.
C1 compiler : 1~3 level compilation - 컴파일 + 낮은 수준의 최적화 + 코드 캐시
C2 compiler : 4 level compilation - 컴파일 + 높은 수준의 최적화 + 코드 캐시
예를 들어 다음과 같은 Kotlin 코드가 있다고 해보자.
fun main() {
functionA()
functionB()
functionA()
functionA()
}
fun functionA() {}
fun functionB() {}
위에서 functionA가 만약 자주 사용되는 function이라고 판단되면 다음과 같이 3번째와 같이 캐시시키고 다음에 해당 function이 불렸을 때는 변환하지 않고 캐시에서 가져온다.
실제 수행 살펴보기
실제 수행을 살펴보기 위해서는 java 파일 수행 시 -XX:+PrintCompilation 옵션을 주면 된다.
예를 들어 아래와 같은 코드를 만든 후 javac(java compile) 를 통해 바이트 코드로 컴파일 하고
public class Main {
public static void main(String[] args){
System.out.println("hello");
}
}
바이트 코드로 만들어진 Main.class 파일을 수행하면 다음과 같은 결과가 나온다. 결과가 길어서 일부를 생략했다.
$ java -XX:+PrintCompilation Main
43 1 3 java.lang.Object::<init> (1 bytes)
43 2 3 java.lang.StringLatin1::hashCode (42 bytes)
44 3 3 java.lang.String::isLatin1 (19 bytes)
44 4 3 java.lang.String::hashCode (49 bytes)
44 5 3 java.lang.String::coder (15 bytes)
45 6 3 java.util.ImmutableCollections$SetN::probe (56 bytes)
45 7 3 java.lang.Math::floorMod (10 bytes)
45 8 3 java.lang.Math::floorDiv (22 bytes)
47 9 3 java.util.ImmutableCollections$SetN::hashCode (46 bytes)
47 11 3 java.lang.StringLatin1::equals (36 bytes)
47 10 3 java.lang.String::equals (65 bytes)
49 12 3 java.util.ImmutableCollections::emptySet (4 bytes)
49 14 3 java.util.Set::of (4 bytes)
50 13 3 java.util.Objects::equals (23 bytes)
50 15 3 java.lang.module.ModuleDescriptor$Exports::hashCode (38 bytes)
51 16 4 java.lang.StringLatin1::hashCode (42 bytes)
여기서 3번째 열이 코드 레벨이다. java.lang.StringLatin1::hashCode (42 bytes) 가 2번째 라인과 마지막 라인에서 수행된것을 확인할 수 있으며, 2번째 라인에서는 3레벨로 C1 컴파일러로 컴파일 되었지만, 마지막 라인에서는 4레벨로 판정되어 C2 컴파일러로 컴파일된 것을 볼 수 있다.
* 제가 들었던 자바 최적화와 관련된 강의와 책에서는 Tiered Compile에서 Level4로 컴파일된 코드만 코드 캐시에 저장된다고 되어 있었던걸로 기억하는데, 이번에 찾아보니 어떤 글에서는 Level 1~3까지도 코드 캐시에 저장되고, Level이 높아지면 캐시가 대체된다고 하네요. 일단 이 부분 내용을 수정하기는 했는데, 이 부분과 관련돼 정확히 아시는 분이 있으시면 말씀해주시면 감사하겠습니다.
*글에 오류가 발견되어 2024년 2월 9일 수정을 진행했습니다. 제가 당시 학습을 진행한 자료에 오류가 있어 잘못된 내용이 있었습니다. 수정을 하게 도와주신 '호호 부는 튜브'님 감사합니다.