Skip to main content

10. JIT 컴파일의 세계로

JVM JIT에 대해서 좀 더 자세하게 들어갑니다.

JITWatch#

다음의 기능을 제공합니다.

  • 애플리케이션 실행 중에 핫스팟이 실제로 바이트코드에 무슨 일을 했는지 이해하는데 도움이 됩니다.
  • 객관적인 비교에 필요한 측정값을 제공합니다.
  • 실행 중인 자바 애플리케이션이 생성한 핫스팟 컴파일 상세 로그를 파싱/분석해서 그 결과를 자바 FX GUI 형태로 보여줍니다.
    • 다음 플래그를 추가해야합니다.
    • -XX:+UnlockDiagnosticVMOptions -XX:+TraceClassLoading -XX:+LogCompilation

디버그 JVM과 hsdis#

  • 디버그 JVM을 통해서 JIT 서브시스템의 통계칭 등의 상세 디버깅 정보를 얻을 수 있습니다.

JIT 컴파일 개요#

  • VM이 데이터를 어떻게 수집하는지, 실행 프로그램에 어떤 최적화를 수행하는지 잘 알고 있어야 툴에서 컴파일드 코드를 보면서 올바르게 해석할 수 있습니다.
  • 핫스팟은 PGO를 이용해 JIT 컴파일 여부를 판단합니다.

핫스팟 JIT 컴파일러는 다양한 최신 컴파일러 최적화 기법을 동원합니다.

  • 인라이닝
  • 루프 펼치기
  • 탈출 분석
  • 락 생략/확장
  • 단일형 디스패치
  • 인트린직
  • 온-스택 치환

아래는 이를 설명합니다. 다만, 최적화 기법은 런타임 정보와 지원 여부에 따라 다소 달라집니다.


인라이닝#

호출된 메서드의 호출한 지점에 복사하는 것입니다.

메서드 호출 시 다음과 같은 오버헤드를 제거할 수 있습니다.

  • 전달할 매개변수 세팅
  • 호출할 메서드를 정확하게 룩업
  • 새 호출 프레임에 맞는 런타임 자료 구조(지역 변수 및 평가 스택 등) 생성
  • 새 메서드로 제어권 이송
  • 호출부에 결과 반환 (결과값이 있는 경우)

인라인은 JIT 컴파일러가 제일 먼저 적용하는 최적화라서 관문 최적화라고도 합니다. 또한 다른 최적화 범위를 확장시키는 역할도 합니다.

  • 탈출 분석, 죽은 코드 제거, 루프 펼치기, 락 생략

인라이닝 제어#

때로는 VM 차원에서 인라이닝 서브시스템에 제한을 걸어야하는 경우도 있습니다.

  • JIT 컴파일러가 메서드를 최적화하는데 소비하는 시간
  • 생성된 네이티브 코드크기

핫스팟은 다음 항목을 따지면서 어떤 메서드를 인라이닝할 지 결정합니다.

  • 인라이닝할 메서드의 바이트코드 크기
  • 현재 호출 체인에서 인라이닝할 메서드의 깊이
  • 메서드를 컴파일한 버전이 코드 캐시에서 차지하는 공간

인라이닝 서브시스템 튜닝#

스위치설명
-XX:MaxInlineSize=<n>메서드를 이 크기 이하로 인라이닝합니다.
-XX:FreqInlineSize=<n>핫 메서드를 이 크기 이하로 인라이닝합니다.
-XX:InlineSmallCode=<n>최종 단계 컴파일이 이미 존재할 경우 메서드를 인라이닝하지 않습니다.
-XX:MaxInlineLevel=<n>이 수준보다 더 깊이 호출 프레임을 인라이닝하지 않습니다.

루프 펼치기#

루프 내부의 메서드 호출을 전부 인라이닝하면, 컴파일러는 루프를 한번 순회할 때마다 비용과 크기를 더 분명하게 알 수 있습니다.

백브랜치(back branch)가 일어나면 그때마다 CPU는 유입된 명령어 파이프라인을 덤프하기 때문에 성능상 바람직하지 않습니다. 일반적으로 루프 바디가 짧을수록 백 브랜치 비용은 상대적으로 높기 때문에 다음의 기준으로 루프 펼치기 여부를 결정합니다.

  • 루프 카운터 변수 유형(대부분 객체 아닌 int나 long형 사용)
  • 루프 보폭(loop stride, 한번 순회할 때마다 루프 카운터 값이 얼마나 바뀌는 지)
  • 루프 내부의 탈출 지점 개수(return 또는 break)

