Search

JVM JIT 컴파일러의 주요 코드 최적화 기법

JVM 환경에서 애플리케이션이 컴파일되어 실행되는 과정에서 JIT 컴파일러는 자바 컴파일러에 의해 변환된 자바 바이트 코드(.class)를 일정 시점에 네이티브 코드로 변환하여 코드 캐시에 캐싱한 이후 인터프리터 대신 네이티브 코드를 직접 실행하도록 한다. 그 덕에 명령어를 하나씩 실행하는 인터프리팅 방식보다 빠른 속도를 가질 수 있다.
물론, JIT 컴파일러의 컴파일 비용도 있기 때문에 모든 바이트코드를 컴파일하진 않고, JVM 내부적으로 해당 메서드의 수행 빈도를 확인 후 일정 정도를 넘을 때 컴파일을 수행한다.
만약 JVM 의 동작형태나 구성요소에 대해 더 궁금하다면 다음 포스팅을 참고하도록 하자. (링크)
JIT컴파일러는 단순 네이티브 코드 변환만을 수행하지 않는다. 변환시에 다양한 최적화 기법을 통해 Java 코드를 더 빠르게 실행할 수 있도록 하며, 정적 컴파일러가 수행하는 최적화뿐 아니라 동적 프로파일링 정보를 활용한 최적화도 포함된다.
이번 포스팅에서는 JIT 컴파일러가 수행하는 주요 코드 최적화 기법에 대해 알아보도록 한다.

1. Inlining(인라이닝)

개념

메서드 호출 시 발생하는 오버헤드(스택 조작, 호출/복귀 비용등)를 줄이기 위해, 호출하는 위치에 해당 메서드의 본문을 직접 삽입하는 최적화 기법으로 인라이닝 자체로도 성능 향상을 주지만, 동시에 다른 최적화의 기반을 만들어주기에 게이트웨이(gateway) 최적화 라고 부르기도 한다.
우선, 어떻게 최적화가 이뤄지는지 다음 간단한 예제 코드를 통해 확인해보자.

인라이닝 최적화 예제 코드

// 최적화 전 int add(int a, int b) { return a + b; } int sum = 0; for (int i = 0; i < N; i++) { sum += add(x, y); } // 최적화 후 (인라이닝 적용) int sum = 0; for (int i = 0; i < N; i++) { // add(x, y)의 본문을 직접 삽입 (a+b 수행) sum += (x + y); }
Java
복사
⇒ 최적화 전에는 루프 내에서 매번 add(x, y) 메서드를 호출하지만, 인라이닝 이후에는 메서드 호출 없이 직접 연산을 수행한다.
이렇게 호출부분의 내용이 직접 들어가며 호출 관련 오버헤드가 제거되고 루프 내 연산이 단순화되는 것을 확인할 수 있다.

내부 동작

JIT 컴파일러는 바이트 코드를 IR(Intermediate Representation)으로 변환하며 메서드 인라이닝을 수행한다.
HotSpot JVM의 경우에는 메서드 크기나 호출 빈도, 호출 깊이 등에 따라 인라이닝 적용 여부를 결정하는데, 각각 좀 더 자세히 살펴보면 다음과 같다.
메서드 크기
작은 메서드들은 인라이닝 하기에 적합하다고 판단한다.
(HotSpot 기준으로 35바이트 이하의 메서드를 인라인할 수 있다고 설정되어 있다.)
호출 빈도, 호출 깊이
HotSpot 프로파일링을 통해 자주 호출되는 메서드를 식별하고, 해당 호출 지점에 대해 인라이닝을 시도한다.
바이트코드를 트리 형태로 표현한 뒤, 호출 노드를 제거하고 피호출 메서드의 바이트코드 트리를 호출자 위치에 합치는 방식으로 동작한다.
위와 같이 각 상황에 맞기 인라이닝이 적용되면 그 이후 최적화 단계에서 메서드 경계가 사라진 통합된 코드에 대해서 데드 코드 제거, 상수 전파, 루프 변환등의 전역 최적화가 가능해지고, JIT 컴파일러는 최적화를 수행한다.
물론, 너무 큰 메서드나 깊은 호출 체인에 대해서는 인라인을 제한하여 코드 폭발을 방지하고 있다.

적용 사례

메서드 인라이닝은 JIT 컴파일러의 가장 기본적이고 효과적인 최적화 기법으로, 상당히 많이 적용된다.
예를 들어 컬렉션의 getter 메서드나 String.length()와 같은 메서드의 크기가 작으면서도 자주 사용되는 메서드들은 JIT에 의해 인라인되어 호출 오버헤드 없이 사용된다. 그렇기에 이러한 메서드의 호출 비용은 사실상 0에 가깝다.

2. Loop Optimization(루프 최적화)

개념

