Published on

Optimizing Java Chap9

Authors
  • avatar
    Name
    ywj9811
    Twitter

들어가며

이번 장에서는 바이트코드 해석을 간략히 살펴본 다음 다른 인터프리터와 핫스팟의 차이점을 알아보고 이어서 프로파일 기반 최적화의 기초 개념을 다루고 코드 캐시 및 핫스팟 컴팡리 서브시스템의 기본적인 내용을 다룰 것이다.

바이트코드 해석

JVM 인터프리터는 일종의 스택 머신 처럼 작동하므로 레지스터와 같은 부분은 없고 작업할 값을 모두 평가 스택에 놓고 스택 머신 명령어로 스택 최상단에 위치한 값을 변환하는 식으로 작동한다.

JVM은 세가지 공간에 데이터를 담아 놓는다.

  • 평가 스택

    메소드별 하나씩 생성

  • 로컬 변수

    결과를 임시 저장 (특정 메소드 별로 존재)

  • 객체

    메소드끼리, 스레드끼리 공유

JVM 바이트코드 개요

JVM에서 각 스택 머신 작업 코드(옵코드)는 1바이트로 나타낸다.

→ 그래서 이름도 바이트코드이다.

따라서 옵코드는 0부터 255까지 지정 가능하다.

바이트코드 명령어는 스택 상단에 위치한 두 값의 기본형을 구분할 수 있게 표기하는데, 예를 들어 iadd와 dadd는 각각 int와 double 값을 의미하는 것이다.

그리고 load 같은 옵코드 군에는 단축형이 있어 인수를 생략할 수 있고 그만큼 클래스 파일의 인수 바이트 공간을 절약할 수 있다.

주요 바이트코드를 유형별로 살펴보자

참고로 앞으로 표시하는 테이블에서 c1은 2바이트짜리 상수 풀 인덱스, i1은 현재 메소드의 지역 변수이고, 괄호는 해당 옵코드 패밀리 중 단축형을 지닌 옵코드가 있음을 의미한다.

패밀리 명인수설명
load(i1)지역 변수 i1 값을 스택에 로드한다.
store(i1)스택 상단을 지역 변수 i1에 저장한다.
ldcc1CP#c1이 가리키는 값을 스택에 로드한다.
const단순 상수값을 스택에 로드한다.
pop스택 상단에서 값을 제거한다.
dupc1스택 상단에 있는 값을 복제한다.
getField스택 상단에 위치한 객체에서 CP#c1이 가리키는 필드명을 찾아 그 값을 스택에 로드한다.
putFieldc1스택 상단의 값을 CP#c1이 가리키는 필드에 저장한다.
getstaticc1CP#c1이 가리키는 정적 필드값을 스택에 로드한다.
putstaticc1스택 상단의 값을 CP#c1이 가리키는 정적 필드에 저장한다.

위는 로드/스토어 카테고리로 스택에 데이터를 넣고 빼는 옵코드 테이블이다.

테이블에서 ldc와 const는 비슷하지만 구별해야 하는데,

ldc는 현재 클래스의 상수 풀에 있는 상수를 로드하는 바이트 코드로 스트링, 기본형 상수, 클래스 리터럴 등등 이 여기에 해당한다.

const는 매개변수 없이 aconst_null, dconst_0, iconst_m1 (-1을 로드) 형태로 진짜 상수만 로드하는 옵코드이다.

패밀리 명설명
add스택 상단의 두 값을 더한다.
sub스택 상단의 두 값을 뺀다.
div스택 상단의 두 값을 나눈다.
mul스택 상단의 두 값을 곱한다.
(cast)스택 상단의 값을 다른 기본형으로 형변환한다.
neg스택 상단의 값을 부정한다.
rem스택 상단의 두 값을 나눈 나머지를 구한다.

위는 산술 카테고리 테이블이다.

이는 기본형에만 적용되며 순수하게 스택 기반으로 연산을 수행하는 것으로 인수가 없다.

