자바 JIT 컴파일러에 대해 정리합니다.
학습할 내용은 다음과 같습니다.
- JIT 컴파일러 개요
- 핫스팟 컴파일
- 기본 튜닝 (서버와 클라이언트 컴파일러)
- 자바와 JIT 컴파일러 버전
- 컴파일러를 위한 중급 튜닝
- 고급 컴파일러 튜닝
- 역최적화 (Deoptimization)
- 티어드 컴파일 레벨 (Tiered Compilation Level)
Reference
저스트 인 타임(just-in-time, JIT) 컴파일러는 JVM의 핵심입니다. JVM 내에서 컴파일러보다 성능에 더 영향을 주는 요소는 없습니다.
다행스럽게도 대부분의 상황에서 이후에 나올 티어드 컴파일(tiered compilation)을 사용하기만 해도 튜닝할 필요는 거의 없습니다.
이 글에서는 컴파일러에 대해 다소 심도 있게 다루겠습니다. 컴파일러의 동작 방법에 대한 일부 정보로 시작해서 JIT 컴파일러의 사용에 대한 장점과 단점을 논의하겠습니다.
그 다음 어떤 자바 버전에 어떤 종류의 컴파일러가 들어있는지에 대해 주제를 옮겨서 설명하겠습니다.
마지막으로 컴파일러의 중급과 고급 튜닝 일부를 다루겠습니다. 이렇게 튜닝하면 애플리케이션의 성능에서 최후의 몇 퍼센트라도 올릴 수 있습니다.
CPU는 어셈블리나 바이너리 코드라고 불리는 특정 명령어만을 실행시킬 수 있습니다. 그 말은 CPU가 실행하는 모든 프로그램은 이 명령어들로 변환이 가능합니다.
C++와 Forttran과 같은 언어들은 바이너리 (컴파일된) 코드로 실행되어 컴파일형 언어라고 불립니다.
즉 프로그램이 작성되고 정적 컴파일러는 바이너리를 생성합니다. 그 바이너리 내의 어셈블리 코드는 특정 CPU만을 대상으로 합니다.
반면 PHP나 Perl과 같은 언어는 인터프리트 됩니다. 동일한 프로그램 소스 코드는 머신이 같은 인터프리터라고 불리는 프로그램을 갖고 있으면 어떤 CPU에서든 실행이 가능합니다.
인터프리터는 각 라인이 실행되면서 프로그램의 각 코드를 한 줄씩 바이너리 코드로 번역합니다.
두 방식의 차이를 보겠습니다.
인터프리터 언어로 작성된 프로그램은 컴파일 언어보다 이식성이 높습니다. 동일한 코드를 짜서 적절한 인터프리터가 있는 머신에 올리고 실행시킬 수 있습니다.
하지만 컴파일 언어보다 빠르게 실행되지는 않습니다. 간단한 예로 하나의 루프에서 작업하는 일이 있다고 생각해보면 인터프리터는 루프 내에서 실행될 때 코드의 각 줄을 다시 번역합니다. 컴파일된 코드는 되풀이해서 번역할 필요가 없습니다.
그리고 바이너리를 생성할 때 컴파일러가 할 수 있는 역할은 더 많습니다.
그 중 관련된 건 바이너리 문장의 순서 입니다. 모든 어셈블리 언어 명령어는 실행하는데 동일한 시간이 걸리지 않습니다.
두 개의 레지스터 내에 저장된 값을 더하는 문장은 한 번 실행되지만 덧셈에 필요한 값을 메모리에 조회하는데는 여러 번의 주기를 가질 것입니다.
그러므로 컴파일러는 이러한 문장의 순서를 이해하고 먼저 데이터를 로딩하기 위한 어셈블리 명령을 실행한 후 덧셈을 하도록 순서를 바꿀 것입니다.
하지만 인터프리터는 메모리에서 데이터를 요청하고 이용할 때까지 기다린 다음 덧셈을 할 것 입니다.
이러한 이유로 인해 인터프리트된 코드는 언제나 컴파일된 코드보다 느릴 것입니다.
자바는 여기서 절충안을 찾으려고 시도합니다. 자바 애플리케이션은 컴파일되지만 특정 CPU에 맞는 특정 바이너리로 컴파일 되는게 아니라 대신 최적화된 어셈블리 언어인 바이트 코드로 컴파일 됩니다.
이는 JVM이 있는 환경이라면 어디서든 실행시킬 수 있는 독립적인 플랫폼을 제공합니다. 그리고 JVM 내의 인터프리터로 최적화된 바이트 코드를 실행하면서 JIT 컴파일러로 바이너리로 컴파일 할 수 있습니다.
핫스팟이란 이름은 코드 컴파일에 대한 접근법에서 유래했습니다. 일반적인 프로그램은 전체 코드 중 일부만 자주 실행되며 애플리케이션의 성능은 이 일부가 얼마나 빠르게 실행되는가에 의해 좌우됩니다.
이 중요한 영영을 애플리케이션의 핫스팟이라고 합니다. 그 영역의 코드가 많이 실행될수록 더욱 핫해집니다.
이런 이유로 JVM은 코드를 실행할 때 바로 코드 컴파일을 하지 않습니다. 여기에는 기본적인 이유가 두 가지가 있습니다.
첫째, 코드가 한 번만 실행된다면 컴파일은 헛수고다. 컴파일해서 컴파일된 코드를 한번만 실행하는 것보다 자바 바이트코드를 인터프리트하는 편이 더 빠를 것이다.
둘째, 최적화 때문입니다. JVM이 특정 메소드나 루프를 실행하는 시간이 길어질 수록 코드에 대해 얻어지는 정보가 많습니다. 이를 통해 JVM이 코드를 컴파일할 때 최적화를 많이 적용할 수 있습니다.
첫번째 이유의 경우 자주 호출되는 메소드거나 많이 반복되는 루프라면 컴파일할 가치가 있습니다. 컴파일러는 반드시 컴파일하기에 충분할 정도로 자주 호출되는 메소드를 알아낼 수 있습니다. 이는 뒤에서 설명하겠습니다.
가장 중요한 최적화 중 하나인 레지스트리와 메인 메모리 최적화 예시를 설명하겠습니다.
public class RegisterTest{
private int sum;
public void calculateSum(int n){
for(int i=0;i<=n;i++){
sum += i;
}
}
}
이 예에서 인스턴스 변수인 sum은 메인 메모리 내에 있어야 하지만 메인 메모리에서 매번 값을 검색한다면 성능은 형편 없을 것입니다.
최적화를 한다면 컴파일러는 sum의 초기값을 레지스터에 로드하고 레지스터내의 값을 이용해 루프를 수행한 후 메인 메모리에 결과 값을 저장할 것입니다.
이 최적화는 매우 효과적이지만 스레드 동기화에서는 좀 다르게 적용될 것입니다. 왜냐하면 스레드는다른 스레드가 사용하는 레지스터의 값을 조회할 수 없기 때문입니다.
동기화를 한다면 레지스터에서 메인 메모리로 저장되어 다른 스레드들이 이용 가능한 시기를 알려줘야 합니다.
두번째 이유에 대해 간단히 예시를 들겠습니다.
b = obj1.equals(obj2);
equals() 메소드의 경우 모든 자바 객체에서 사용할 수 있는 메소드이며 흔히 오버라이드 됩니다.
인터프리터는 위의 문장에 맞닥뜨릴 때 실행시킬 equals() 메소드가 뭔지 알기 위해서 obj1의 타입을 찾기 위해 동적 look up을 해야 합니다. 이는 꽤 시간이 걸립니다.
시간이 흐르면서 이 문장을 많이 실행 해봤고 매번 obj1의 타입이 java.lang.String 이라는 사실을 알게되었다고 가정해봅시다. 그러면 JVM은 obj1.equals()를 String.equals()로 최적화한 코드를 만들 수 있습니다.
JIT 컴파일러는 두 가지 형태로 나뉩니다. 사용할 형태는 흔히 애플리케이션이 실행 되고 있을 때 해야 할 컴파일러 튜닝에 따라 결정됩니다.
두 개의 컴파일러는 클라이언트와 서버로 알려져 있습니다.
JVM 개발자들은 흔히 c1(컴파일러 1, 클라이언트 컴파일러)와 c2(컴파일러 2, 서버 컴파일러)라는 이름으로 부릅니다.
두 컴파일러의 주요 차이점은 코드 컴파일에 있어서 적극성의 유무 입니다.
클라이언트 컴파일러는 서버 컴파일러보다 먼저 컴파일하기 시작합니다. 이는 서버 컴파일러보다 상대적으로 더 많은 코드를 컴파일 한다는 의미이며 코드가 실행되기 시작하는 시간동안 클라이언트 컴파일러가 보다 더 빠를 것입니다.
서버 컴파일러는 클라이언트 컴파일러보다 더 많은 정보를 바탕으로 컴파일을 하고 더 나은 최적화를 제공합니다. 결국 애플리케이션이 장기간 작동을 한다면 서버 컴파일러가 클라이언트 컴파일러보다 더 빠를 것입니다.
각각의 컴파일러에 대한 트레이드 오프는 프로그램이 수행되는 기간과 초기 스타트업 시간의 중요도를 바탕으로 선택합니다.
여기서 한가지 의문이 있을 수 있습니다.
JVM이 시작할 땐 클라이언트 컴파일러로 작동하다가 코드가 많이 호출되면 서버 컴파일러로 바꿀 순 없을까? 이 기법은 티어드 컴파일(tiered compilation)이라고 합니다.
티어드 컴파일을 이용하면 코드는 먼저 클라이언트 컴파일러로 컴파일 되고 많이 쓰이게 되면 역최적화 후 서버 컴파일러로 재컴파일 됩니다. 재컴파일 되는 시간은 성능에 영향을 줄 정도로 크지 않습니다.
티어드 컴파일은 자바 7부터 릴리즈 되었으며 자바 8에서는 기본으로 사용할 수 있습니다.
빠른 스타트업이 주목적일 때 클라이언트 컴파일러가 가장 자주 사용됩니다.
표. 다양한 애플리케이션 스타트업 시간
애플리케이션 | -client | -server | -XX:+TieredCompilation |
---|---|---|---|
HelloWorld | 0.08 | 0.08 | 0.08 |
NetBeans | 2.83 | 3.92 | 3.07 |
BigApp | 51.5 | 54.0 | 52.0 |
단순한 HelloWorld 어플리케이션은 컴파일러가 어떤 기여도 할 수 없기 때문에 유리한 컴파일러가 없습니다.
NetBeans은 중간 크기의 자바 GUI 애플리케이션입니다. 스타트업할 때 약 10,000개의 클래스를 로드하고 몇개의 그래픽 객체를 초기화시키는 등의 작업을 합니다.
여기서 클라이언트 컴파일러는 스타트업할 때 매우 유리합니다. 서버 컴파일러는 38.5% 늦게 시작하며 1초 가량으로 뚜렷한 차이를 보입니다.
여기서 티어드 컴파일러도 클라이언트 컴파일러보다 약 8% 느리다는 점에 주목하면 됩니다.
GUI 프로그램 같은 경우는 더 빨리 시작할 수록 사용자에게 더 성능이 좋다고 느껴지는 프로그램입니다.
만약 전반적인 성능이 확실히 더 중요하다고 느껴지는 경우가 아니라면 클라이언트 컴파일러를 사용하는게 맞습니다.
BigApp 같은 경우는 20,000개 이상의 클래스를 로드하고 막대한 초기화를 수행하는 대규모 서버 프로그램입니다. 이 프로그램의 초기 스타트업의 영향은 컴파일러보단 디스크에서 읽어야 하는 JAR 파일의 개수 입니다. 클라이언트 컴파일러를 사용한다고 눈에 띄는 이점은 별로 없을 것입니다.
고정된 양의 작업을 수행하는 배치 어플리케이션에서 컴파일러의 선택은 중요한 요소입니다.
다음 배치 어플리케이션 예제를 통해 살펴보겠습니다.
표. 배치 애플리케이션 작업 완료 시간
주식의 개수 | -client | -server | -XX:+TieredCompilation |
---|---|---|---|
1 | 0.142 초 | 0.176 초 | 0.165 초 |
10 | 0.211 초 | 0.348 초 | 0.226 초 |
100 | 0.454 초 | 0.674 초 | 0.472 초 |
1000 | 2.556 초 | 2.158 초 | 1.916 초 |
10000 | 23.78 초 | 14.03 초 | 13.56 초 |
이 애플리케이션은 1~10000개의 주식에 대해 1년치의 이력 (평균과 표준편차)를 요청하는 애플리케이션 입니다.
1~100 개의 작업을 할 땐 클라이언트 컴파일러가 더 빨리 작업을 완료 했습니다. 그 후 성능상의 이점은 서버 컴파일러와 티어드 컴파일러 쪽으로 기웁니다.
티어드 컴파일이 항상 표준 서버 컴파일러보다 약간 더 성능상에 우위가 있다는 점도 흥미롭습니다.
이론상으로 일단 프로그램의 핫스팟을 전부 컴파일하기 충분할 정도로 스타트업 됐다면 서버 컴파일러의 성능이 더 나을 것으로 예상할 수 있습니다.
하지만 애플리케이션은 거의 항상 드물게 실행되는 작은 영역의 코드를 가지고 있고 티어드 컴파일러는 이 부분을 컴파일 했겠지만 서버 컴파일러는 인터프리트 모드로 실행했을 것입니다.
마지막으로 장기 수행 어플리케이션에서 컴파일러의 성능 차이를 보겠습니다.
장기 수행 어플리케이션은 전형적으로 코드의 주요 부분이 전부 컴파일될 정도로 충분히 오래 실행됐다는 의미입니다.
다음 예제는 0, 60, 300초의 준비 기간 후 60초 동안 측정을 합니다. 그 후 서버가 초당 몇개의 작업을 했는지 보여주는 예제입니다.
표. 서버 애플리케이션의 처리율
준비 기간 | -client | -server | -XX:+TieredCompilation |
---|---|---|---|
0 | 15.87 | 23.72 | 24.23 |
60 | 16.00 | 23.73 | 24.26 |
300 | 16.85 | 24.42 | 24.43 |
여기서 측정 기간은 60초이므로 준비 기간이 0초라도 충분히 컴파일러는 핫스팟을 컴파일할 기회가 많습니다. 그러므로 이 예제에서는 서버 컴파일러 쪽이 더 낫습니다.
앞의 내용과 같이 티어드 컴파일은 단독 서버 컴파일러보다 코드를 조금 더 많이 컴파일하고 조금 더 나은 성능을 쥐어짜낼 수 있습니다.
지금까지 클라이언트와 서버 컴파일러만 다뤘지만 JIT 컴파일러에는 세 가지 버전이 있습니다.
32-bit 클라이언트 버전
32-bit 서버 버전
64-bit 서버 버전
64-bit 서버 컴파일러는 티어드 컴파일을 지원하기 위해 클라이언트 컴파일도 가지고 있습니다.
32bit 운영체제를 가지고 있다면 JVM도 32bit를 사용해야 합니다.
64bit 운영체제를 가지고 있다면 JVM은 32bit 또는 64bit를 사용할 수 있습니다.
만약 힙 크기가 3GB보다 작다면 32bit 버전의 자바가 더 빠르게 동작할 수 있습니다. 당연하게 메모리 참조가 32bit 이므로 64bit 보다는 비용이 싸기 때문입니다.
만약 long이나 double 같은 64bit 변수를 광범위하게 사용하는게 아니고 전체 프로세스 크기가 4GB 미만이라면 32bit 컴파일러를 사용하는게 더 성능상의 우위가 있습니다.
컴파일러는 여러개 설치하고 사용할 수 있지만 호환성을 위해 어떤 컴파일러를 사용할지 명시하는 인자는 엄격하게 따지지 않습니다.
예시로 64bit JVM을 가지고 있다면 클라이언트 컴파일러를 사용하겠다고 -client 명령어를 붙혀도 서버 컴파일러를 사용하게 됩니다.
32bit JVM을 가지고 있다면 -d64 명령어를 통해 64bit 컴파일러를 사용하겠다고 하면 에러를 낼 것 입니다.
자바 8의 경우 서버 컴파일러가 디폴트일 때 티어드 컴파일도 디폴트로 사용하게 됩니다.
대개 컴파일러 튜닝이란 대상 머신에 설치하기에 알맞은 JVM과 컴파일러 모드( -client, -server, -XX:+TieredCompilation)를 선택하는 일입니다.
티어드 컴파일은 보통 오래 수행되는 어플리케이션에서 가장 최선의 선택입니다. 주기가 짧은 어플리케이션에서는 클라이언트 컴파일러를 선택해 성능의 이점을 누릴 수 있습니다.
그리고 이제 추가로 튜닝이 필요한 경우가 있습니다.
JVM이 코드를 컴파일할 때 코드 캐시 내에는 어셈블리 언어 명령 세트가 들어있습니다.
이 코드 캐시는 고정 크기이며 일단 가득 차게 되면 JVM은 더 이상 코드를 추가적으로 컴파일 할 수 없습니다.
이 말은 애플리케이션의 많은 양의 부분이 인터프리터로 실행될 수 있다는 말입니다.
이와 같은 이슈는 클라이언트 컴파일러나 티어드 컴파일러를 사용할 때 발생할 수 있습니다. 서버 컴파일러의 경우에는 몇개의 소수 클래스만 컴파일 되므로 코드 캐시를 채울 일은 그다지 없습니다.
코드 캐시가 가득 차게되면 JVM은 다음과 같은 경고를 발생합니다.
Java HotSpot(TM) 64-Bit Server VM Warning: CodeCache is full. Compiler has been disabled.
Java HotSpot(TM) 64-Bit Server VM Warning: Try increasing the code cache size using -XX:ReservedCodeCacheSize=
다음 표는 다양한 플랫폼에 대한 코드 캐시의 디폴트 값 목록입니다.
JVM 타입 | 디폴트 코드 캐시 크기 |
---|---|
32bit 클라이언트 자바 8 | 32MB |
티어드 컴파일을 하는 32bit 자바 8 | 240MB |
티어드 컴파일을 하는 64bit 자바 8 | 240MB |
32bit 클라이언트 자바 7 | 32MB |
32bit 서버 자바 7 | 32MB |
64bit 서버 자바 7 | 48MB |
티어드 컴파일을 하는 64bit 서버 자바 7 | 96MB |
자바 7을 사용한다면 티어드 컴파일의 경우에 디폴트 코드 캐시 사이즈가 부족해 캐시 크기를 늘릴 필요가 있습니다.
클라이언트 컴파일러를 사용하는 대형 프로그램이라면 또 코드 캐시 사이즈를 늘릴 필요가 있습니다.
특정 애플리케이션이 필요로 하는 코드 캐시가 어느 정도인지 알아내기에 적합한 매커니즘은 따로 없습니다.
코드 캐시의 최대 크기를 -XX:ReservedCodeCacheSize=N 플래그를 통해 디폴트의 두 배 또는 네 배로 늘려보는 방법밖에 없습니다.
코드 캐시는 JVM 내의 대부분의 메모리와 같이 관리되며 초기 크기가 있습니다. 초기 크기부터 시작해서 캐시가 차면 늘어나는 구조 입니다.
이런 캐시 리사이징은 백그라운드에서 일어나기 때문에 성능에는 실제로 영향을 주진 않습니다.
그러면 최대 코드 캐시로 실제 큰 값을 지정하여 공간이 부족하지 않게 하면 되지 않을까? 라고 생각할 수 있습니다.
이는 대상 머신에서 사용할 수 있는 가상 메모리의 크기에 따라 달려 있습니다. 1GB로 코드 캐시 사이즈를 명시했다면 JVM은 네이티브 메모리 크기를 1GB로 예약합니다. 이 메모리는 필요로 하기 전까지 할당되진 않겠지만 예약되어 있으므로 이 메모리를 제외한 나머지 메모리에서 자원을 사용할 것입니다.
즉 예약 조건을 만족시키기 위해 머신에 이용 가능한 가상 메모리가 충분하다면 사용하면 됩니다.
코드 컴파일을 유발하는 요소는 얼마나 자주 코드를 실행했는가 입니다. 일정한 횟수만큼 실행되고 나면 컴파일 임계치에 도달하고 컴파일러는 코드를 컴파일 하기에 충분한 정보가 쌓였다고 생각합니다.
컴파일은 JVM 내에 있는 메소드가 호출된 횟수, 메소드가 루프를 빠져 나오기까지 돈 횟수에 대한 카운터(counter) 두 개를 기반으로 합니다.
루프 자체의 끝에 이른경우 또는 continue와 같은 분기문을 만난 경우도 실행했기 때문에 루프 실행을 완료한 횟수로 생각할 수 있습니다.
JVM은 자바 메소드를 실행할 때 두 카운터의 합게를 확인하고 메소드가 컴파일 될 자격이 있는지 결정합니다.
자격이 있다면 메소드는 컴파일되기 위해 큐에서 대기합니다. 이런 컴파일을 정식 명칭은 없지만 일반 컴파일(standard compilation)이라고 합니다.
만약 정말 긴 루프가 있거나 빠져나오기 힘든 구조로 되어 있는 경우라면 어떨까요? 이 경우에 JVM은 메소드 호출을 기다리지 않고 루프를 컴파일할 필요가 있습니다.
따라서 루프의 실행이 완료될 때마다 분기 카운터가 증가하고 감지됩니다. 분기 카운터가 개별적인 임계치를 넘었다면 루프는 컴파일 대상이 됩니다.
이 종류의 컴파일은 루프가 컴파일되고 스택상의 교체(on-stack replacement, OSR)를 통해 컴파일된 루프를 실행하도록 동작합니다.
이 방식으로 인해 JVM은 스택상의 코드를 교체해 더 빠르게 실행될 수 있도록 합니다.
일반 컴파일은 -XX:CompileThreshold=N 플래그의 값에 따라 트리거됩니다.
클라이언트 컴파일러의 경우 N의 디폴트 값은 1500이고 서버 컴파일러는 10,000 입니다.
CompileThreshold 값을 더 낮게 변경한다면 더 빨리 컴파일 될 수 있습니다. 이 값은 메소드 엔트리 카운터(method entry counter)와 백 엣지 루프 카운터(back-edge loop counter)를 더한 합계로 계산됩니다.
주로 특정 메소드가 컴파일될 때 컴파일러에서 이용할 수 있는 정보 때문에 클라이언트와 서버 컴파일러의 궁극적인 성능에 차이가 납니다.
특정 서버 컴파일러에서 컴파일 임계치를 낮추면 실제 가능한 것보다는 덜 최적으로 코드가 컴파일될 위험이 있지만, 애플리케이션에서 테스트하면 사실 10,000번 과 8,000 번의 컴파일 사이에는 성능 상 차이가 거의 없습니다.
컴파일 임계치를 낮추는 이유는 다음 두가지 이유 떄문입니다.
애플리케이션 워밍업하는 데 필요한 시간을 약간 절약한다.
컴파일 임계치를 낮추지 않으면 절대로 컴파일 되지 않을 메소드들이 있다.
첫번째 이유는 쉽게 이해가 가므로 넘어가겠습니다.
두번째 이유는 컴파일 임계치에 도달하지 않았기 때문이 아닙니다. 메소드와 루프가 실행될 때마다 카운터 값이 증가하지만 시간이 지남에 따라 감소하기 때문에 컴파일 임계치에 도달하지 못합니다.
주기적으로 JVM은 세이프포인트(safepoint)에 이르렀을 때 각 카운터의 값은 감소합니다. 이를 통해 나름 사용한 메소드라고 하더라도 컴파일 되지 않을 수 있습니다. 이를 lukewarm 메소드라고 합니다.
컴파일러의 작업에 가시성을 제공하는 JVM 플래그로는 -XX:+PrintCompilation 입니다. 디폴트로는 false 입니다.
PrintCompilation이 활성화돼있다면 메소드가 컴파일될 때마다 JVM은 방금 컴파일된 대상에 대한 정보를 한 줄씩 출력할 것입니다.
결과물은 자바 릴리즈에 따라 다릅니다.
여기서는 자바 7 기준으로 적겠습니다. 컴파일 로그는 대부분 다음 형식을 따릅니다.
(timestamp compilation_id attributes (tiered_level) method_name size deopt)
timestamp
compilation_id
attributes
컴파일되고 있는 코드의 상태를 나타냅니다. 속성은 다음과 같습니다.
%: OSR 컴파일을 말합니다.
s: 메소드가 동기화됩니다.
!: 메소드는 예외 핸들러를 가지고 있습니다.
b: 블로킹 모드에서 컴파일 됩니다. (백그라운드에서 컴파일 되지 않는다는 의미입니다.)
n: 네이티브 메소드에 대한 래퍼를 위해 컴파일 됩니다. (네이티브 메소드를 호출 할 수 있도록 일부 컴파일 코드를 생성한다는 말입니다.)
tiered_level
method_name
size
deopt
컴파일이 동작하는 방법에 대한 상세 내역의 일부를 다루며 처리 과정에서 영향을 줄 수 있는 부가적인 튜닝을 소개하겠습니다.
하지만 이 값을 변경한다고 크게 달라지는 부분은 없습니다.
컴파일 임계치에서 메소드가 컴파일 대상이 되면 컴파일 큐에 들어가 대기하게 됩니다.
큐는 한 개 이상의 백그라운드 스레드에 의해 처리됩니다. 이건 컴파일이 비동기 프로세스로 대상 코드가 컴파일 중인 동안에도 계속해서 프로그램이 실행된다는 의미입니다.
메소드가 일반 컴파일을 한다면 다음 메소드를 호출하는 경우 컴파일된 메소드를 실행할 것입니다.
루프가 OSR을 통해 컴파일 된다면 루프가 다음 반복될 때 바로 컴파일된 코드를 실행합니다.
컴파일 큐는 FIFO 방식은 아닙니다. 호출 카운터가 더 높은 메소드가 우선순위가 더 높습니다. 즉 가장 중요한 코드가 먼저 컴파일되게 해줍니다.
클라이언트 컴파일러를 사용 할 때 JVM은 컴파일 스레드 한 개로 시작합니다. 서버 컴파일러는 두 개의 스레드로 시작합니다.
티어드 컴파일을 수행할 때 JVM은 대상 플랫폼의 CPU 개수와 이중 로그에 관한 복잡한 방정식에 근거해 디폴트 스레드를 정합니다.
다음 표는 티어드 컴파일에서 사용되는 스레드 개수입니다.
표. 티어드 컴파일에서 C1과 C2 컴파일러 스레드 디폴트 값
CPU 개수 | C1 스레드 개수 | C2 스레드 개수 |
---|---|---|
1 | 1 | 1 |
2 | 1 | 1 |
4 | 1 | 2 |
8 | 1 | 2 |
16 | 2 | 6 |
32 | 3 | 7 |
64 | 4 | 8 |
128 | 4 | 10 |
컴파일러 스레드 개수는 -X:CICompilerCount=N 플레그 설정을 통해서 조정이 가능합니다.
언제 이 값을 조정하는게 나을까요?
프로그램이 단일 CPU에서 실행된다면 컴파일러 스레드는 한 개만 쓰는 편이 낫습니다.
제한된 CPU만을 사용할 수 있는 환경에서는 더 적은 스레드가 그 자원을 두고 다투는 환경이 성능에 도움이 될 것 입니다.
물론 준비 기간에서는 컴파일러 스레드가 더 많으면 스타트업 시간에는 우위가 있을 것입니다. 하지만 현실에서는 이 이득이 그렇게 크지 않습니다.
준비 기간 이후에는 많이 쓰이는 메소드들이 점점 컴파일 되므로 CPU를 두고 경쟁하는 일이 없어질 것이므로 CPU 보다 적게 스레드를 두는게 낫습니다.
컴파일 스레드에 또 다른 설정은 XX:+BackgroundCompilation 플래그 입니다. 기본값은 true로 큐가 비동기 방식으로 처리되는 것을 말합니다.
플래그를 false로 설정한다면 메소드가 컴파일 되면 그걸 실행하길 원하는 코드는 실제로 컴파일될 때까지 기다리게 되빈다.
컴파일러가 하는 최적화 기법 중 하나는 인라인 메소드로 만드는 것입니다.
훌륭한 객체 지향 설계를 따르는 코드는 때로 getter(), setter()로 접근되는 메소드가 많습니다.
이런 메소드의 호출로 인한 오버헤드는 생각외로 매우 큽니다. 그래서 자바 초창기에 이런 종류의 캡슐화에 대한 반대 의견도 많았습니다.
다행히 JVM은 이런 종류의 메소드들을 기계적으로 인라인으로 만들 수 있습니다.
예는 다음과 같습니다.
Point p = getPoint();
p.setX(p.getX() * 2)
인라인 메소드로 컴파일된 코드는 다음과 같습니다.
Point p = getPoint();
p.x = p.x * 2;
인라이닝은 디폴트로 사용 가능합니다. 실제로 반드시 사용해야 할 정도로 성능을 매우 효과적으로 향상시킵니다.
하지만 -XX:-Inline 플래그를 통해 사용하지 않을 수도 있습니다.
기본적으로 메소드의 인라인화를 결정하는 요소는 얼마나 자주 호출되는 가와 메소드의 크기 입니다.
JVM은 내부 연산을 바탕으로 메소드가 얼마나 인기 있는지를 판단합니다.
이런 자주 호출되는 메소드의 경우에는 바이트 코드 크기가 325바이트보다 작다면 인라인화 됩니다.
이 크기는 =XX:MaxFreqInlineSize=N 플래그로 설정할 수 있습니다.
자주 호출되지 않는 메소드의 경우에는 바이트 코드가 35바이트보다 작다면 인라인화 됩니다.
이 크기는 -XX:MaxInlineSize=N 플래그로 설정할 수 있습니다.
때로 MaxInlineSize를 보다 높여서 더 많은 메소드를 인라인화 하기도 합니다. 하지만 35 바이트보다 키운다면 메소드가 처음 호출될 때 인라인화 될지도 모릅니다.
그리고 자주 호출되는 메소드의 경우 325바이트보다 작다면 인라인화 되므로 애플리케이션에 크게 영향을 주진 않습니다.
탈출 분석(Escape Analysis)은 객체의 스코프를 분석해서 특정 스코프를 벗어날 수 있는지 여부를 판단해 최적화를 적용하는 기법입니다.
탈출 분석(Escape Analysis)은 기본적으로 활성화 되있습니다. -XX:+DoEscapeAnalysis 플래그로 설정 할 수 있습니다.
예를 들어 다음 팩토리얼 클래스를 보겠습니다.
public class Factorial {
private BigInteger factorial;
private int n;
public Factorial(int n) {
this.n = n;
}
public synchronized BigInteger getFactorial(){
if(factorial == null) {
factorial = BigInteger.valueOf(0);
}
return factorial;
}
}
// 팩토리얼 값 100개를 저장
List<BigInteger> list = new ArrayList<>();
for (int i = 0; i < 100 ; i++) {
Factorial factorial = new Factorial(i);
list.add(factorial.getFactorial());
}
위의 코드 실행 흐름을 보면 팩토리얼 객체의 스코프는 루프 내에서만 참조 됩니다. 그 외 다른 코드에서 팩토리얼 객체를 접근할 순 없습니다.
이와 같은 탈출 분석을 통해서 JVM은 최적화를 할 수 있습니다.
getFactorial() 메소드를 호출할 때 동기화 락(synchronization lock)을 걸 필요가 없습니다.
팩토리얼 객체에 있는 메모리 필드에 n을 저장할 필요가 없습니다. 어짜피 루프가 끝나면 사라지므로 그냥 레지스터에서 값을 유지하면 됩니다.
팩토리얼 객체를 힙에 할당하지 않아도 됩니다. 어짜피 루프가 끝나면 사라지므로 그냥 로컬 스택을 사용해도 됩니다.
이런 종류의 최적화는 꽤 복잡합니다. 그리고 때론 탈출 분석의 최적화는 더 느려지는 경우가 되기도 합니다.
이 경우에는 코드를 단순하게 만든다면 해결할 수 있습니다.
역최적화(Deoptimaztion)는 컴파일러가 선행한 컴파일의 일부를 원상태로 되돌리는 걸 의미합니다.
이에 대한 영향은 컴파일러가 대상 코드를 다시 컴파일할 수 있을 때까지 애플리케이션의 성능은 감소합니다. 하지만 그렇게 크지 않습니다.
역최적화는 코드가 진입 불가(made not entrant)와 좀비화(made zombie)일 때 발생합니다.
코드에 진입하지 못하게 만드는 요인은 두 개가 있습니다.
하나는 클래스와 인터페이스가 동작하는 방식에 기인한 것이고 다른 하나는 티어드 컴파일의 구현 세부 사항입니다.
첫번째 경우를 보겠습니다.
주식 어플리케이션은 인터페이스로 StockPriceHistory를 가지고 있습니다. 이 인터페이스의 구현체로 StockPriceHistoryImpl과 로그를 쌓기 위한 인터페이스 구현체로 StockPriceHistoryLogger가 있습니다.
서블릿 코드에서는 URL 피러미터로 구현체를 구별합니다.
StorkPriceHistory sph;
String log = request.getParameter("log");
if(log != null && log.equals("true")){
sph = new StockPriceHistoryLogger();
}else{
sph = new StorkPriceHistoryImpl();
}
이 예제에서 만약 로그 요청 없이 http://localhost:8080/StockServlet 로 다수의 호출이 일어났다면 컴파일러는 sph의 객체의 실제 타입이 StockPriceHistoryImpl 이라는 걸 알고 최적화 할 것입니다.
하지만 나중에 로그 요청을 포함한 http://localhost:8080/StockServlet?log=true 요청을 호출한다면 sph 객체의 타입에 대한 컴파일러의 최적화는 틀렸다라고 판단하고 이전 최적화는 더 이상 유효하지 않을 것입니다.
이로 인해 역최적화의 함정에 빠져 이전 최적화는 폐기되고 진입 불가 상태가 되고 후에 좀비화가 될 것입니다.
역최적화가 처리되는 순간적인 시점을 제외하고는 적어도 성능 측면에서 역최적화는 그렇게 나쁘지 않습니다.
코드에 진입할 수 없는 두번째 요인은 티어드 컴파일이 동작하는 방식입니다.
티어드 컴파일에 의해 먼저 클라이언트 컴파일러로 컴파일이 되고 후에 서버 컴파일러에 의해 재컴파일 됩니다.
즉 서버 컴파일러에 의해 재컴파일할 때 컴파일된 코드를 교체합니다. 이는 오래된 코드를 진입 불가로 만들고 새로 교체될 코드로 대체하기 위한 과정입니다.
성능 측면에서 좀비 코드의 역최적화는 좋은 일입니다.
코드는 고정 크기의 코드 캐시에서 컴파일 됩니다. 좀비 메소드가 발견되면 다른 클래스를 컴파일할 공간을 코드 캐시에서 제거하면서 만듭니다.
티어드 컴파일을 사용하는 프로그램에 대한 컴파일 로그에는 각 메소드가 컴파일된 티어 레벨이 출력됩니다.
각 레벨의 컴파일은 다음과 같습니다.
0: 인터프리트된 코드
1: 단순 C1 컴파일된 코드
2: 제한된 C1 컴파일된 코드
3: 전체 C1 컴파일된 코드
4: C2 컴파일된 코드
전형적인 컴파일 로그를 보면 대부분의 메소드는 3레벨인 전체 C1 컴파일된 코드로 처음 컴파일된다는 걸 보여줍니다.
모든 메소드는 0레벨부터 시작하고 매우 빈번하게 수행된다면 3레벨을 거쳐서 4레벨에서 컴파일 될 것입니다. 그리고 3레벨의 코드는 진입 불가 상태가 됩니다.
만약 서버 컴파일러 큐가 가득차서 4레벨의 컴파일이 불가능하다면 프로파일 피드백을 필요로 하지않는 2레벨에서 컴파일 될 것입니다. 그 후 프로파일 정보를 모은 후 C1 컴파일러가 3레벨에서 컴파일 하고 마지막으로 서버 컴파일러 큐가 덜 바쁘다면 4레벨에서 컴파일 됩니다.
반면 클라이언트 컴파일러 큐가 가득 차면 3레벨에서 컴파일될 예정인 메소드가 4레벨 컴파일 대상이 될 수 있습니다. 이 경우에는 2레벨로 컴파일 된 다음 바로 4레벨로 넘어갑니다.
그리고 코드가 역최적화될 때 0레벨로 갑니다.
와..엄청난 내공의 글 감사드립니다!