반복문 내부에서 불필요한 연산을 제거하거나 반복 제어를 단순화하여 반복문 전체의 성능을 개선하는 기법으로 JIT 컴파일러는 루프를 만나면 반복 구조를 분석해, 루프의 반복 횟수를 줄이거나(loop unrolling), 루프 태부에서 매 반복마다 불필요하게 수행되는 작업을 밖으로 빼내거나(loop invariant code motion, 루프 불변 코드 이동) 하는 등의 최적화를 수행한다.
또한, 인덱스 범위 검사 제거(range check elimination)강도 감소 최적화(strength reduction)같은 최적화도 루프에 적용된다. 이러한 최적화들로 루프의 분기 횟수는 줄어들고 CPU 효율을 높혀, 대용량 반복 연산 최적화를 할 수 있다.
참고: strength reduction 최적화
비용이 높은 연산(ex: 곱셈)을 비용이 더 낮은 연산(ex: 덧셈)으로 대체하는 성능 최적화로 반복문 내에서 특정 값이 일정한 배율로 증가하거나, 배열 인덱스 기반 수식 계산, 수치 연산이 반복 수행되는 경우 적용될 수 있는 최적화 기법이다.

최적화 예제

대표적인 루프 최적화인 루프 언롤링(loop unrolling)과 루프 불변 코드 이동(loop invariant code motion)최적화를 예제를 통해 알아보자.
루프 언롤링(Loop Unrolling)
// 최적화 전: 루프 본문 (sum += i)을 N번 실행 int sum = 0; for (int i = 1; i <= N; i++) { sum += i; } // 최적화 후: 2회 언롤링된 루프 (분기 횟수를 절반으로 감소) int sum = 0; for (int i = 1; i <= N; i += 2) { sum += i; if (i + 1 <= N) { // 다음 반복 내용을 미리 실행 sum += (i + 1); } }
Java
복사
⇒ unrolling 이후 로프 종료 조건 체크와 i증가가 절반만 수행되며, 분기 예측과 반복 제어에 드는 비용이 크게 줄어든다. 물론, 루프 컨텍스트 내의 본문이 너무 커질 경우 I-Cache 부하 증가 등의 부작용이 있을 수 있어 적절한 수준으로 unrolling 적용이 되야 한다.
루프 불변 코드 이동 (loop invariant code motion)
// 최적화 전 int[] arr = new int[100]; int y = 5, z = 10; for (int i = 0; i < arr.length; i++) { int prod = y * z; // 불변식 계산을 반복 수행 arr[i] = prod * i; } // 최적화 후 (불변 코드를 루프 밖으로 이동) int[] arr = new int[100]; int y = 5, z = 10; int prod = y * z; // 불변식 계산을 1회로 축소 int len = arr.length; // 배열 길이도 미리 저장 for (int i = 0; i < len; i++) { arr[i] = prod * i; }
Java
복사
int prod = y * z; 최적화 전 코드에서 prod 의 값을 구하는 이 로직은 반복문 내 계속 동일하게 반복되는 루프 불변식이다. 그렇기에 루프 밖으로 이동하여 1회 선언 후 재사용해도 문제가 없다. 그리고 반복문의 종료 조건에서 arr 배열의 길이를 기준으로 잡고 있는데, 이 역시 루프 밖으로 분리해서 사용할 수 있다. 이처럼 루프 내부에서 반복될 필요가 없는 계산이나 메모리 접근을 감지해 바깥으로 빼내며, 루프 자체는 필요한 핵심 계산만 수행하도록 단순화한다.

내부 동작

JIT 컴파일러는 제어 흐름 그래프상에서 루프 구조를 인식하고 별도 처리하는데, 루프 최적화 단계에서 다음과 같은 작업들을 수행한다.
루프 불변 코드 호이스팅(loop invariant hoisting)
루프 내에서 루프 인덱스(i)나 루프 내에서 변경되는 변수에 의존하지 않는 코드를 탐지하여, 루프 전에 수행하고 루프 내에서는 결과만 참조하도록 IR을 변환한다.
인덱스 범위 검사 제거(range check elimination)
배열 접근 시마다 경계를 검사해야 하는데, JIT는 루프의 인덱스 범위를 분석해 항상 범위 내임을 증명할 수 있으면 내부의 경계체크를 제거한다. (ex: i < arr.length)
루프 언롤링(loop unrolling)
루프 반복 횟수나 바디 크기 등을 고려해 성능 이득이 있다고 판단되면 루프를 전개한다. C2 컴파일러는 기본적으로 짧은 루프에 대해 8회 미만의 언롤링을 시도하며, 루프 인덱스의 타입(int, long)에 따라서도 다르게 처리한다. 필요 시 루프 분할이나 루프 뒤집기(loop inversion)같은 기법으로 루프 구조를 변형하기도 한다.
강도 감소(strength reduction)
곱셈과 같이 비용이 높은 연산을, 더 낮은 비용의 연산으로 대체한다.
이러한 루프 최적화는 보통 메서드 단위 전역 최적화 단계에서 수행되며, IR 상에서 루프 관련 노드들이 재구성된 후 실제 머신 코드로 변환된다. 추가적으로 JIT은 OSR기능으로 실행 중인 긴 루프를 도중에 최적화된 코드로 대체하기도 한다.
참고: OSR(On-Stack-Replacement, 온-스택 치환)
프로그램의 실행 흐름에서 함수가 호출되고, 호출된 함수가 종료될 때, 자신을 호출했던 지점으로 복귀되어 실행 흐름을 이어가게 된다. 여기서 함수가 호출되는 시점에 작업 후 복귀되기 위해 복귀지점을 Stack에 만드는데 이것을 RET(Return Address) 이라 부른다. OSR은 이런 흐름 메커니즘에서 복귀지점(RET)을 JIT 컴파일러에 의해 최적화된 주소로 복귀하도록하여 최적화된 코드가 대신 이어서 실행되도록 하는 최적화 기법이라고 볼 수 있다.