패밀리 명인수설명
if(i1)조건이 참일 경우 인수가 가리키는 위치로 분기한다.
gotoi1주어진 오프셋으로 무조건 분기한다.

위는 흐름 제어 카테고리 테이블이다.

니는 소스 코드의 순회, 분기문을 바이트코드 수준으로 표현하는 옵코드들로 for, if, while, switch 문을 컴파일 하면 모두 이런 흐름 제어 옵코드로 변환된다.

이렇게 보면 바이트코드가 몇 안돼보이지만 if 옵코드 패밀리에 속한 옵코드가 상당히 많아서 실제 가짓수는 많다.

패밀리 명인수설명
invokevirtualc1CP#c1이 가리키는 메소드를 가상 디스패치를 통해 호출한다.
invokespecialc1CP#c1이 가리키는 메소드를 ‘특별한’ 디스패치를 통해 호출한다. (즉, 정확하게 호출)
invokeinterfacec1, count, 0CP#c1이 가리키는 인터페이스 메소드를 인터페이스 오프셋 룩업을 통해 호출한다.
invokestaticc1CP#c1이 가리키는 정적 메소드를 호출한다.
invokedynamicc1, 0, 0호출해서 실행할 메소드를 동적으로 찾는다.

위는 메소드 호출 바이트 코드로, 자바에서 새 메소드로 제어권을 넘기는 유일한 장치이다.

JVM 설계 구조상 메소드 호출 옵코드를 명시적으로 사용하기 때문에 기계어에는 이와 동등한 호출 작업이 없다.

하지만 JVM 바이트코드는 몇가지 전문 용어를 사용하는데, 호출부는 메소드(호출자) 내부에서 다른 메소드(피호출자)를 호출한 지점이다.

비정적 메소드 호출의 경우 어느 객체에 있는 메소드인지 반드시 찾아야 하는데 이렇게 찾은 객체를 수신자 객체, 이 객체의 런타임 타입을 수신자 타입이라고 한다.

사실 자바 프로그래머는 VM 수준에서 잘들여다 보지 않기 때문에 익숙하지 않겠지만,

인스턴스 메소드 호출 → invokevirtual

인터페이스에 선언된 메소드 호출 → invokeinterface

프라이빗 메소드, 슈퍼클래스 호출 → invokespecial

이렇게 명령어로 호출되게 된다.

그렇다면 invokedynamic은 어디서 사용될까?

자바8 이후에 이는 핵심으로 급부상하게 되었는데, 자바 언어의 고급 기능을 지원하는데 활용되고 있다.

public class LamdaExample {
	private static final String HELLO = "Hello";
	
	public static void main(Stirng[] args) throws Exception {
		Runnable r = () -> System.out.println(HELLO);
		Thread t = new Thread(r);
		t.start();
		t.join();
	}
}

이렇게 단순 람다 표현식을 컴파일 하게 되면, run()에서 invokedynamic이 사용되게 된다.

즉, 람다 표현식을 가리키는 객체 레퍼런스로 이 객체 레퍼런스는 invokedynamic 명령어에 의해 호출되는 플랫폼 팩토리 메소드가 생성한다.

자바 프로그래머 입장에서는 invokedynamic 유스케이스로 람다 표현식만큼 확실한 것은 없겠지만, 이 외에도 제이루비, 내쉬혼 처럼 JVM에서 작동하는 논자바 언어와 같은 많은 자바 프레임워크에서 사용되고 있기도 하다.

패밀리 명인수설명
newc1CP#c1이 가리키는 타입의 객체에 공간을 할당한다.
newarrayprim기본형 배열에 공간을 할당한다.
anewarrayc1CP#c1이 가리키는 타입의 객체 배열에 공간을 할당한다.
arraylength스택 상단에 위치한 객체를 그 길이로 치환한다.
monitorenter스택 상단의 객체 모니터를 잠금한다.
monitorexit스택 상단의 객체 모니터를 잠금 해제한다.

