Chapter 10. JIT 컴파일의 세계로
10.1 JITWatch란?
핫 패스에 있는 컴파일 대상 메서드를 분석 대상으로 삼아야 한다. 인터프리티드 메서드는 최적화 대상으로 적절치 않다.
- JITWatch는 객관적인 비교에 필요한 측정값을 제공한다.
- JITWatch는 핫스팟 컴파일 상세 로그를 파싱/분석하여 결과를 자바FX GUI 형태로 보여준다.
- 애플리케이션 실행 시 다음 세 플래그를 추가해야 JITWatch가 동작한다.
-XX:+UnlockDiagnosticVMOptions -XX:+TraceClassLoading -XX:+LogCompilation
10.1.1 기본적인 JITWatch 뷰
- JITWatch엔 JIT 작동을 시험해볼 수 있는 샌드박스(sendbox) 환경을 제공한다.
- 프로그램을 빠르게 프로토타이핑하여 JVM의 JIT 결정을 확인할 수 있다.
- 샌드박스는 프로그램을 바이트코드로 컴파일한 후 JIT 로그를 켜 JVM에서 실행한 뒤 JIT 로그 파일을 JITWatch에 로드한다.
- 샌드박스는 작은 변화가 일어났을 때 JVM의 최적화 기법 선택을 포착하여 피드백한다.
- 샌드박스는 VM 스위치를 시험해볼 수 있는 환경을 제공한다.
- 설정을 바꾸면 JVM 성능에 심각한 영향을 줄 수도 있으니 운영계에서는 권장하지 않는다.
- 풀 사이즈 애플리케이션에선 JVM이 훨씬 광범위한 코드를 대상으로 최적화를 조합하기에 샌드박스와는 조금 다르다.
- 때문에 샌드박스보다 더 정교한 애플리케이션 컴파일 뷰가 필요할 수도 있다.
- JITWatch는 3단뷰라는 뷰를 메인으로 제공한다.
- 소스 코드가 바이트코드, 어셈블리 양쪽으로 어떻게 컴파일됐는지 알 수 있다.
- 자바 9부터 분할 코드 캐시가 생겨 컴파일드 메서드, 논프로파일드 메서드, VM 자체 네이티브 코드를 별도 영역에 저장한다.
- 자바 8 이전에는 하나의 코드 캐시 영역에 담았다.
- 단편화 및 스위퍼 시간을 단축하고 풀 컴파일드 코드의 지역성을 높일 수 있따.
10.1.2 디버그 JVM과 hsdis
- 디버그 JVM - 운영 JVM 보다 더 상세한 디버깅 정보를 얻기 위해 제작한 가상 머신
- 심도있는 튜닝을 위한 JIT 통계치를 얻으려면 필요
- 성능 희생을 감수해야 한다.
- 핫스팟 디버그 JVM은 OpenJDK 소스에서 빌드할 수 있다.
- JIT 컴파일러가 생성한 역어셈블된 네이티브 코드를 보려면 hsdis 같은 역어셈블리 바이너리가 필요하다.
10.2 JIT 컴파일 개요
- 핫스팟은 프로파일 기반 최적화(PGO)를 이용해 JIT 컴파일 여부를 판단한다.
- MDO(method data object) - 핫스팟이 내부적으로 실행 프로그램 정보를 저장하는 구조체
- 바이트코드 인터프리터와 C1 컴파일러에서 JIT 컴파일러가 언제 무슨 최적화를 할지 결정하는 데 필요한 정보를 기록
- 프로파일링 데이터가 모여 컴파일 결정을 내리면 컴파일러별 세부 처리 절차로 넘어간다.
- 컴파일할 코드 내부 표현형을 빌드 (구체적인 표현형은 컴파일러(c1 or c2)에 따라 다르다.)
- 핫스팟 JIT 컴파일러는 내부 표현형을 토대로 다양한 최적화 기법을 총동원한다.
- 인라이닝
- 루프 펼치기
- 탈출 분석
- 락 생략/확장
- 단일형 디스패치
- 인트린직
- 온-스택 치환
- 이러한 최적화 기법은런타임 정보와 지원 여부에 따라 완전 달라질 수 있다.
- 핫스팟의 C1, C2 컴파일러 역시 위 기법을 상이하게 조합한다.
- C1은 추측성 최적화를 하지 않는다.
- C2는 공격적인 최적화기로 런타임 정보를 토대로 추정하여 최적화를 수행한다.
- 추측성 최적화를 하기 전에 가드(guard)라는 타당성 검사를 수행한다.
- 공격적인 최적화로 성능 향상이 될 수도 있지만 가드가 실패하면 역최적화를 수행한다.
10.3 인라이닝
- 인라이닝은 호출된 메서드를 호출한 지점에 복사하는 기법
- 호출부에서 메서드를 호출할 때 발생하는 오버헤드를 제거할 수 있다.
- 전달할 매개변수 세팅
- 호출할 메서드를 정확하게 룩업
- 새 호출 프레임에 맞는 런타임 자료 구조(지역 변수 및 평가 스택 등) 생성
- 새 메서드로 제어권 이송
- 호출부에 결과 반환
- 인라이닝은 관문 최적화라고도 한다.
- JIT 컴파일러가 제일 먼저 적용하는 기법
- 메서드 경계를 없애고 연관된 코드를 한데 모은다.
- 인라이닝 최적화 장점
- 개발자는 재사용 가능한 코드를 작성할 수 있다.
- 손수 마이크로 최적화를 할 필요가 없다.
- 인라이닝은 다른 최적화 범위를 확장시키는 역할을 한다.
- 탈출 분석, DCE(dead code elimination, 죽은 코드 제거), 루프 펼치기, 락 생략
10.3.1 인라이닝 제한
- VM 차원에서 아래 항목을 조정해야할 때 인라이닝 제한을 걸어야 할 경우가 있다.
- JIT 컴파일러가 메서드를 최적화하는 데 소비하는 시간
- 생성된 네이티브 코드 크기 (즉 코드 캐시 메모리 사용량)
- 제한이 없다면 아주 깊은 호출 체인까지 인라이닝할 것이고 코드 캐시 공간이 줄어든다.
- 핫스팟은 다음 항목으로 인라이닝 여부를 결정한다.
- 인라이닝할 메서드의 바이트코드 크기
- 현재 호출 체인에서 인라이닝할 메서드 깊이
- 컴파일된 메서드 버전이 코드 캐시에서 차지하는 공간
10.3.2 인라이닝 서브시스템 튜닝
스위치 |
디폴트(JDK 8, Linux x86_64) |
설명 |
-XX:MaxInlineSize= |
35바이트의 바이트코드 |
메서드를 이 크기 이하로 인라이닝 |
-XX:FreeqInlineSize= |
325바이트의 바이트코드 |
핫 메서드를 이 크기 이하로 인라이닝 |
-XX:InlineSmallCode= |
1000바이트의 네이티브 코드(단계 없음), 2000바이트의 네이티ㅡㅂ 코드(단계 있음) |
코드 캐시에 이 수치보다 더 많은 공간을 차지한 최종 단계 컴파일이 존재하는 경우 인라이닝하지 않는다. |
-XX:MaxInlineLevel= |
9 |
이 수준보다 깊은 호출 프레임을 인라이닝하지 않는다. |
- 중요 메서드가 인라이닝되지 않는 경우 이런 메서드까지 인라이닝되도록 JVM 매개변수를 조정할 수도 있다.
- 매개변수를 바꾸며 튜닝할 땐 반드시 측정 데이터를 근거로 삼아야 한다.
10.4 루프 펼치기
- 루프 내부의 메서드 호출을 전부 인라이닝 후 컴파일러는 루프 순회 비용을 살핀다.
- 백 브랜치가 일어나면 CPU는 유입된 명령어 파이프라인을 덤프하기에 성능상 좋지 않다.
- 백 브랜치 - 한 번 순회를 마치고 루프문 처음으로 돌아가는 것
- 보통 루프 바디가 짧을수록 백 브랜치 비용이 상대적으로 높기에 다음 기준에 따라 루프 펼치기 여부를 결정한다.
- 루프 카운터 변수 유형 (대부분 객체 아닌 int, long)
- 루프 보폭(한 번 순회할 때마다 루프 카운터 값이 얼마나 바뀌는지)
- 루프 내부 탈출 지점 개수(return 또는 break)
10.4.1 루프 펼치기 정리
- 핫스팟은 다양한 최적화 기법으로 루프 펼치기를 한다.
- 카운터가 int, short, cha형일 경우 루프를 최적화
- 루프 바디를 펼치고 세이프포인트 폴을 제거
- 루프를 펼치면 백 브랜치 횟수가 줄고 그만큼 분기 예측 비용도 덜하다.
- 세이프포인트 폴을 제거하면 순회할 때마다 하는 일이 줄어든다.
세이프포인트 폴 - 세이프포인트 도달 여부를 폴링하여 체크하는 코드이다. JIT 컴파일러는 컴파일드 코드가 오랫동안 세이프포인트 플래그 체크 없이 실행되지 않도록 세이프 포인트 검사 코드를 삽입한다.
10.5 탈출 분석
- 범위 기반 분석(탈출 분석)
- 어떤 메서드가 내부에서 수행한 작업을 그 메서드 경계 밖에서도 볼 수 있는지 판별
- 또는 부수 효과를 유발하지 않는지 판단
- 메서드 내부에서 할당된 객체를 메서드 범위 밖에서 바라볼 수 있는지 알아보는 용도
탈출 분석 최적화는 반드시 인라이닝 수행 이후 시도한다. 호출부에 피호출부 메서드를 복사하면 호출부에 메서드 인수로 전달된 객체는 더 이상 탈출 객체로 표시되지 않기 때문
- 핫스팟은 잠재적으로 탈출한 객체를 세 유형으로 분류한다.
NoEscape = 1
- 객체가 메서드/스레드를 탈출하지 않고 호출 인수로 전달되지 않으며 스칼라로 대체 가능
ArgEscape = 2
- 객체가 메서드/스레드를 탈출하진 않지만 호출 인수로 전달되거나 레퍼런스로 참조되며 호출 도중에는 탈출하지 않는다.
GlobalEscape = 3
- 객체가 메서드/스레드를 탈출한다.
10.5.1 힙 할당 제거
10.5.2 락과 탈출 분석
- 핫스팟은 탈출 분석과 관련 기법으로 락 성능도 최적화한다.
이 최적화는 synchronized
를 사용한 인스린직 락에만 해당되며 java.util.concurrent
패키지의 락에는 적용되지 않는다.
- 락 최적화 핵심
- 비탈출 객체에 있는 락은 제거한다. (락 생략)
- 같은 락을 공유한, 락이 걸린 연속된 영역은 병합한다. (락 확장)
- 락을 해제하지 않고 같은 락을 반복 획득한 블록을 찾는다. (중첩 락)
10.5.3 탈출 분석의 한계
- CPU 레지스터나 스택 공간은 상대적으로 희소한 리소스
- 기본적으로 원소가 64개 이상읜 배열은 핫스팟에서 탈출 분석 혜택을 볼 수 없다.
- 이 개수 제한은
-XX:EliminateAllocationArraySizeLimit=<n>
스위치로 조정할 수 있다.
- 핫스팟은 부분 탈출 분석을 지원하지 않는다.
- 객체가 어느 분기점에서 메서드 범위를 탈출하면 탈출 분석 최적화가 적용되지 않는다.
for (int i = 0; i < 100_000_000; i++) {
Object mightEscape = new Object(i)
if (condition) {
result += inlineableMethod(mightEscape);
} else {
result += tooBigToInline(mightEscape);
}
}
- 아래 코드처럼 비탈출 분기 조건 안에 객체 할당을 묶어야 탈출 분석 덕을 볼 수 있다.
for (int i = 0; i < 100_000_000; i++) {
if (condition) {
Object mightEscape = new Object(i)
result += inlineableMethod(mightEscape);
} else {
Object mightEscape = new Object(i)
result += tooBigToInline(mightEscape);
}
}
10.6 단형성 디스패치
- 핫스팟 C2 컴파일러의 추측성 최적화는 대부분 경험적 연구 결과를 토대로 한다.
- 단형성 디스패치는 아래의 경험적 사실을 기반으로 하는 추측성 최적화 기법이다.
- 어떤 객체의 메서드를 호출할 때 그 메서드를 최초 호출한 객체의 런타임 타입을 알아내면 이후 호출도 동일 타입일 가능성이 크다.
- 이 추측성 가정이 옳다면 해당 호출부의 메서드 호출을 최적화할 수 있다.
- 단형성 디스패치의 메서드 호출 최적화
- klass 포인터 및 vtable을 통해 가상 룩업을 하고 에둘러 메서드를 참조하는 일은 한 번만 하면 된다.
- 항상 타입이 같으니 invokevirtual 명령어를 퀵 타입 테스트(가드) 후 컴파일드 메서드 바디로 분기하는 코드로 치환하면 된다.
- 단형성 디스패치를 사용하다가 호출부의 런타임 타입이 변경되면 핫스팟은 풀 가상 디스패치를 이용하는 방식으로 되돌린다.
- 메서드 디스패치와 관련된 성능에 관한 전체 내용은 알렉세이 쉬필레프의 ‘(자바) 메서드 디스패치의 흑마술’ 블로그를 참고
10.7 인트린직
- 인트린직 - JIT 서브시스템이 동적 생성하기 이전에 JVM이 이미 알고 있는 고도로 튜닝된 네이티브 메서드 구현체
- 주로 OS나 CPU 아키텍처 특정 기능을 응용하는 성능이 필수적인 코어 메서드
- 플릿폼에 따라 지원 여부가 상이하다.
- JVM은 런타임에 자신을 실행한 하드웨어 CPU를 살펴보고 사용 가능한 기능을 목록화한다.
인트린직은 C1/C2 JIT 컴파일러 및 인터프리터에도 구현 가능하다.
메서드 |
설명 |
java.lang.System.arraycopy() |
CPU의 백터 지원 기능으로 배열을 빨리 복사 |
java.lang.System.currentTimeMillis() |
대부분 OS가 제공하는 구현체가 빠름 |
java.lang.Math.min() |
일부 CPU에서 분기 없이 연산 가능 |
기타 java.lang.Math 메서드 |
일부 CPU에서 직접 명령어를 지원 |
암호화 함수 (ex. AES) |
하드웨어로 가속하면 성능이 매우 좋아짐 |
자바 9부터 메서드 앞에 @HotSpotIntrinsicCandidate
애너테이션을 붙여 인트린직을 사용할 수 있음을 나타낸다.
10.8 온-스택 치환
- 컴파일할 정도로 호출 빈도가 높지 않지만 메서드 내부에 핫 루프가 포함된 경우가 있다.
- 핫스팟은 이런 코드를 온-스택 치환(OSR)을 이용해 최적화한다.
- 루프 백 브랜치 횟수를 세어보고 한계치를 초과하면 루프를 컴파일한 후 치환해서 실행
- 컴파일러는 컴파일 이전의 흐름과 변화가 컴파일 이후에도 반영되도록 보장해야 한다.
- 루프 내에서 엑세스하는 지역 변수와 락 등의 상태 변화
10.9 세이프포인트 복습
- GC STW 이벤트뿐만 아니라 다음 경우에도 전체 스레드가 세이프포인트에 걸린다.
- 메서드를 역최적화
- 힙 덤프를 생성
- 바이어스 락을 취소
- 클래스를 재정의 (가령, 인스트루먼테이션 용도로)
- 컴파일드 코드에서 세이프포인트 체크 발급은 JIT 컴파일러가 담당
- 핫스팟에서 다음 지점에 세이프포인트 체크 코드를 넣는다.
- 때문에 스레드가 세이프포인트에 도달하려면 시간이 소요될 수 있다.
- 가령 메서드를 전혀 호출하지 않고 많은 산술 연산 코드가 포함된 루프를 실행할 경우
- 루프가 펼쳐져 있으면 더 시간이 걸릴 것이다.
- 컴파일러는 다음 두 비용 사이에서 고민할 것이다.
- 세이프포인트를 폴링하며 체크하는 비용
- 이미 세이프포인트에 닿은 스레드가 다른 스레드도 세이프포인트에 모두 닿을 때까지 대기하는 긴 시간 (time to safepoint, TTSP)
10.10 코어 라이브러리 메서드
JDK 코어 라이브러리 크기가 JIT 컴파일에 주는 영향 살펴보기
10.10.1 인라이닝 하기 적합한 메서드 크기 상한
- 클래스 파일을 정적 분석하면 인라이닝 하기에 큰 메서드를 솎아낼 수 있다.
- 인라이닝 여부는 메서드의 바이트코드 크기로 결정되므로
- JarScan이라는 오픈 소스 툴로 바이트코드 크기가 한계치 이상인 메서드를 모두 찾을 수 있다.
도메인에 특정한 메서드로 성능 개선
java.lang.String
의 toUpperCase(),
toLowerCase()
는 439바이트라 인라이닝 범위를 벗어난다.
- 대/소문자를 바꾸면 저장할 캐릭터 개수가 달라지는 로케일이 존재하기 때문에 크기가 커진 것
toUpperCase()
를 도메인에 특정한 메서드로 만들어 바이트코드 크기를 인라이닝 한계치 이하루 줄일 수 있다.
- 다양한 캐릭터셋을 고려할 필요가 없고 ASCII 캐릭터만 입력 받는 경우
- ASCII 전용 구현체로 만들어 컴파일하면 69바이트밖에 안 된다.
- ASCII 전용 버전이 코어 라이브러리보다 약 2.4배 초당 처리 건수가 많다.
메서드를 작게 유지하면 좋은 점
- 메서드를 작게 만들면 인라이닝 가짓수가 늘어난다.
- 런타임 데이터가 다양해질수록 여러 상이한 경로를 거치며 코드가 ‘핫’하게 될 가능성이 있다.
- 메서드를 작게 유지하면 다양한 인라이닝 트리를 구축해 핫 경로를 더욱 최적화할 여지가 생긴다.
- 메서드가 커지면 인라이닝 한계치를 초과해 최적화되지 않게 된다.
10.10.2 컴파일하기 적합한 메서드 크기 상한
- 핫스팟에는 컴파일되지 않는 메서드 크기 한계치가 있다. (8000 바이트)
- 컴파일 불가한 크기의 메서드는 핫 코드에서 발견된 가능성은 크지 않다.
- 코어 라이브러리 중 UI 서브시스템을 초기화하거나 화폐, 국가, 로케일명 목록 등의 리소스를 제공하는 부류의 메서드에서 발견된다.
- 자동 생성된 코드에서 거대한 메서드가 발견되는 사례가 많다.
- 가령 쿼리 자동 생성 소프트웨어에서 쿼리가 점점 복잡해져 한계치에 다다를 것 같으면 JarScan으로 한 번 메서드 크기를 확인해보는 게 좋다.