적용 사례

루프 최적화는 수치 연산및 컬렉션 처리와 같이 루프가 많은 코드에서 좋은 결과를 보여준다.
sum, max등의 집계함수를 구현한 루프에선 JIT 컴파일러는 경계 검사를 제거하고, 불변 데이터를 호이스팅해 순회 속도를 높힌다. 루프 언롤링 최적화의 경우, CPU 분기 예측 실패나 파이프라인 플러시가 줄어드는 효과로 반복당 수행 시간을 줄일 수 있는데, 예를 들어 HotSpot에서 int 타입의 타입 루프 변수를 사용할 경우 long보다 적극적으로 언롤링(unrolling)하여 같은 루프에도 int를 쓴 경우 최대 두 배까지도 빠른 성능을 보이기도 한다.
또한, 루프 최적화 과정에서 데드 코드 제거도 수행하곤 하는데, 상수로 반복하는 루프같은 경우에는 전체 루프를 없애버리고 미리 결과를 계산해버리는 경우도 있다. (ex: 0부터 100까지 더하는 반복문)

3. Method Inlining(메서드 인라이닝)

개념

위에서 소개한 인라이닝의 개념을 메서드 호출에 적용하는 방법으로, 자주 호출되는 작은 메서드나 단순한 getter/setter 메서드등을 감지해 호출자 코드에 삽입하여 실제 호출을 제거하는 최적화 기법.
해당 최적화를 적용 할 경우 호출 스택을 쌓고 해제하는 비용, 매개변수 전달 비용등을 절약할 수 있다.
뿐만 아니라 인라이닝 최적화와 마찮가지로 다른 최적화를 가능하게 하여 상호 연관된 최적화를 일어나게 하는 게이트웨이 최적화이기도 하다. 이러한 메서드 인라이닝 최적화는 JIT 컴파일러가 알아서 합쳐주기 때문에, 작은 메서드들을 구조화해서 여러 메서드들로 분리해놔도 성능 페널티는 거의 없다.(그러니 순수함수를 지양하지 말자.)
다만, 인라이닝은 JIT 컴파일러가 임의로 적용/미적용하기 때문에 개발자가 강제로 할 수는 없다.
하지만, 대부분 합리적으로 동작하여 인라이닝 되기에 크게 걱정할 필요는 없다.

최적화 예제

// 최적화 전 int mul(int x, int y) { return x * y; } int square(int a) { return mul(a, a); } int compute(int n) { int sq = square(n); return sq + 5; } // 최적화 후 (인라이닝 적용) int compute(int n) { // square(n)의 본문 인라인 // -> square는 mul(n, n)을 호출하므로 mul 본문도 인라인 int tmp = n * n; // mul의 결과 int sq = tmp; // square의 결과 return sq + 5; }
Java
복사
⇒ 최적화 전 compute 메서드를 보면 내부적으로 square메서드를 호출하고 square는 다시 mul을 호출하는 구조로, 인라이닝 이후 compute 내부에 square와 mul의 내용이 모두 펼쳐져서 계산되는 단일 메서드가 되었다. 이러한 변환을 통해 불필요한 호출 스택 오버헤드가 사라지고 바로 n*n+5 연산이 수행된다.

내부 동작

JIT 컴파일러는 각 메서드의 바이트코드 크기와 호출 빈도 등의 정보를 바탕으로 인라이닝할 대상을 정한다.
메서드 크기나 호출 빈도등은 인라이닝 최적화와 같은 임계값과 조건으로 시도한다.
C1 컴파일러 단계에서 프로파일링된 메서드 호출 카운트나 타입 정보등이 C2 컴파일러의 인라이너가 결정하는데 활용되는데, 종료 조건이 있는 재귀는 일정 횟수까지 인라인을 시도해 반복문처럼 펼치기도 한다.
(물론, 무한 재귀 방지를 위해 깊이 제한은 존재한다.)
추가적으로, 가상 메서드 호출의 인라이닝은 별도의 가드 처리가 필요하기 때문에, 가상 메서드 인라이닝 기법을 활용해야 한다.