위는 플랫폼 옵코드 테이블로 객체별로 힙 저장 공간을 새로 할당하거나, 고유 락(동기화시 사용하는 모니터)를 다루는 명령어다.

그렇다면 인터프리터로 해석된 코드의 세이프포인트(JVM이 어떤 관리 작업을 수행하고 내부 상태를 일관되게 유지하는 데 필요한 지점) 에 대해 짚고 넘어가보자.

우선 일관된 상태를 유지하려면 JVM이관리 작업 수행 도중 공유 힙이 변경되지 않게 모든 어플리케이션이 스레드를 멈춰야 하는데,

우선 인터프리티드 메소드를 실행하는 스레드에 대해 옵코드가 디스패치되는 시점에서 어플리케이션 스레드가 실행하는 것은 유저 코드가 아닌 JVM 인터프리터 코드라는 점을 알아야 한다.

따라서 **‘바이트코드 사이사이’**가 어플리케이션 스레드를 멈추기에 이상적인 시점이자 가장 단순한 세이프포인트이다.

AOT와 JIT 컴파일

실행 가능한 코드를 만드는 AOT 컴파일과 JIT 컴파일, 두 방식을 알아보고 비교해보도록 하자.

AOT 컴파일

C/C++ 개발을 했다면 AOT 컴파일은 익숙한 개념일텐데 아마 그냥 ‘컴파일’ 이라고 불렀을 것이다.

이는 사람이 읽을 수 있는 프로그램 소스 코드를 외부 프로그램에 넣고 바로 실행 가능한 기계어를 뽑아내는 과정이다.

이러한 AOT의 목표는 프로그램을 실행할 플랫폼과 프로세서 아키텍처에 딱 맞는 실행 코드를 얻는 것으로 이렇게 대상이 고정된 바이너리 프로세서별로 특수한 기능을 십분 활용해 프로그램 속도를 높일 수 있다.

하지만 대부분의 실행 코드는 자신이 어떤 플랫폼에서 실행될지 모르는 상태에서 생성되기에 AOT 컴파일은 자신이 사용 가능한 프로세서 기능에 대해 가장 보수적인 선택을 해야한다.

결국 AOT 컴파일한 바이너리는 CPU 기능을 최대한 활용하지 못하는 경우가 다반사고 성능 향상의 숙제가 남게된다.

JIT 컴파일

JIT 컴파일은 런타임에 프로그램을 고도로 최적화한 기계어로 변환하는 기법이다.

핫스팟을 비록한 대부분의 주요 상용 JVM은 이 방식으로 작동된다.

이는 프로그램의 런타임 실행 정보를 수집해서 어느 부분이 자주 쓰이고, 어느 부분을 최적화해야 가장 효과가 좋은지 프로파일을 만들어 결정을 내리는 것이다.

JIT은 바이트코드를 네이티브 코드로 컴파일 하는 비용을 런타임에 지불하게 되는데, 이 과정에서 본래라면 프로그램 실행에만 온전히 동원됐을 일부 리소스가 소비되기 때문에 JIT 컴파일은 산발적으로 수행된다.

그리고 VM은 최적화하면 가장 좋은 지점(핫스팟인지) 찾고자 각종 프로그램 관련 지표를 수집한다.

AOT VS JIT

AOT 컴파일은 상대적으로 이해하기 쉽고 소스 코드에서 바로 기계어가 생성되고 컴파일 단위로 대응되는 기계어를 어셈블리로 바로 사용할 수 있다.

그렇기 때문에 코드의 성능 특성이 그리 복잡하지 않다.

하지만 AOT는 최적화 결정을 내리는데 유용한 런타임 정보를 포기하는 만큼 장점이 상쇄되게 된다.

물론 저지연 또는 극단적으로 성능이 중요한 유스케이스에서 특정 기능을 타겟으로 실행 가능한 코드를 만들게 된다면 유용하겠지만 확장성에 대한 문제가 남게 된다.