다음과 같이 벤치마킹 할 수 있습니다.

package optjava.jmh;
import org.openjdk.jmh.annotations.*;
import java.util.concurrent.TimeUnit;
@BenchmarkMode(Mode.Throughput)
@OutputTimeUnit(TimeUnit.SECONDS)
@State(Scope.Thread)
public class LoopUnrollingCounter {
private static Final int MAX = 1_000_000;
private long[] data = new long[MAX];
@Setup
public void createData() {
java.util.Random random = new java.util.Random();
for (int i = 0; i < MAX; i++) {
data[i] = random.nextLong();
}
}
@Benchmark
public long intStride1() {
long sum = 0;
for (int i = 0; i < MAX; i++) {
sum += data[i];
}
return sum;
}
@Benchmark
public long longStride1() {
long sum = 0;
for (long l = 0; l < MAX; l++) {
sum += data[(int) l];
}
return sum;
}
}

벤치마크 결과

BenchmarkModeCntScoreErrorUnites
UnitsLoopUnrollingCounter.intStride1thrpt2002423.818 ±2.547ops/s
LoopUnrollingCounter.longStride1thrpt2001469.833 ±0.721ops/s

루프 펼치기 정리#

핫스팟은 다양한 최적화 기법으로 루프 펼치기를 합니다.

  • 카운터가 int, short, char 형일 경우 루프를 최적화합니다.
  • 루프 바디를 펼치고 세이프포인트 폴을 제거합니다.
  • 루프를 펼치면 백 브랜치 횟수가 줄고, 그만큼 분기 예측 비용이 적게 듭니다.
  • 세이프포인트 폴을 제거하면 루프를 순회할 때마다 하는 일이 줄어듭니다.

탈출 분석#

핫스팟은 어떤 메서드가 내부에서 수행한 작업을 그 메서드 경계 밖에서도 볼 수 있는지, 또는 부수 효과를 유발하지 않는지 범위 기반 분석을 통해서 판별합니다. 이러한 기법을 탈출 분석이라고 하며 메서드 내부에서 할당된 객체를 메서드 범위 밖에서 바라볼 수 있는지를 알아보는 용도로 사용됩니다.

일반적으로 세가지 유형으로 분류됩니다.

typedef enum {
// 객체가 메서드/스레드를 탈출하지 않고
// 호출 인수로 전달되지 않으며,
// 스칼라로 대체 가능하다.
NoEscape = 1,
// 객체가 메서드/스레드를 탈출하지 않지만
// 호출 인수로 전달되거나 레퍼런스로 참조되며,
// 호출 도중에는 탈출하지 않는다.
ArgEscape = 2,
// 객체가 메서드/스레드를 탈출한다.
GlobalEscape = 3
}

힙 할당 제거#

핫스팟의 탈출 분석 최적화는 개발자가 객체 할당률을 신경 쓰지 않고도 자바 코드를 자연스레 작성할 수 있도록 설계되었습니다.

스칼라 치환(scalar replacement)이라는 최적화를 적용해 객체 필드를 마치 처음부터 객체 필드가 아닌 지역 변수였던 것처럼 스칼라 값으로 바꿉니다. 그 후 레지스터 할당기(register allocator)라는 핫스팟 컴포넌트에 의해 CPU 레지스터 속으로 배치합니다.

락과 탈출 분석#

핫스팟은 탈출 분석 및 관련 기법을 통해서 락 성능도 최적화합니다.

  • 비탈출 객체에 있는 락은 제거합니다. (락 생략)
  • 같은 락을 공유한, 락이 걸린 연속된 영역은 병합합니다. (락 확장)
  • 락을 해제하지 않고 같은 락을 반복 획득한 블록을 찾아냅니다. (중첩 락)

상세 내용 : JVM 명세서

탈출 분석의 한계#

탈출 분석 역시 다른 최적화 기법들처럼 트레이드오프가 있습니다. 힙이 아니라도 다른 어딘가에는 할당을 해야 하는데, CPU 레지스터나 스택 공간은 상대적으로 희소한 리소스입니다. 또 기본적으로 원소가 64개 이상인 배열은 핫스팟에서 탈출 분석의 혜택을 볼 수 없습니다. 이 개수 제한은 다음 VM 스위치로 조정합니다.