적용 사례

메서드 인라이닝 최적화는 Java 애플리케이션 전반에서 일어나며, List.get(int)같은 메서드는 단순한 배열접근이기에 인라이닝되어 최적화된다. 이처럼 getter/setter나 수학 함수, 람다식 바디등은 대부분 인라이닝되어 별도의 호출 비용 없이 호출된다.

4. Common Subexpression Elimination (공통 부분식 제거)

개념

한 번 계산한 값을 동일한 범위 내에서 반복 계산하지 않고, 재사용하는 최적화 기법으로 컴파일러는 이 중간 결과를 캐싱해둠으로써 불필요한 중복 연산을 제거한다. 예를 들어, a*b + a*b*2 이런 수식이 있을 때 a*b 부분은 중복되기에 미리 계산 후 재사용하는 식으로 게산횟수가 줄어서 성능 향상과 코드 크기 감소등의 효과를 얻을 수 있다.
특히, 전역 공통 부분식 제거(CSE)는 JIT의 SSA형태 IR과 전역 값 번호매기기(global value numbering) 기법(메서드 전역에서 같은 값을 산출하는 노드를 식별해 하나로 합치는 기법)을 통해 효과적으로 구현되어 있다.

최적화 예제

// 최적화 전 int calc(int x, int y, int c, int d) { int result1 = (x + y) * c; int result2 = (x + y) * d; return result1 + result2; } // 최적화 후 (공통 부분식 제거 적용) int calc(int x, int y, int c, int d) { int tmp = x + y; // 공통 부분식 (x+y)을 한 번만 계산 int result1 = tmp * c; int result2 = tmp * d; return result1 + result2; }
Java
복사
= (x + y) 수식이 반복되기에 tmp에 저장하여 결과를 재사용하도록 해 덧셈 연산의 수를 줄였다.

내부 동작

JIT 컴파일러는 IR을 다루는 최적화 단계에서 CSE를 수행한다.
HotSpot의 경우 그래프 형태의 SSA IR(Sea of Nodes) 을 사용하기에 같은 연산을 갖는 노드는 자연스럽게 공유되거나, 통합 된다. 예를 들어 IR 상에서 두 연산이 동일한 계산을 위미하면 하나의 값만 남기고 나머지를 제거하는 식이다. 이를 위해 전역 값 번호매기기(global value numbering) 기법이 활용되어, 프로그램 경로 전역으로 등기인 식을 식별한다.
또한 JIT은 객체 필드나 배열 요소를 반복해서 읽는 경우, 그 사이에 값이 바뀔 수 없다고 판단되면 한 번만 메모리에서 읽고 레지스터에 저장해 이후에는 재사용하도록 중복 메모리 로드 제거도 수행한다.

적용 사례

공통 부분식 제거는 수식 계산이 많은 코드, 예를 들어 과학 계산이나 그래픽 연산과 같은 곳에서 효과적인 최적화 기법이다. JIT은 개발자가 직접 임시 변수를 만들어 공통값을 저장하지 않더라도 자동으로 동일한 계산을 캐싱해준다.

5. Instruction Reordering(명령어 재정렬)

개념

서로 독립적인 명령어들의 실행 순서를 변경하여 CPU 파이프라인이 최대한 대기 시간 없이 연속적으로 작업할 수 있도록 하여 프로세서의 효율을 극대화 하는 최적화 기법이다.
JIT 컴파일러는 프로그램의 논리적 결과가 동일하게 유지되는 선에서 명령어들의 순서를 바꿔 메모리 지연(latency)이나 연산 의존성으로 인한 병목을 줄인다. 예를 들어, A와 B 연산이 있을 때 원래 코드 순서가 A다음 B라고 해도 JIT은 B를 먼저 실행하도록 순서를 바꿔 A의 결과를 기다리는 동안 B를 처리하게 할 수 있다.
그리고, 메모리 접근의 경우 인접한 메모리 주소를 순차적으로 읽는 식으로 재정렬해 캐시 지역성(locality)을 높이기도 한다. 이러한 재정렬 최적화를 통해 분기 예측 실패나 캐시 미스로 인한 CPU 파이프라인의 유휴시간을 최소화 한다.

최적화 예제

