Published on

Optimizing Java Chap10

Authors
  • avatar
    Name
    ywj9811
    Twitter

들어가며

이번 장에서는 JVM JIT 컴파일러의 안쪽 세계로 깊히 들어가보도록 하자.

이 주제는 다소 추상적이고 기술적으로 복잡하지만, 그래도 확인하고 구체적인 JIT 최적화 알고리즘과 각각의 특성을 살펴보고 이런 기법들이 어떤 작용을 하는지 그리고 이를 시각화 하기 위한 JITWatch를 간단히 알아보도록 하자.

JITWatch란?

JITWatch는 오픈 소스 자바FX툴이다.

이를 사용하면 어플리케이션 실행 중에 핫스팟이 실제로 바이트코드에 무슨 일을 했는지 이해하는 데 도움이 된다.

JITWatch는 객관적인 비교에 필요한 측정값을 제공하는데 이러한 측정값 없이 판단하면 ‘숲을 못 보고 나무만 본다’ 와 같은 안티 패턴에 빠지기 쉽다.

JITWatch는 실행중인 자바 어플리케이션이 생성한 핫스팟 컴파일 상세 로그를 파싱/분석하여 그 결과를 자바FX GUI형태로 보여주는 것으로,

-XX:+UnlockDiagnosticVMOptions -XX:+TraceClassLoading -XX:+LogComplication

이러한 플래그를 반드시 추가해야 한다.

디버그 JVM과 hsdis

좀 더 심도있게 튜닝을 하면서 JIT 서브시스템의 통계치를 얻으려면 디버그 JVM을 이용하면 된다.

디버그 JVM은 운영 JVM보다 더 상세한 디버깅 정보를 추출하려고 제작한 가상 머신인데, 그만큼 성능 희생은 감수해야 한다.

그리고 JIT 컴파일러가 생성한 역어셈블된 네이티브 코드를 살펴보려면 hsdis 같은 역어셈블리 바이너리가 있어야 한다.

이를 사용하기 위해서는 VM에 메소드 어셈블리를 출력하는 스위치를 추가한다.

-XX:+PrintAssembly

JIT 컴파일 개요

VM이 데이터를 어떻게 수집하는지, 실행 프로그램에 어떤 최적화를 수행하는지 잘 알고 있어야 툴에서 컴파일드 코드를 보면서 올바르게 해석할 수 있다.

핫스팟은 프로파일 기반 최적화(PGO)를 이용해 JIT컴파일 여부를 판단한다고 하였는데, 내부적으로는 핫스팟이 실행 프로그램 정보를 메소드 데이터 객체(MDO)라는 구조체에 저장한다.

이 MDO의 쓰임새는 바이트코드 인터프리터와 C1 컴파일러에서 JIT 컴파일러가 언제, 무슨 최적화를 할지 결정하는데 필요한 정보를 기록하는 것이다.

이렇듯 프로파일링된 프로퍼티의 ‘사용빈도’를 계속 기록하고, 그렇게 기록한 값들은 프로파일링을 거치며 차츰사라지게되어 결국 컴파일 큐 맨 앞에 이르렀을 때도 아직 핫한 메소드만 컴파일 되게 되는 것이다.

이렇게 프로파일링 데이터가 모이고 컴파일 결정을 내린 후엔 컴파일러별 세부 처리 절차로 넘어가는데, 컴파일러는 컴파일할 코드의 내부 표현형을 빌드한다.

그리고 컴파일러는 이 내부 표현형을 토대로 코드를 한껏 컴파일하는데, 핫스팟 JIT 컴파일러는 다양한 최신 컴파일러 최적화 기법을 총동원 한다.

(ex. 인라이닝, 루프 펼치기, 탈출 분석, 락 생략/확장 등등)

⚠️ 단, 이러한 최적화 기법은 런타임 정보와 지원 여부에 따라서 완전 달라질 수 있다는 것을 꼭 기억하도록 하자.

핫스팟의 C1/C2 컴파일러 역시 이들 기법을 상이하게 조합해서 사용하는데, 기본적으로 컴파일에 접근하는 철학 자체가 다르다.

C1은 추측성 최적화를 하지 않는다.

