JIT 컴파일
우리가 작성한 소스코드를 컴퓨터가 실행하도록 만들기 위해서는 컴퓨터의 명령어로 변환해줘야 한다. C/C++같은 언어들은 실행하려는 컴퓨터의 환경에 맞게 미리 컴파일해서 실행가능 파일을 만든다. 반면, Python이나 JavaScript같은 인터프리터 언어들은 소스코드를 한 줄씩 읽어 실행가능한 명령어로 변환한다. 컴파일 언어는 실행가능 파일을 실행만 하면 되기 때문에 비교적 성능이 좋고, 인터프리터 언어는 속도는 비교적 느리지만 인터프리터가 있는 환경이라면 어디서든 같은 코드를 실행시킬 수 있다는 장점이 있다.
Java는 독특하게 컴파일 언어의 특징과 인터프리터 언어의 특징을 모두 가진다. 소스코드를 컴파일해서 바이트 코드로 변환하고, 바이트 코드는 JVM의 실행 엔진에 있는 인터프리터로 한 줄씩 실행된다. 이러한 특징은 컴파일한 바이트 코드를 하드웨어 환경에 관계없이 실행할 수 있도록 만들어주는 반면, 성능의 한계를 분명히 하기도 했다.
그러나 Java 개발자들은 이러한 성능의 한계를 개선할 수 있는 방법을 찾아냈다. 자주 사용되는 코드를 미리 기계어로 변환해두고 캐시하여 성능을 개선하는 방법이다. '자주 사용되는 코드'를 Hot Spot이라고 하며, 이렇게 런타임에 컴파일하는 방법을 JIT(Just In Time) 컴파일이라고 한다.
JIT 최적화 기법
JIT 컴파일러가 처음 도입된 HotSpotVM은 1999년에 출시되어 20년 이상 사용되고있다. 그동안 JIT 컴파일 기술에도 많은 변화가 있었고, 다양한 최적화 기법을 통해 성능 개선이 이루어졌다. 단순히 컴파일을 미리 해두는 것을 넘어서 성능 최적화를 하는 대표적인 기법들을 알아보도록 하자.
메소드 인라이닝(Method Inlining)
메소드 인라이닝은 메소드를 호출한 지점(Caller)에 메소드(Callee)의 내용을 복사하여 메소드 호출에 필요한 오버헤드를 줄이는 방법이다. 일반적으로 메소드를 호출하면 호출할 메소드를 디스패치하고, 파라미터를 전달하고, 스택 프레임을 생성하며, 결과값을 전달하는 등의 과정이 필요하다. 따라서 메소드 인라이닝을 하면 이러한 오버헤드를 없앨 수 있다.
예를 들어, 두 정수를 더하는 간단한 메소드를 생각해보자.
private static int add(int x, int y) {
return x + y;
}
이렇게 간단한 코드를 위해 메소드 호출을 하는 것은 오버헤드가 크다.
int result = add(x, y);
따라서 JIT 컴파일러에 의해 최적화된 바이트 코드는 아래와 같이 실행될 수 있다.
int result = x + y;
그러나 메소드 인라이닝이 항상 일어나는 것은 아니다. JIT 컴파일러에 의해 컴파일된 코드는 코드 캐시라는 공간에 저장된다. 코드 캐시는 공간의 한계가 있기 때문에 모든 메소드를 컴파일해서 저장할 수 없다. 따라서 JIT 컴파일러는 컴파일하려는 메소드의 바이트 코드 크기, 컴파일 후의 크기 등에 따라 컴파일할 메소드를 결정한다.
루프 펼치기(Loop Unrolling)
루프 펼치기는 루프의 반복 횟수를 줄이는 방법이다. 루프가 한 번 실행되고 처음으로 돌아가는 것을 '백 브랜치'라고 하는데, 백 브랜치가 일어나는 것은 성능에 좋지 않다. 파이프라이닝으로 동시에 실행되는 명령어들이 버려지기 때문이다. 또한, 매번 루프를 실행할지 여부를 체크해야하는 것 또한 오버헤드다. 따라서 루프의 반복 횟수를 줄이면 성능 감소를 줄일 수 있다.
예를 들어, 아래와 같은 루프가 있다고 해보자.
int sum = 0;
for (int i = 0; i < 1000000; i++) {
sum += data[i];
}
위 코드에서 데이터를 가져와 sum에 덧셈을 하는 것을 100만번 반복한다. 한 번 반복이 될 때마다 sum에만 연산이 일어나는 것이 아니라, i도 업데이트하고, 반복을 종료할 것인지도 체크해야한다. 이러한 과정은 i의 범위가 미리 정해져있는 경우에는 최적화가 가능하다.
int sum = 0;
for (int i = 0; i < 1000000; i += 2) {
sum += data[i];
sum += data[i + 1];
}
위와 같이 코드를 변경하면 결과는 같지만 경계를 검사하는 횟수가 절반으로 줄어든다. 이러한 방식을 루프 펼치기라고 한다.
그러나 루프 펼치기는 항상 일어나지는 않는다. 내부에 복잡한 분기가 일어나거나 인라이닝 되지 않은 메소드가 있으면 루프 내부의 실행을 예상하기 어렵다. 또한, 위와 같이 카운터를 int 타입이나 char, short으로 사용하면 루프 펼치기가 일어날 수 있지만, long 타입이면 일어나지 않는다.
탈출 분석(Escape Analysis)
탈출 분석은 메소드 내에서 생성된 객체를 외부에서 참조하는지를 분석하는 것이다. 만약 외부에서 객체를 사용하지 않는다면 굳이 힙 메모리 할당을 받을 필요가 없기 때문이다. 대신, 레지스터나 스택 프레임 내에 지역변수에 데이터를 저장할 수 있다.
탈출 분석에서는 객체를 3가지로 분류한다. NoEscape는 객체가 메소드 내부에서만 사용되는 것이고, ArgEscape는 객체가 메소드 밖으로 탈출하지는 않지만, 다른 메소드에서 참조하는 것이다. GlobalEscape는 static 변수와 같이 메소드 외부로 탈출하는 것이다. 객체가 NoEscape로 판단되면 힙에 메모리를 할당해 객체를 생성하는 대신, 지역변수로 대체하는 방식으로 최적화할 수 있다.
예를 들어, 아래와 같은 클래스를 생각해보자.
public class Point {
int x;
int y;
Point(int x, int y) {
this.x = x;
this.y = y;
}
}
아래 메소드에서 사용되는 Point 객체는 NoEscape로 분류할 수 있다.
public int noEscape() {
Point p = new Point(5, 10);
System.out.println("x = " + p.x + ", y = " + p.y);
}
이렇게 간단한 객체는 지역 변수로 대체할 수 있다.
public int noEscape() {
int x = 5;
int y = 10;
System.out.println("x = " + x + ", y = " + y);
}
만약, 아래와 같이 다른 메소드에서 객체를 사용하는 ArgEscape의 경우는 어떻게 될까?
public int argEscape() {
Point p = new Point(5, 10);
printPoint(p);
}
private static void printPoint(Point p) {
System.out.println("x = " + p.x + ", y = " + p.y);
}
printPoint 메소드가 메소드 인라이닝이 된다면 위에서 살펴본 noEscape 메소드와 똑같이 변한다. 따라서 ArgEscape에서 NoEscape로 바뀌고, 지역변수로 대체될 수 있다. 따라서, 탈출 분석은 메소드 인라이닝이 일어난 후에 시작된다.
탈출 분석은 상대적으로 작은 레지스터나 스택 공간을 사용하기 때문에 큰 객체에서는 일어나지 않는다. VM 옵션으로 바꿀 수 있지만, 기본적으로 사이즈가 64 이상인 배열은 최적화되지 않는다. 또한, 내부적으로 분기에 따라 탈출 여부가 달라진다면 최적화가 일어나지 않는다는 한계가 있다.
[Escape Analysis] https://wiki.openjdk.org/display/HotSpot/EscapeAnalysis
3줄 요약
- 메소드 인라이닝으로 메소드 호출의 오버헤드를 없앨 수 있다
- 루프 펼치기로 루프 검사에 의한 오버헤드를 줄일 수 있다
- 탈출 분석으로 객체 할당에 의한 오버헤드를 줄일 수 있다
'Java > 자바 최적화' 카테고리의 다른 글
[Java] 자바 최적화(12) - Java Collections Framework (0) | 2023.04.27 |
---|---|
[Java] 자바 최적화(11) - JIT 최적화 기법(2) (0) | 2023.04.23 |
[Java] 자바 최적화(9) - 바이트 코드 실행 (0) | 2023.04.09 |
[Java] 자바 최적화(8) - GC 모니터링 (0) | 2023.04.06 |
[Java] 자바 최적화(7) - 최신 가비지 컬렉터 (0) | 2023.04.02 |