// 비순차 접근 int[] arr = {1, -1, 1, -1}; int sum = 0; sum += arr[1]; sum += arr[0]; sum += arr[3]; sum += arr[2];
Java
복사
mov eax, [arr + 0*4] mov ebx, [arr + 1*4] mov ecx, [arr + 2*4] mov edx, [arr + 3*4] add eax, ebx add ecx, edx add eax, ecx
Plain Text
복사
JIT 의 네이티브 코드 재정렬 이후
⇒ 코드를 보면 arr이라는 배열에 순차적으로 접근하는게 아닌 1 → 0 → 3 → 2 순서로 접근한다. HotSpot JIT 컴파일러는 이러한 메모리접근을 최적화하기 위해, 실제 네이티브 명령에서는 0 → 1 → 2 → 3 순으로읽도록 재정렬한다.
// 서로 독립적인 계산 int a = computeA(); // A 연산 (느림) int b = computeB(); // B 연산 (느림) int c = a + 5; int d = b * 2;
Java
복사
⇒ computeA와 computeB가 서로 상관없는 값일 경우 JIT은 명령어 스케줄링을 통해 A와 B의 내부 명령을 교차 배치할 수 있다. 즉, A의 결과를 기다리는 동안 B의 일부를 먼저 실행하도록 하여, 두 연산이 겹쳐 수행되게 최적화 할 수 있다.

내부 동작

명령어 스케줄러(instruction scheduler)는 일반적으로 JIT 컴파일러의 뒷부분, 기계어 생성 단계에서 동작한다. C2 컴파일러도 IR을 최종적으로 바이너리 코드 변환을 하며 스케줄링 패스를 거치는데, 이 때 각 명령 노드의 데이터 의존성 그래프를 고려해 효율적인 실행 순서를 결정한다.
graph LR
    A[명령어 인출 
    Instruction Fetch] --> B[명령어 해독
    Instruction Decode]
    B --> C[명령어 실행
    Instruction Execute]
    C --> D[메모리 접근
    Memory Access]
    D --> E[결과 쓰기
    Write Back]
Mermaid
복사
CPU pipeline
JIT은 CPU의 파이프라인 단계(페치-디코드-실행-메모리 등)를 모델링해 한 명령의 결과가 다음 명령의 피연산자로 필요하지 않은 경우 앞뒤를 바꾸거나 지연이 큰 명령(ex: load, mul)뒤에 독립적인 명령을 배치하는식의 규칙을 적용한다. 다만, volatile 연산이나 메모리 장벽이 있는 경우 그 전후의 정렬을 제한하고, 그 외에는 비교적 자유롭게 순서를 조정한다.
참고: volatile 연산과 메모리 장벽이 있는경우 왜 정렬을 제한할까?
부수 효과 및 외부 변경 감지:
volatile 변수는 하드웨어나 다른 스레드에 의해 언제든지 값이 바뀔 수 있음을 의미한다. 이를 통해 해당 연산이 부수 효과를 발생시킬 수 있다는 것을 컴파일러에 알리며, 이런 연산은 코드 실행 순서를 보장해야 올바르게 동작하는데, 재정렬을 허용하면 외부 변경이 반영되지 않거나 예기치 않은 결과가 발생할 수 있다.
메모리 접근 순서 보장:
메모리 장벽은 특정 시점에 메모리 접근의 순서를 강제로 유지하게 한다. 다중 스레드 환경이나 하드웨어와의 상호작용에서 메모리 접근 순서가 보장되지 않으면 데이터 일관성이 깨지거나 동기화 문제가 발생할 수 있기 때문에 메모리 장벽이 있는 경우, 해당 장벽 앞뒤의 명령어들은 재정렬되어서는 안 된다.
결론: 두 경우 명령어 간 순서를 반드시 유지해야 하는 중요 포인트이기에 재정렬을 제한해 프로그램의 올바른 동작과 데이터 일관성을 보장한다.

적용 사례

CPU 파이프라인이 깊은 현대 프로세서에서 높은 효용성을 보인다. JIT 컴파일러가 없는 인터프리터 환경에서는 바이트코드 순서대로 실행하기에 최적화가 안되지만, JIT 컴파일러가 생성한 네이티브 코드는 수동으로 튜닝한 C 코드와 같이 효율적인 순서를 가지게 된다.
또한 Out-of-order 실행 지원이 없는 일부 임베디드 프로세서 상에서도 JIT 스케줄링은 명령 레벨 병렬성(ILP)을 높여준다.
참고: Out-of-order 란
비순차적 실행을 의미하며 고성능 마이크로프로세서가 특정한 종류의 지연으로 인해 낭비될 수 있는 명령 사이클을 이용하는 패러다임으로 더 효율적인 방법으로 명령어를 실행하는 것을 의미한다.
참고:명령어 수준 병렬성(Instruction Level Parallelism, ILP)
명령어 스트림 내에서 윈도우 크기만큼 범위내에서 ILP를 검사해 비순차적으로 처리한다. 처리가 끝날 경우 가장 오래된 명령어가 윈도에서 빠지고 동시에 새로운 명령어가 추가되어 반복한다.