JIT 컴파일은 새로 릴리즈를 할때마다 새로운 프로세서 기능에 관한 쵲거화 코드를 추가할 수 있고, 어플리케이션은 기존 클래스 및 JAR 파일을 다시 컴파일하지 않아도 신기능을 십분 활용할 수 있다.

핫스팟 JIT 기초

우선 핫스팟의 기본 컴파일 단위는 전체 메소드인데, 따라서 한 메소드에 해당하는 바이트 코드는 한번에 네이티브 코드로 컴파일된다.

핫스팟은 핫 루프를 on-stack replacement(OSR) 이라는 기법을 이용해 컴파일하는 기능 또한 지원한다.

OSR은 어떤 메소드가 컴파일할 만큼 자주 호출되지는 않지만 컴파일하기 적합한 루프가 포함되어 있고 루프 바디 자체가 메소드인 경우에 사용한다.

klass 워드, vtable, 포인터 스위즐링

핫스팟은 멀티스레드 C++ 어플리케이션인데, 따라서 실행중인 모든 자바 프로그램은 OS 관점에서는 실제로 한 멀티스레드 어플리케이션의 일부일 뿐이다.

JIT 컴파일 서브시스템을 구성하는 스레드는 핫스팟 내부에서 가장 중요한 스레드인데, 컴파일 대상 메소드를 찾아내는 프로파일링 스레드와 실제 기계어를 생성하는 컴파일 스레드도 모두 여기에 포함된다.

컴파일 대상으로 낙점된 메소드는 컴파일러 스레드에 올려놓고 백그라운드에서 컴파일 한다.

최적화된 기계어가 생성되면 해당 klass의 vtable은 새로 컴파일된 코드르 가리키도록 수정된다.

핫스팟 내부의 컴파일러

핫스팟 JVM에는 C1, C2 라는 두 JIT 컴파일러가 있다.

각각 클라이언트 컴파일러, 서버 컴파일러라고 부르기도 하는데, 역사적으로 C1은 어플리케이션 및 기타 ‘클라이언트’ 프로그램에 C2는 실행 시간이 긴 ‘서버’ 어플리케이션에 주로 사용되었지만, 요즘 자바 어플리케이션에는 이렇게 구분하는 기준이 뚜렷하지 않고 핫스팟은 새로운 환경에 맞게 최대한 성능을 발휘하도록 변화했다.

C1, C2 컴파일러 모두 핵심 측정값, 즉 메소드 호출 횟수에 따라 컴파일이 트리거링 된다.

호출 횟수가 특정 한계치에 이르면 그 사실을 VM이 알림 받고 해당 메소드를 컴파일 큐에 넣는다.

컴파일 프로세스는 가장 먼저 메소드의 내부 표현형을 생성한 다음, 인터프리티드 단계에서 수집한 프로파일링 정보를 바탕으로 최적화 로직을 적용한다.

헌데, C1과 C2가 생성하는 내부 표현형은 다른데 C1은 C2보다 컴파일 시간도 짧고 단순하게 설계되었기 때문에 풀 최적화를 진행하지 않는다.

변수를 일체 재할당하지 않는 코드로 변환하는 단일 정적 할당(SSA)는 두 컴파일러 모두 사용하는 공통 기법으로 이는 오직 final 변수만 사용하는 코드로 탈바꿈 하는 기법이다.

핫스팟의 단계별 컴파일

단계별 컴파일 모드가 있는데, 이는 인터프리티드 모드로 실행되다가 단순한 C1 컴파일 방식으로, 그러다가 다시 C2보다 고급 최적화를 수행하는 방식으로 단계를 바꾸는 이런 모드이다.

단계는 5가지 실행 레벨이 있는데 아래와 같다.

  • 레벨 0 : 인터프리터
  • 레벨 1 : C1 - 풀 최적화 (프로파일링 없음)
  • 레벨 2 : C1 - 호출 카운터 + 백엣지 카운터
  • 레벨 3 : C1 - 풀 프로파일링
  • 레벨 4 : C2