그러나 C2는 공격적인 최적화기로 런타임 실행을 주시한 결과를 토대로 추정을 하고 그에 따른 최적화를 수행한다.

물론 이러한 공격적인 최적화는 잘 맞는다면 엄청난 성능 향상 효과를 볼 수 있지만, 만약 엉뚱하게 흘러가면 무용지물이 될 수도 있을 것이다. 따라서 이를 방지하기 위해 가드라는 ‘타당성 검사’ 를 하여 추측한 내용이 여전히 유효한지 최적화된 코드를 실행할 때마다 확인한다.

하지만 가드마저 실패하면 더 이상 컴파일드 코드는 안전하지 않기때문에 제거해야 한다.

핫스팟은 혹여 부정확한 코드가 실행되는 불상사를 막기 위해 즉시 해당 메소드를 인터프리티드 모드로 작동시켜 역최적화 한다.

인라이닝

인라이닝은 호출된 메소드의 콘텐츠를 호출한 지점에 복사하는 것이다.

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

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

인라이닝은 JIT 컴파일러가 제일 먼저 적용하는 최적화라서, 관문 최적화라고도 한다.

메소드 경계를 없애고 연관된 코드를 한데 모아 놓기 때문이다.

int result = add(a, b);

private int add(int x, int y) {
	return x + y;
}

이러한 코드는 인라이닝 최적화 후 add() 메소드 바디는 호출부에 합쳐진다.

int result = a + b;

핫스팟은 자동으로 통계치를 분석해서 관련된 코드를 어느 시점에 하나로 모을지 결정한다.

그래서 인라이닝은 다른 최적화의 범위를 확장시키는 역할을 한다.

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

인라이닝 제한

VM에서 다음 항목을 조정해야 할 때는 인라이닝 서브시스템에 제한을 걸 수 있다.

  • JIT 컴파일러가 메소드를 최적화하는데 소비되는 시간
  • 생성된 네이티브 코드 크기 (코드 캐시 메모리 사용량)

제약 조건이 하나도 없다면, 결국 코드 캐시를 거대한 네이티브 메소드로 가득 채울 것이다.

JIT 컴파일이 값비싼 리소스 라는 일반 원칙은 여기서도 적용된다.

핫스팟은 다음 항목을 따져가며 어떤 메소드를 인라이닝 할지 결정한다.

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

인라이닝 서브시스템 튜닝

스위치디폴트실행
-XX:MaxInlineSize=<n>35바이트의 바이트코드메소드를 이 크기 이하로 인라이닝 한다.
-XX:FreqInlineSize=<n>325바이트의 바이트코드'핫’ 메소드를 이 크기 이하로 인라이닝 한다.
-XX:InlineSmallCode=<n>1,000바이트의 네이티브 코드
(단계 없음)
2,000바이트의 네이티브 코드
(단계 있음)코드 캐시에 이 수치보다 더 많은 공간을 차지한 최종 단계 컴파일이 이미 존재할 경우 메소드를 인라이닝하지 않는다.
-XX:MaxInlineLevel=<n>9이 수준보다 더 깊이 호출 프레임을 인라이닝 하지 않는다.

만약 중요 메소드가 인라이닝 되지 않는데, 이유가 인라이닝 최대 허용 크기를 살짝 초과해서라면 이런 메소드까지 인라이닝 되도록 적절히 JVM 매개변수를 조정해야할 수 있기도 하다.

그리고 스위치를 변경해가며 성능이 조금이라도 나아지는지 확인해야 한다.

이렇게 매개변수를 바꿔가며 튜닝할 때는 반드시 측정 데이터를 근거로 삼아야 한다.

루프 펼치기

루프 내부의 메소드 호출을 전부 인라이닝 하면, 컴파일러는 루프를 한번 순회할 때 마다 비용이 얼마나 드는지, 반복 실행되는 코드는 크기가 얼마나 되는지 더 분명해진다.

이 정보를 토대로 컴파일러는 매번 순회할 때마다 루프 처음으로 되돌아가는 횟수를 줄이기 위해 루프를 펼칠 수 있다.

만약 백 브랜치(루프문 처음으로 돌아가는 것)이 일어난다면 그때마다 CPU는 유입된 명령어 파이프라인을 덤프하기 때문에 성능상 바람직하지 않다.