6. Virtual Method inlining (가상 메서드 인라이닝)

개념

다형적(polymorphic) 메서드 호출을 마치 정적 호출처럼 인라인하여 빠르게 만드는 최적화 기법이다.
Java의 핵심 키워드를 말해보라고하면 늘 나오는 키워드가 다형성이다. 인터페이스나 상속에 의해 다형적 호출(virtual call)을 하는 방식은 Java에서 매우 흔한 작성 방식인데 예를 들어, List interface의 list.size() 메서드를 호출하면 실행 시점에는 구체 클래스(ex: ArrayList, LinkedList, ArrayQueue, ArrayStack 등)가 결정되어 해당 클래스의 구현이 호출된다.
이러한 가상 메서드 호출은 가상 테이블 (vtable) 조회나 인터페이스 디스패치를 거치기 때문에 직접 호출하는 것보다 비용이 높을 수 밖에 없다. JIT 컴파일러는 런타임 프로파일링 정보를 활용해 특정 호출 지점에서 실제로 등장하는 구현 클래스가 대부분 한 종류뿐이면, 그 경우에 한해 인라이닝을 시도한다.
그리고 이런 가정이 틀릴 경우를 가정해 가드 조건도 추가해놓는데, 가드(Guard)란 JIT 컴파일러가 가정한 조건이 틀릴 경우 일반 호출로 빠져나가는 분기를 의미한다.
이 기법은 가상 디스패치 제거(devirtualization)라고도 불리며, JIT 컴파일러가 정적 컴파일러보다 유리한 점 중 하나이다.

최적화 예제

interface Animal { void makeSound(); } class Dog implements Animal { public void makeSound() { System.out.println("Woof"); } } class Cat implements Animal { public void makeSound() { System.out.println("Meow"); } } // 최적화 전 호출 코드 Animal ani = new Dog(); ani.makeSound(); // 가상 메서드 호출 // 최적화 후 호출 코드 Animal ani = new Dog(); // 가드(Guard) 분기 if (ani.getClass() == Dog.class) { System.out.println("Woof"); //Dog.makeSound() 인라인된 코드 } else { // 가정이 깨진 경우: 원래 방식으로 가상 호출 처리 ani.makeSound(); // (다른 타입일 경우의 디스패치) }
Java
복사
⇒ 최적화 전에는 ani.makeSound() 가상 메서드가 호출될 때 ani 인스턴스가 가리키는 실제 클래스(Dog)의 메서드를 찾아 호출한다. JIT은 만약 이 지점에서 ani가 항상 Dog인 것으로 프로파일된다면, Dog.makeSound() 메서드의 본문을 인라이닝하고 가드를 넣어 최적화한다.
⇒ JIT은 상황에따라 최대 두 가지 타입까지 가드로 처리하는 bimorphic inlining도 수행한다.

내부 동작

가상 메서드 인라이닝 최적화 기법은 런타임 프로파일에 크게 의존한다.
HotSpot은 인터프리터 단계(C1 컴파일)에서 각 호출 바이트코드에 대해 어떤 클래스들이 몇 번 등장했는지를 기록하고, 이 정보를 바탕으로 C2컴파일러는 해당 호출이 단일 타입(monomorphic)인지 두 타입(bimorphic)인지를 파악한다.
그리고, 단일 타입으로 판정되면 JIT은 해당 타입으로의 인라이닝을 시도하고, 컴파일된 코드에 가드를 넣는다.
그리고 만약 객체 계층 분석(Class Hierarchy analysis, CHA) 결과로 현재까지 해당 메서드의 구현 클래스가 하나뿐이라면, 프로파일이 없어도 인라이닝 할 수 있다. 물론, 이때도 다른 구현이 로드되면 디옵티마이즈 할 수 있도록 assumption을 걸어둔다.
참고: JIT가 가상 호출을 인라이닝하는 경우 VM 코드
cmp [receiver_object의 클래스], KnownClass ; 대상 객체의 클래스 확인 jne SlowPath ; 다르면 느린 경로로 분기 ; --- 인라인된 메서드 본문 --- ... (메서드의 실제 내용에 해당하는 명령들) ... jmp EndLabel ; 메서드 끝으로 점프 SlowPath: ; (여기로 오면 deopt 혹은 가상 호출 수행) EndLabel:
WebAssembly
복사

적용 사례

Java의 다형성에 따른 비용을 줄여주는 최적화 기법으로 대표적으로 Java의 컬렉션에서 iterator를 사용하는 코드에서 JIT은 List.interator() 호출을 가드 및 인라이닝하여 ArrayList와 같은 특정 구현일 때는 직접 원소를 읽도록 최적화한다. 이 과정에서 Iterator 객체 생성도 Escape Analysis를 통해 제거되고 NoSuchElementException을 던지는 코드도 필요 없다고 판단되어 제거된다.
또한, invokeinterfaceinfokevirtual도 JIT 컴파일된 코드에서는 직접 호출로 대체되기에 CPU의 브랜치 예측도 유리해진다.