단형성 디스패치#

핫스팟 C2 컴파일러가 수행하는 추측성 최적화는 대부분 경험적 연구 결과를 토대로 합니다. 단형성 디스패치 기법도 그런 부류 중 하나입니다.

즉, 어떤 객체에 있는 메서드를 호출할 때, 그 메서드를 최초로 호출한 객체의 런타임 타입을 알아내면 그 이후의 모든 호출도 동일한 타입일 가능성이 큽니다. 이 추측을 통해서 호출부의 메서드 호출을 최적화할 수 있습니다.

일반 애플리케이션에서는 대부분이 단형적 호출입니다. 다만. 이형성 디스패치다형성도 지원합니다.


인트린직#

인트린직(intrinsics)은 JIT 서브시스템이 동적 생성하기 이전에 JVM이 이미 알고 있는 고도로 튜닝된 네이티브 메서드 구현체를 가리키는 용어입니다. 주로 OS나 CPU 아키텍처의 특정 기능을 응용하는, 성능이 필수적인 코어 메서드에서 쓰입니다. 따라서, 플랫폼에 따라 지원되는 경우도 있고 안되는 경우도 있습니다.

아래는 많이 쓰이는 인트린직입니다.

메서드설명
java.lang.System.arraycopy()CPU의 벡터 지원으로 배열을 빠르게 복사합니다.
java.lang.System.currentTimeMillis()대부분 OS가 제공하는 구현체가 빠릅니다.
java.lang.Math.min일부 CPU에서 분기 없이 연산 가능합니다.
기타 java.lang.Math 메서드일부 CPU에서 직접 명령어를 지원합니다.
암호화 함수(Ex. AES)하드웨어로 가속하면 성능이 매우 좋아집니다.

OpenJDK 핫스팟 소스 코드에서 확장자가 .ad(architecture dependent)인 파일이 바로 인트린직 템플릿입니다.

인트린직의 핵심 중 하나는 정말 자주 쓰이는 작업에 한해서만 성능에 큰 영향을 미칠 수 있습니다.


온-스택 치환#

컴파일을 일으킬 정도로 호출 빈도가 높지는 않지만 메서드 내부에 핫 루프가 포함된 경우가 있습니다. 대표적인 예시로 main() 메서드입니다.

핫스팟은 이런 코드를 온-스택 치환(OSR)을 이용해서 최적화합니다. 인터프리터가 루프 백 브랜치 횟수를 세어보고 특정 한계치를 초과하면 루프를 컴파일한 후 치촨해서 실행합니다.


세이프포인트 복습#

JVM에 세이프포인트가 걸리는 조건을 종합 정리해보면 다음과 같습니다.

  • 메서드를 역최적화
  • 힙 덤프를 생성
  • 바이어스 락을 취소
  • 클래스를 재정의

핫스팟에서는 다음 지점에 세이프포인트 체크 코드를 넣습니다.

  • 루프 백 브랜치 지점
  • 메서드 반환 지점

따라서 경우에 따라 스레드가 세이프포인터에 도달하려면 어느 정도 시간이 소요될 수 있습니다. 또한 컴파일러는 세이프포인트를 폴링하면서 체크하는 비용을 감수할지, 다른 스레드도 모두 세이프포인테 닿을 때까지 대기하는 긴 세이프포인트를 회피할 지에 대해 고민하게 됩니다.


코어 라이브러리 메서드#

JDK 코어 라이브러리 크기가 JIT 컴파일에 어떤 영향을 주는지 살펴봐야합니다.

인라이닝하기 적합한 메서드 크기 상한#

인라이닝 여부는 메서드의 바이트 코드 크기로 결정되므로, 클래스 파일을 정적 분석하면 인라이닝을 하기에 지나치게 큰 메서드를 솎아낼 수 있습니다.

  • 도메인에 특정한 메서드로 성능 개선합니다.
  • 메서드를 작게하는 것도 인라이닝 가짓수를 늘여서 장점을 가집니다.

컴파일하기 적합한 메서드 크기 상한#

핫스팟은 메서드 크기가 어느 이상 초과하면 컴파일되지 않는 한계치(8000바이트)가 있습니다.


마무리#

-XX:+PrintCompilation 플래그와 9장에서 소개한 기법들을 잘 활용하면 개별 메서드마다 정말 최적화됐는지 여부를 확인할 수 있습니다.

Last updated on