단계결 컴파일은 오래전부터 디폴트로 성능 튜닝할 때 이 부분을 조정할 일은 거의 없으나, 작동 원리 정도는 알아두는 것이 좋다.

코드 캐시

JIT 컴파일드 코드는 코드 캐시라는 메모리 영역에 저장된다.

VM 시작시 코드 캐시는 설정된 값으로 최대 크기가 고정되고 확장이 불가능 한데, 코드 캐시가 꽉 차면 그때부터 더 이상 JIT 컴파일은 안되고 컴파일 되지 않은 코드는 인터프리터에서만 실행되어야 한다.

코드 캐시는 미할당 영역프리 블록 연결 리스트를 담은 힙으로 구현되는데, 네이티브 코드가 제거될 때마다 해당 블록이 프리 리스트에 추가된다. (이러한 블록 재활용은 스위퍼라는 프로세스가 담당한다.)

네이티브 메소드가 새로 저장되면 컴파일드 코드를 담기에 크기가 충분한 블록을 프리 리스트에서 찾아보고, 만약 없다면 여유 공간이 충분한 코드 캐시 사정에 따라 미할당 공간에서 새 블록을 생성한다.

허나, 다음 경우 네이티브 코드는 코드 캐시에서 제거된다.

  1. (추측성 최적화 적용한 결과 틀린 것으로 판명) 역최적화 될때
  2. 다른 컴파일 버전으로 교체 됐을때 (단계별 컴파일에서 컴파일 레벨 변경)
  3. 메소드를 지닌 클래스가 언로딩될 때

단편화

C1 컴파일러를 거친 중간 단계의 컴파일드 코드가 C2 컴파일로 치환된 후 삭제되는 일이 잦아지면 코드 캐시는 단편화되기 쉽다. (자바 8 이전)

결국 미할당 영역이 모두 소진되고 여유 공간은 전부 프리 리스트에 있는 것으로 나타나게 될 것이다.

결국 메모리 블록을 재배치하지 않는 가비지 수집 방식에서 단편화는 불가피하며 코드 캐시도 예외는 아닐 것이다.

압착을 안하면 코드 캐시는 단편화되고 컴파일은 중단될 것이며 이는 캐시가 고갈되는 또 다른 형태일 뿐이다.

간단한 JIT 튜닝법

단순 JIT 튜닝의 대원칙은 정말 간단한데, ‘컴파일을 원하는 메소드에게 아낌없이 리소스를 베풀라’ 라는 것이다.

이러한 목표를 위해서 아래 항목을 점검해야 한다.

  1. 먼저 PrintComilation 스위치를 켜고 어플리케이션을 실행한다.
  2. 어느 메소드가 컴파일 되었는지 기록된 로그를 수집한다.
  3. ReservedCodeCacheSize를 통해 코드 캐시를 수집한다.
  4. 어플리케이션을 재실행한다.
  5. 확장된 캐시에서 컴파일드 메소드를 살펴본다.

그리고 JIT 컴파일에 내재된 불확정성을 고려해야 하는데 그러면 아래 두가지 명백한 사실을 관찰할 수 있다.

  • 캐시 크기를 늘리면 컴파일드 메소드 규모가 유의미한 방향으로 커지는가?
  • 주요 트랜잭션 경로상에 위치한 주요 메소드가 모두 컴파일되고 있는가?

만약 캐시 크기를 늘려도 컴파일드 메소드 개수는 그대로고 로딩 패턴이 뚜렷하면 JIT 컴파일러의 리소스가 부족한 것이 아니고 코드 캐시가 제대로 쓰이지 않는다는 것이다.

그렇다면 트랜잭션이 몰리는 경로에 있는 메소드가 컴파일 로그에 전부 나타나는지 확인해야 하며 그렇지 않다면 왜 그런 것인지 원인을 파악해야 할 것이다.

즉, 코드 캐시 공간이 모자라는 일이 없게 함으로써 JIT 컴파일이 절대 끊기지 않도록 보장하는 전략인 것이다.