따라서 핫스팟은 다음 기준에 따라 루프 펼치기 여부를 결정한다.

  • 루프 카운터 변수 유형
  • 루프 보폭
  • 루프 내부의 탈출 지점 개수

루프 펼치기 정리

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

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

탈출 분석

핫스팟은 어떤 메소드가 내부에서 수행한 작업을 그 메소드 경계 밖에서도 볼 수 있는지, 또는 부수 효과를 유발하지는 않는지 범위 기반 분석을 통해 판별한다.

이러한 기법을 탈출 분석이라고 하며 메소드 내부에서 할당된 객체를 메소드 범위 밖에서 바라볼 수 있는지를 알아보는 용도로 쓰인다.

핫스팟은 탈출 분석 단계 도중, 잠재적으로 탈출한 객체를 세 가지 유형으로 분류한다.

  • NoEscape - 객체가 메소드/스레드로 탈출하지 않고 호출 인자로 전달되지 않으며, 스칼라로 대체 가능하다.

    public long noEscape() {
    	long sum = 0;
    	for (int i = 0; i < 1000000; i++) {
    		MyObj foo = new MyObj(i);
    		sum += foo.bar();
    	}
    	return sum;
    }
    
    // foo는 메소드 밖으로 벗어나지 않기 때문에 NoEscape로 분류
    
  • ArgEscape - 객체가 메소드/스레드로 탈출하지 않지만 호출 인자로 전달되거나 레퍼런스로 참조되며, 호출 도중에는 탈출하지 않는다.

    public long argEscape() {
    	long sum = 0;
    	for (int i = 0; i < 1000000; i++) {
    		MyObj foo = new MyObj(i);
    		sum += extBar(foo);
    	}
    }
    
    // foo는 extBar의 매개변수로 전달된다. -> ArgEscape
    
  • GlobalEscape - 객체가 메소드/스레드를 탈출한다.

힙 할당 제거

빡빡한 루프 안에서 객체를 새로 만들면 그만큼 메모리 할당 서브시스템을 압박하게 되고, 단명 객체가 끊임없이 양산되면 마이너 GC 이벤트가 자주발생할 것이고, 영세대가 꽉차서 조기 승격이 일어나게 될 수 있을 것이다.

그렇게 결국 풀 GC 이벤트가 발생하게 되는 결과를 초래할 것이다.

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

  • 할당된 객체가 메소드를 탈출하지 않는다는 사실을 밝힌다면 (NoEscape 로 분류)

    VM은 스칼라 치환이라는 최적화를 적용해 객체 필드를 마치 처음부터 객체 필드가 아닌 지역 변수인것 처럼 스칼라 값으로 바꾼다.

    그 이후 레지스터 할당가라는 핫스팟 컴포넌트에 의해 CPU 레지스터 속으로 배치된다.

  • 목표

    탈출 분석의 목표는 힙 할당을 막을 수 있는지 추론하는 것이다.

    만약 그럴 수 있다면 객체는 스택에 자동 할당되고 GC 압박을 조금 덜 수 있을 것이다.

락과 탈출 분석

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

락 최적화의 핵심 기술은 아래와 같다.

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

핫스팟은 락을 발견하면 반대 방향으로 거슬러 올라가 동일한 객체에 언락이 있는지 찾아보고, 있다면 두 락 영역을 더 큰 단일 영역으로 합칠 수 있는지 살펴보는 방식으로 락이 걸린 영역을 확장할 수 있는지 체크한다.

탈출 분석의 한계

탈출 분석 역시 트레이드오프가 있는데,

만약 탈출 분석을 하게 되면 힙이 아니더라도 다른 어딘가에 저장해야 하는데 CPU 레지스터나 스택 공간은 상대적으로 희소한 리소스이다.

단형성 디스패치

단형성 디스패치 기법도 경험적 연구 결과를 토대로 진행하는데,

그 메소드를 최초로 호출한 객체의 런타임 타입을 알아내면 그 이후의 모든 호출도 동일한 타입일 가능성이 크다는 것이다.

이 추측이 옳다면 해당 호출부의 메소드 호출을 최적화할 수 있다.

