바이트코드 실행
바이트코드를 열어보면 명령어는 조금 다르지만 어셈블리어와 비슷한 형태로 만들어진 것을 확인할 수 있다. 실제 CPU에서 어셈블리 명령어를 읽어서 실행하는 것처럼, JVM은 바이트코드 명령어들을 한 줄씩 읽어서 명령어의 종류에 따라 동작을 실행한다.
이러한 바이트코드는 실제 CPU에서 실행할 수 있는 형태가 아니기때문에, 이것을 CPU에서 실행할 수 있는 형태로 변경하는 작업이 필요하다. 이러한 작업을 해주는 것이 JVM의 실행 엔진이다.
실행 엔진은 크게 3부분으로 구성되어있다. 바이트코드 명령어를 하드웨어 명령어로 해석해서 실행하는 interpreter, 성능을 개선하기 위해 자주 사용되는 바이트코드를 미리 하드웨어 명령어로 컴파일하는 JIT(Just In Time) 컴파일러, 그리고 더 이상 사용하지 않는 객체를 메모리에서 삭제해주는 Garbage Collector가 있다.
Interpreter
인터프리터는 바이트코드 명령어를 한 줄씩 읽어서 하드웨어에서 실행할 수 있는 기계어로 번역해주는 역할을 한다. while 루프를 돌면서 한 줄씩 명령어를 읽고, 명령어에 따라 필요한 행동을 하는 코드를 생각하면 쉽다. 아마 python이나 javascript를 공부해봤다면 인터프리터 언어라는 말을 들어봤을텐데, python과 javascript의 인터프리터와 같은 역할이라고 보면 된다. 이러한 인터프리터 언어들은 명령어를 수행할 때마다 매번 기계어로 번역하는 과정을 거치기 때문에 상대적으로 느리다. C/C++같은 언어들은 미리 기계어로 번역을 해두고, 실행만 하기 때문에 컴파일 언어라고 부른다. 이러한 방식을 AOT(Ahead Of Time) 컴파일이라고 한다.
Java는 컴파일 언어와 인터프리터 언어의 특징을 모두 가지고 있는 언어다. 소스코드를 미리 컴파일하여 JVM에서 실행할 수 있는 바이트코드로 바꾸는 과정을 거치고, JVM에서 인터프리터로 한 줄씩 실행하기 때문이다. 이것은 Java의 특징 중 하나인 '이식성'을 위한 선택이었던 것 같다.
참고로, Java에서도 AOT 컴파일을 도입하려는 시도가 있다. 오라클에서 연구중인 GraalVM이 그것이다. GraalVM에서는 JIT 컴파일러를 이용한 성능 개선의 한계를 극복하고자 직접 Native Image로 컴파일한다. 이렇게 Native Image를 바로 실행할 수 있으면 Java 어플리케이션의 시작 시간이 훨씬 빨라지고 사이즈가 작아져 Cloud Native로 실행하기에 좋다. 그러나 AOT 컴파일러는 Java 9버전에 도입되었다가, Java 17에서 사라진 것으로 보아 아직 상용화 수준은 아닌 것 같다.
JIT Compiler
JIT 컴파일러는 인터프리터의 느린 속도를 개선하기 위해 만들어졌다. JIT 컴파일러는 메소드마다 실행된 횟수를 카운트해서 일정한 기준을 넘어서면 메소드를 미리 컴파일하여 일부를 JVM의 코드 캐시에 저장한다. 또한, 컴파일 할 때 메소드 인라이닝 등의 코드 최적화도 함께 일어나는데, 이 때 실제 바이트코드는 변하지 않으며 JVM 메모리 내부에서만 최적화가 일어난다. 컴파일의 기준은 기본값이 10,000이며, JVM 실행시 CompileThreshold 옵션으로 변경이 가능하다.
JIT 컴파일러는 내부적으로 C1 컴파일러와 C2 컴파일러로 구성된다. 이들은 메소드를 분석하고, 통계를 이용해 5가지 레벨로 구분하여 레벨 0은 인터프리터로 실행하며, 레벨 1~3은 C1 컴파일러, 레벨 4는 C2 컴파일러로 컴파일한다. 기본적으로 레벨 0에서 시작해 자주 사용되는 메소드는 레벨 3로 올라가고, 프로파일링 과정을 거쳐 더 최적화가 필요한 메소드는 레벨 4로, 중요하지 않다고 판단되는 메소드는 레벨 1로 바뀐다. 또, C2 컴파일러의 큐에 공간이 부족한 경우 레벨 2로 이동하여 관리된다.
[이미지 출처] https://www.baeldung.com/jvm-tiered-compilation
JIT 컴파일러가 도입된 초기에는 클라이언트 컴파일러와 서버 컴파일러로 구분하여 용도에 따라 둘 중에 하나만 사용되었다. 이름 그대로 클라이언트 컴파일러는 데스크탑 애플리케이션에서 사용되는 비교적 시작 시간이 빠른 컴파일러였고, 서버 컴파일러는 시작은 느리지만 성능 최적화에 적합한 용도였다. 이 클라이언트 컴파일러가 C1 컴파일러가 되었고, 서버 컴파일러가 C2 컴파일러로 이름이 바뀌어 현재는 두 컴파일러를 함께 사용하게 되었다. 이렇게 두 컴파일러를 함께 사용하는 것을 Tiered Compilation이라고 부른다.
[이미지 출처] https://www.baeldung.com/jvm-tiered-compilation
JVM 실행시에 PrintCompilation 옵션을 주면 컴파일 로그를 확인할 수 있다.
public class Test {
public static void main(String[] args) {
long sum = 0;
for (int i = 1; i <= 1000000; i++) {
sum += i;
}
System.out.println(sum);
}
}
java -XX:+PrintCompilation Test
위 사진에서 맨 왼쪽 숫자는 시작후 지난 시간을 ms단위로 나타내는 타임스탬프다. 두 번째 숫자는 컴파일된 메소드의 ID이며, % 표시는 OSR(On-stack Replacement)로 최적화된 코드를 실행했다는 것을 나타낸다. 세 번째 숫자는 0~4까지의 레벨이다.
Garbage Collector
Java에서는 동적으로 할당된 메모리를 직접 해제해줄 필요가 없다. 가비지 컬렉터가 개발자를 대신해 메모리를 관리해주기 때문이다. C언어에서는 메모리 동적 할당을 위해 malloc 함수를 사용하고, free 함수로 명시적으로 메모리를 해제해줘야 한다. 이것은 메모리를 필요할 때 바로 해제하여 불필요하게 낭비되는 메모리를 줄일 수 있다는 장점이 있지만, 개발자의 실수로 메모리를 해제하지 않는 경우 조금씩 메모리를 잡아먹다가 며칠 후에나 문제가 발생할 수 있다.
가비지 컬렉션에서는 사용하지 않는 객체를 어떻게 식별하는 방법이 중요하다. 가비지를 판별하기 위해서는 reachability라는 개념을 사용하는데, 이것은 해당 객체에 유효한 참조가 있는가를 나타낸다. 객체는 힙 내의 다른 객체, 스택의 로컬 변수, 네이티브 스택의 객체, 메소드 영역의 static 변수에서 참조할 수 있다. 힙 내부에서는 순환 참조가 발생할 수 있기 때문에 스택과 네이티브 스택, 메소드 영역에서 참조할 수 있는 객체들을 reachable하다고 표현하고, reachable하지 않은 객체들이 가비지 컬렉션의 대상이 된다.
[이미지 출처] https://d2.naver.com/helloworld/329631
가비지 컬렉션에는 다양한 알고리즘이 존재하지만, 일반적으로 Mark and Sweep 과정을 거친다고 보면 된다. Mark는 reachability를 판별하여 살아있는 객체들에 표시하는 과정이고, Sweep은 객체들의 할당 리스트를 순회하며 살아있는 객체만 남기고 메모리를 해제하는 것이다.
가비지 컬렉션을 실행할 때는 JVM이 어플리케이션의 실행을 잠시 멈춘다. 이것을 STW(Stop the World)라고 한다. 가비지 컬렉션을 최적화하기 위해서는 STW 시간을 줄이는 것이 중요하다. 그러나 지금까지 나온 어떤 알고리즘을 사용해도 STW를 완전히 없앨 수는 없다. 따라서 최적화의 목적에 따라 알맞은 알고리즘을 선택해야 한다. 일반적으로는 latency를 줄이거나, throughput을 극대화하는 방향을 선택한다. throughput이 중요할 때는 Parallel GC를 선택할 수 있고, latency가 중요한 경우에는 ZGC나 G1GC같은 알고리즘을 선택할 수 있다. 그러나 실무에서는 메모리의 사이즈나 어플리케이션의 요구 사항 등 여러 요소들을 고려해야하고, 실제로 성능 테스트를 거쳐서 알맞은 알고리즘을 선택해야 할 것이다.
참고로, Java 9 이후 버전은 G1GC가 디폴트이며, ZGC는 Java 15부터 프로덕션으로 지원되고 있다. 알고리즘을 변경하려면 JVM 실행시 아래와 같이 옵션을 주면 된다.
java -XX:+UseParallelGC Test
java -XX:+UseG1GC Test
java -XX:+UseZGC Test
3줄 요약
- 실행 엔진은 Interpreter, JIT compiler, Garbage Collector로 구성된다
- JIT compiler는 Interpreter의 성능 개선을 위해 도입되었다
- 목적에 따라 GC 알고리즘과 옵션 변경을 변경하여 최적화할 수 있다
'Java > 자바 최적화' 카테고리의 다른 글
[Java] 자바 최적화(6) - 가비지 컬렉션 기초 (0) | 2023.03.26 |
---|---|
[Java] 자바 최적화(5) - 마이크로 벤치마킹 (0) | 2023.03.23 |
[Java] 자바 최적화(4) - 런타임 데이터 영역 (0) | 2023.03.18 |
[Java] 자바 최적화(2) - 클래스 로더 (0) | 2023.03.16 |
[Java] 자바 최적화(1) - 성능 지표 (0) | 2023.03.15 |