7. Profile-Guided Optimization(PGO) (프로파일 기반 최적화)

개념

애플리케이션 실행 중 수집한 프로파일 데이터를 이용해, JIT 컴파일러는 자주 실행되는 핫 코드 경로(hot path)에 대해 집중적으로 최적화를 적용하는 기법으로, 특정 분기문이 100번 중 99번은 true라는 것을 알 경우 참인 경로를 hot path로 최적화하고, false인 경로는 cold path로 취급해 덜 최적화하거나 아예 늦게 컴파일합니다.
위에서 설명한 가상 메서드 인라이닝과 같 JIT 컴파일러는 프로그램이 현재까지 어떻게 동작해왔는지를 기준으로 최적화 전략을 세우고, 나중에 패턴이 바뀌면 다시 수정합니다.
HotSpot에서는 피드백 지향 최적화(Feddback-Directed Optimization) 또는 어댑티드 최적화라고도 부른다.

최적화 예제

if (condition) { fastPath(); } else { slowPath(); }
Java
복사
⇒ PGO의 대표적인 예는 분기 예측 최적화로 위 코드에서 fastPath()가 95%의 확률로 실행될 경우, JIT 컴파일러는 프로파일링을 통해 condition이 true일 경우가 많다는 것을 알게되면 다음과 같이 최적화 해버린다.
fastPath() 의 코드를 분기 없이 직렬로 배치해서 어셈블리 상에서 별도의 점프 없이 곧바로 fastPath의 내용이 나오도록 한다.
slowPath()의 코드는 별도 영역에 둬서 만약 조건이 false일때만 점프하도록 만든다. 또는 uncommon trap이라는 기법을 써서 느린 경로로 실행해야 하면 바로 디옵티마이즈하여 인터프리터로 돌아가 처리하게 할 수도 있다.
이렇게 최적화가 되면 네이티브 코드에서 fastPath는 별도의 분기 없이 실행되도록 재배열된다.
혹은 메서드 선택형 인라이닝을 할 수도 있는데, A 메서드만 자주 호출될 경우 과감하게 A만 최적화하고 B는 단순 컴파일만 하여 컴파일 비용을 최소화 할 수 있다.

내부 동작

HotSpot JVM의 JIT은 단계적 컴파일(Tiered Compilation)전략을 사용한다.
처음에는 인터프리터로 실행하며 프로파일 수집하고, 일정 기준을 넘으면 C1 컴파일러가 간단히 컴파일하면서 추가 프로파일을 수집한다. 그리고 더 많이 호출되는 메서드는 C2 컴파일러가 프로파일 데이터를 이용해 본격적으로 최적화를 수행한다.
C2는 프로파일 정보를 IR에 주입하여, 분기문의 각 경로에 확률 가중치를 할당하고, 이를 토대로 레이아웃 최적화와 코드 분기 처리를 합니다.
또한, 가상 호출의 피호출 클래스 목록, 루프의 평균 반복 횟수, 메서드 호출 횟수등을 활용해 인라이닝 여부나 루프 언롤링 횟수를 결정한다. 그리고, Uncommon trap은 프로파일 상 드물게 실행되는 코드에 적용되는데, JIT 컴파일러는 해당 부분을 런타임에 트랩으로 바꿔놓고 실제 발생 시 디옵티마이즈한다.
이는 마치 이 분기가 나올 확률은 0.1% 이하이기에 일단 빼겠다. 라는 식이다.
JIT 컴파일러는 컴파일된 코드에 디옵트 체크를 심어두고, 만약 실행 중 트랩이 발생하면 안전하게 인터프리터로 돌아가거나 해당 메서드를 다시 컴파일한다. 이후 프로파일 카운터가 변하면 JIT는 해당 메서드를 다시 컴파일해 최적화 전략을 수정하는데, 이러한 적응형 재컴파일은 HotSpot 의 런타임에서 수시로 일어나는데, 만약 이를 눈으로 보고 싶다면 -XX:+PrintCompliation 옵션을 통해 메서드가 컴파일되었다가 무효화되고 다시 컴파일되는 로그를 볼 수 있다.

적용 사례

JVM은 처음부터 공격적인 최적화를 하기보단 충분한 데이터가 모일 때까지 기다렸다가 최적화함으로써, 잘못된 추측으로 인한 과학 최적화를 최소화한다. 실전에서 웜업(warm-up)시간동안 JVM은 프로파일을 모으고 최적화를 점진적으로 적용하여, 어느 순간부터 성능이 극대화된다.
이런 방식은 장시간 동작하는 서버 애플리케이션에서 중요한데, PGO의 효과는 특히 분기문이 많은 비즈니스 로직에서 두드러진다. 드물게 발생하는 예외적인 케이스들은 JIT 컴파일러가 알아서 코드에서 격리해주고, 일반 경로만 빠르게 실행되도록 한다.
뿐만 아니라, JIT은 재컴파일을 통해 특정 타입 특화 코드를 철회하고 일반 코드로 돌아갈수도 있다. 즉, 현재 프로그램에 가장 잘 맞는 형태로 코드를 조율하며 최상의 성능을 내도록 한다.

