컴파일러의 이름은 네이티브 코드로 바꾼다는 것이지만, 이게 중요한게 아니다.
네이티브 코드로 바꿀 때 얼마나 효율적인 코드로 최적화할 수 있냐 가 컴파일러의 품질을 결정한다.
JIT 컴파일러는 컴파일 과정에서 LIR, HIR 을 대상으로 여러가지 최적화를 진행한다.

https://wiki.openjdk.org/spaces/HotSpot/pages/11829287/PerformanceTacticIndex
이번 글에서는 JVM 밑바닥까지 파헤치기 책에 나오는 4가지 최적화를 알아 볼 것이다.
이 네가지는 아주 기본적인 최적화이다.
메서드 인라인은 결과가 같다면 메서드 사용처를 메서드의 구현으로 대체(inline)하는 기법이다.
메서드 인라인은 두가지 이유로 중요한 최적화다.
public static void foo(Object obj) {
if (obj != null) {
System.out.pringln("do something");
}
}
public static void inline(String[] args) {
Object obj = null;
foo(obj);
}
다른 최적화의 기반이 된다는 말을 알아보자.
위 inline() 메서드는 죽은 코드(dead code) 이다. 죽은 코드 제거(흐름 인지 재작성) 최적화를 적용할 수 있어 보인다.
하지만 각각의 메서드로 보았을 때에는 죽은 코드일지 알 수 없다.
foo() 입장에서는 obj 가 null 이 아닌 값이 들어올 수 있고, inline() 입장에서는 foo() 에서 의미 있는 일을 할 수도 있기 때문이다.
이 두 메서드를 인라인하게 되면 죽은 코드 제거 최적화를 할 수 있다.
public static void afterInline(String[] args) {
Object obj = null;
if (obj != null) { // 인라인 됨
System.out.pringln("do something");
}
}
코드가 아무 동작을 하지 않음이 확실해 진다.
이처럼 인라인 최적화는 다른 최적화(이 예에서는 죽은 코드 제거)를 하기 위한 기반이 된다.
java 의 메서드는 javac 컴파일 타임에 직접 참조로 변환되는 몇몇의 케이스를 제외하고는 모두 가상 메서드 이다.
가상 메서드는 런타임에 리시버가 확정된 후 동적 디스패치를 진행해야 한다.
어떤 클래스의 메서드가 사용될지 리시버가 확정되기 전에는 모르기 때문에 인라인을 섣불리 할 수 없다.
반면 C++ 의 경우는 모든 메서드가 기본적으로 final(비가상) 메서드다.(다형성 필요시 virtual 키워드를 붙인다) 컴파일 타임에 리시버의 타입이 정해진다. 따라서 메서드 인라이닝이 수월하다.
자바에서도 다형성을 사용하지 않는다면 가상 메서드라도 인라이닝을 할 수 있을 것이다. 그 방법이 final 을 붙이는 것이다. 하지만 개발자가 인라이닝 최적화를 신경쓰면서 final 을 일일히 붙이는건 좋지 않다.
따라서 JVM 레벨에서 클래스 계층구조를 분석하여 가상 메서드가 오버라이딩을 사용하는지 판단 후 인라이닝을 진행하는 기능을 제공한다.
하지만 자바에서는 동적 클래스 로딩으로 클래스 계층구조는 항상 변할 수 있다. 따라서 JIT 컴파일러는 현재 메서드 오버라이딩이 한개 뿐이라서, 인라이닝할 수 있다 라는 가정/예측 을 기반으로 인라이닝한다.
이를 guided inlining 이라 하며, 따라서 자바의 인라이닝은 급진적 예측 최적화에 속한다.
동적 클래스 로딩에 의해 클래스 계층이 바뀌면 인터프리터 모드로 돌아간다.
클래스 계층 구조를 분석했을 때 오버라이딩 메서드가 하나면 위처럼 예측 컴파일을 하면 된다.
반면 만약 오버라이딩 메서드가 많아 리시버의 동적 디스패치가 필요하면 인라인 캐시 방식을 사용한다.
메서드의 호출은
vtable을 사용하여 메서드 영역에서 실제 메서드를 찾는 동작을 줄인다.
하지만 이 동작 자체도 이 인라인 캐시로 최적화된다.
호출 지점(callsite) 에 캐시를 넣는다는 점에서 인라인 캐시이다.
메서드 인라이닝과 다르다. 무언가 코드가 인라인 되는 것이 아니다.
메서드 안에서 생성한 객체가 메서드 밖으로 나가냐 안나가냐를 판단하는 것을 탈출 분석이라 한다.
탈출 분석 결과는 여러가지 최적화의 기반, 근거가 된다.
탈출 분석 결과 객체가 스레드에서 탈출하지 않는다고 확신하는 경우
객체를 힙 대신 스택에 할당할 수 있다.
메서드가 끝나 스택 프레임이 없어지면 자동으로 객체 할당 메모리가 정리되므로, 가비지 컬렉팅 비용을 많이 줄일 수 있기 때문이다.
탈출 분석 결과 객체가 메서드를 벗어나지 않는다고 확신하는 경우
객체를 스칼라 타입으로 분해하여 객체 필드 각각이 지역 변수 처럼 쓰일 수 있도록 한다.
객체 생성 비용이 줄어들며, 새로운 최적화가 가능해질 수 있다.
탈출 분석 결과 다른 스레드에서 접근할 수 없는 변수가
동기화 블럭에 사용된 경우 동기화 블럭을 제거할 수 있다.
탈출 분석은 비용이 크기 때문에 Java 의 JIT 에서 적용하기 부담스럽다.
탈출 여부를 정확하게 판별하려면 복잡한 프로시저간 분석이 필요하기 때문이다.
따라서 C++ 이나 C# 에 비해 탈출 분석의 정확도가 조금 뒤떨어지는 알고리즘을 사용한다.
발할라 프로젝트의 값객체가 완성될 경우, 스칼라 치환이 좀 더 효율적이게 동작할 수 있다.
public int test(int x) {
int xx = x + 2;
Point p = new Point(xx, 42);
return p.getX();
}
public int test(int x) {
int xx = x + 2;
Point p = point_memory_alloc();
p.x = xx; // 생성자 인라인
p.y = 42; // 생성자 인라인
return p.x; // Point::getX() 인라인
}
public int test(int x) {
int xx = x + 2;
int px = xx; // Point 타입 객체 p 삭제 후 스칼라 치환
int py = 42;
return px;
}
public int test(int x) {
return x + 2;
}
// java -XX:+PrintEscapeAnalysis -XX:+PrintEliminateAllocations Escape.java
public class Escape {
static class Point {
int x;
int y;
public Point(int x, int y) { this.x = x; this.y = y; }
public int getX() { return x; }
}
public static int test(int x) {
int xx = x + 2;
Point p = new Point(xx, 42);
return p.getX();
}
public static void main(String[] args) {
for (int i = 0; i < 100000; i++) { // JIT 컴파일 촉발
test(10);
}
}
}
-XX:+PrintEscapeAnalysis : 탈출 분석 결과 출력-XX:+PrintEliminateAllocations : 스칼라 치환 결과 출력-XX:+PrintEliminateLocks : 동기화 제거 결과 출력디버그 모드로 빌드된 JVM 에서 위 옵션을 사용할 수 있다.
표현식 E 가 이미 평가되었고, E 에 등장하는 모든 변수값이 평가 이후 변하지 않는다면, 이후의 등장하는 표현식 E 를 공통 하위 표현식이라 한다.
int d = (c * b) * 12 + a + (a + b * c);
(cb) 와 (bc) 는 같다.
앞의 (cb) 가 계산되었다면 (bc) 는 하위 표현식으로서 제거되고 이전 결과값을 사용할 수 있다.
이를 공통 하위 표현식 제거라고 한다.
공통 하위 표현식 제거는 클래스 파일 바이트 코드 명령어 수(연산의 수)를 줄일 수 있다.
자바는 안전한 언어이다.
배열 사용시 인덱스에 크기보다 큰 값이 들어오면 IndexOfOutBoundsException 이 발생한다.
이 체크는 배열 접근마다 발생하는 작업이다. 특히 순환문이라면 이 작업으로 인한 부하가 더 커진다.
컴파일 시점에 코드를 분석하여 배열 접근이 범위를 벗어나지 않음이 확실하면, 이 범위 체크(경계 검사) 로직을 제거할 수 있다.
같은 작업도 비슷하게 자바가 안정성을 위해 실행하는 로직이지만 JIT 컴파일시 최적화 될 수 있다.
암묵적 예외 최적화
JIT 컴파일시 검사 코드를 제거하고, CPU/OS가 던지는 하드웨어 예외(트랩) 를 이용해서 자바 예외를 처리하는 최적화
어차피 예외가 나야 하는 상황이라면, 미리 if (x==null) throw NPE; 같은 명시적 체크를 매번 넣는 대신, 실제로 문제가 발생할 때만 하드웨어 예외가 터지게 하고 그걸 자바 예외로 바꿔치기한다.
숨겨진 하드웨어 예외를 사용한다는 점에서 암묵적 이란 이름이 붙음
배열 경계 검사 제거도 암묵적 예외 최적화 방식으로 경계 검사를 제거
if (foo != null) { return foo.value; } else { throw new NullPointerException(); }를
try { return foo.value; // 체크 없이 리턴으로 변경 } catch (segment_fault) { // 하드웨어 예외 catch // 하드웨어 예외를 NPE 로 대체하는 핸들러 // 스레드를 커널 모드로 변경해야 하기 때문에 느리다. // 따라서 JIT 모니터링 정보 상 NPE 확률이 적을때 효율적이다. uncommon_trap(); }