일단 호출 대상을 계산해서 invokevirtual 명령어를 퀵 타입 테스트(가드) 후 컴파일드 메소드 바디로 분기하는 코드로 치환하면 된다.

즉, klass 포인터 및 vtable을 통해 가상 룩업을 하며, 참조하는 일은 딱 한번만 하게 된다.

뿐만 아니라 형성 디스패치 라는 최적화도 지원한다.

이를 통해 서로 다른 두 타입을 단형성 디스패치와 같은 방법으로 호출부마다 상이한 klass 워드를 캐시해서 처리한다.

인트린직

JIT 서브시스템이 동적 생성하기 이전에 JVM이 이미 알고 있는, 고도로 튜닝된 네이티브 메소드 구현체를 가리키는 용어이다.

이는 주로 OS나 CPU 아키텍처의 특정 기능을 응용하는, 성능이 필수적인 코어 메소드에 쓰인다.

새 인트릭직을 추가할 때는 복잡도가 증가하는 것과 유용하게 잘 쓰는 것 사이에서 저울질을 해봐야 한다.

예를 들어 자연수 n까지의 합을 구하는 것처럼 기본적인 산술 연산을 수행하는 인트린직이 있다고 가정하면, 이는 기존 자바 코드로는 O(n)의 작업을 해야 하는데, 단순 공식 하나로 O(1)이면 계산할 수 있을 것이다.

하지만 인트린직은 쓸데없이 JVM에 복잡도를 가중시킬 뿐 큰 가지치가 없을 수 있기 때문에 반드시 정말 자주 쓰이는 작업에 한해서만 진행해야 한다.

온-스택 치환

컴파일을 일으킬 정도로 호출빈도가 높지는 않지만 메소드 내부에 핫 루프가 포함된 경우가 있다.

예를 들어 자바의 main() 이 있다.

핫스팟은 이런 코드를 온-스택 치환(OSR)을 이용해 최적화 한다.

세이프포인트 복습

JVM에 세이프포인트가 걸리는 조건은 GC STW 이벤트뿐만 아니라 다음과 같은 경우에도 전체 스레드가 세이프포인트에 걸린다.

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

컴파일드 코드에서 세이프포인트 체크 발급은 JIT 컴파일러가 담당하며 핫스팟에서는 다음 지점에 세이프포인트 체크 코드를 넣는다.

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

코어 라이브러리 메소드

JDK 코어 라이브러리 크기가 JIT 컴파일에 어떤 영향을 주는지 간략히 살펴보도록 하자.

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

인라이닝 할지 말지는 메소드의 바이트코드 크기로 결정되기 때문에 클래스 파일을 정적 분석하면 인라이닝을 하기에 지나치게 큰 메소드를 솎아낼 수 있다.

java.* 패키지 메소드 중 바이트코드가 325바이트를 초과하는 것이 490개나 있다.

예를 들어 java.lang.String 클래스의 toUpperCase(), toLowerCase() 는 무료 439바이트나 되어서 정상적인 인라인 범위를 벗어난다.

근데 두 메소드의 크기가 커진 이유는 무엇일까?

대/소문자를 바꾸면 저장할 캐릭터 개수가 달라지는 로케일이 있기 때문이다.

도메인에 특정한 메소드로 성능 개선

그렇다면 toUpperCase() 를 도메인에 특정한 메소드로 만들어 바이트코드 크기를 인라이닝 한계치 이하로 줄일 수 있을 것이다.

ASCII 전용 구현체를 만들어 컴파일 하면 바이트코드 코드가 69바이트밖에 안된다.

이런식으로 도메인에 특정한 메소드로 개선하여 인라이닝을 가능하게 한다면 기존보다 성능을 개선시킬 수 있다.

메소드를 작게하면 좋은점

메소드를 작게 만들면 어떤점이 좋을까?

가독성, 유지보수, 디버깅 등등의 측면 외에도 아래와 같은 이유가 있다.

  • 인라이닝 가짓수가 늘어난다.

    런타임 데이터가 다양해질수록 여러 상이한 경로를 거치면서 코드가 ‘핫’하게 될 가능성이 있다.

    따라서 다양한 인라이닝 트리를 구축하여 핫 경로를 더욱 최적화할 여지가 생긴다.