8. Exception Path Optimization(예외 경로 최적화)

개념

Java의 예외 처리로 인한 성능 손실을 최소화하기 위한 최적화 기법으로, Java에서는 NullPointerExceptionArrayIndexOutOfBoundsException과 같은 런타임 예외를 자주 던지는데, 정상적인 코드 경로에서는 이런 예외가 발생하지 않는다.
(자주 발생한다면 그건 개발자를 매우 쳐야 할 이슈가 아닐까 )
그렇다면, JIT컴파일러는 이렇게 드물게 던져지는 예외 처리 경로를 정상 경로와 분리해서 정상 경로가 아닌 모든 분기를 콜드 코드로 빼버리거나 예외를 감지하는 코드를 명시적으로 넣지 않을 수 있다.
즉, 정상적인 실행 경로에는 예외 처리 비용(명시적 체크, 분기 등)이 없도록 최적화 하는 기법이라고 할 수 있다.
가장 대표적인 예로 NPE 체크 제거와 예외적 상황의 uncommon trap 처리등을 들 수 있다.

최적화 예제

// 자바 코드 if (obj == null) { throw new NullPointerException(); } int x = obj.value; // 널이 아니면 접근 // JIT 컴파일 후 (암시적 null 체크) mov eax, [obj + #offset] ; obj.value를 읽는 x86 명령 (obj가 null이면 CPU fault 발생)
Java
복사
⇒ 가장 흔한 최적화는 NPE 체크 제거로 자바 바이트 코드에서 객체를 접근할 때마다 NPE 체크를 해야하는데 HotSpot JIT 컴파일러는 이것을 명시적인 if로 만들지 않고 암시적인 NPE 발생 기법을 사용한다. 예를 들어, obj.field 접근시 기계어에서 obj주소를 직접 참조하도록 생성하고, obj가 null이라면 CPU가 해당 주소를 읽으려 할 때 하드웨어 예외(segfault)를 일으키고 JVM은 이 시그널을 받아 NPE를 던진다. 이렇게 하면 null이 아닐 때 별도 분기 없이 바로 필드 접근을 수행하기에 NPE 비용이 사라진다.
그리고, try-catch-finally 구조에서도 JIT 컴파일러는 평상시 예외가 발생하지 않으면 성능 페널티가 없도록 구현한다.
try { // 작업 A // 작업 B } catch(Exception e) { // 예외 처리 }
Java
복사
⇒ 위 코드에 대한 JIT 컴파일 결과를 보면, 작업 A와 B에 대해서는 별도의 예외 핸들러 분기가 없이 순차 코드가 생성되지만, catch 블록은 주로 사용되지 않기에 이것을 메서드 말미나 별도 영역으로 빼놓고 만약, 예외가 던져지면, JVM이 그 시점에 스택 언와인딩을 처리하여 catch 블록으로 점프한다.
즉, 정상 흐름에선 try-catch로 인한 추가 오버헤드가 없고, 예외 시에만 runtime이 개입한다.

내부 동작

C2 컴파일러는 예외 처리 경로 프로파일리이이 없어도 를 드물게 실행되는 경로로 취급한다. 따라서 해당 경로에 있는 코드는 최대한 인라인하거나 최적화하지 않는다. 오히려 그런 코드는 uncommon trap을 사용해 아예 메인 코드에 포함하지 않고 트랩으로 바꾸기도 한다.
HotSpot 에서는 사용되지 않는 예외 경로 코드를 통째로 트랩으로 대체해 제거함으로써 예외 경로에 있던 객체들도 가상화 최적화(escape analysis)에 방해되지 않게 되고, 정상 경로 최적화가 더 잘 될수 있다.

적용 사례

예외는 느리고 catch블록을 통해 잡는 비용은 적지 않기 때문에 무분별한 try-catch는 지양해야 한다고 합니다.
하지만 JIT 덕분에 예외를 사용하지 않는 한 그 존재로 인한 비용은 무시 할 만하다. 즉, try-catch로 감싸도 예외가 발생하지 않는다면 성능차이가 0에 수렴할 수 있다는 것이다.
종합하면 예외 경로 최적화는 java 예외 처리 메커니즘이 갖는 필요할때만 비용을 지불한다는 철학을 구현하여 JIT 컴파일러가 정상적인 프로그램 흐름에서는 예외 관련 비용 부담이 거의 없도록 만들어준다.

출처