[JVM] JIT : Just In Time Compiler 개념 & 튜닝

2021. 12. 16. 12:37 JAVA/JVM
컴파일러와 인터프리터

인터프리터는 코드를 한줄 읽고 바로 결과를 출력한다. 같은 기능을 하는 코드가 다시 나와도 또 다시 해석하여 결과를 출력한다.
컴파일러는 한번만 소스코드를 컴파일하여 같은 기능을 하는 코드가 있으면 미리 컴파일된 결과를 이용하여 결과를 출력한다.
때문에 한 번만 실행될 코드라면 인터프리터가 더 빠르다. 하지만 많이 실행될 코드라면 컴파일을 하는 것이 좋다. 

 

JIT : Just In Time, 그때그때!

핫스팟 JVM의 핵심, 컴파일 방법중 하나
핫스팟이란, 가장 자주실행되는 영역을 의미한다.
가장 자주실행되는 영역만을 컴파일한다고 생각하면 된다. 

핫스팟 JVM은 코드를 바로 컴파일하지 않는다. 먼저 인터프리터가 동작하여 코드를 실행한다. 
일정시간 동안, 인터프리터가 코드를 해석하며 컴파일하기에 충분할 정도로 자주 호출되는 메소드가 무엇인지 알아내고 해당 메소드만 컴파일한다. 그리고 최적화도 수행한다. 

※ 최적화하는데 일정 시간이 필요한 예
b = obj1.equals(obj2)
위의 코드에서 obj1의 타입에 따라 동작이 달라진다. 때문에 Dynamic Lookup이 필요한데, 다이나믹 룩업은 해당 오브젝트의 타입을 실행시간에 결정하는 것으로 매우 느리다. 
따라서 JVM은 위의 코드가 실행될 때마다 obj1이 String이란것을 안다면, 동적 룩업을 하지 않고 String.equals()메소드를 호출하도록 컴파일한다. 하지만 다른 타입도 올 가능성이 있기 때문에, 가능성을 열어두고 컴파일한다. 
컴파일된 코드는 코드캐시라는 곳에 올라가며, 프로그램이 멈추지 않고도 컴파일된 코드가 바로 적용 가능하다.

여태까지 컴파일된 코드만 실행가능하다고 생각했고, 컴파일이 된 뒤에야 코드가 실행되는 거라고 생각해왔다. 하지만, JIT은 코드캐시라는 곳에 자주사용되는 컴파일된 코드를 저장하며, 이곳에 있는 컴파일된 코드들은 자주 사용되는 코드들로 동적으로 바뀌며, 바뀌더라도 프로그램이 정지하지않고 동작한다. 

 
컴파일러 튜닝이란

위에서 일정기간동안 자주 사용되는 메소드들을 알아낸다고 했는데 일정기간은 JVM 옵션을 통해 설정가능하다. 컴파일된 코드가 올라가는 코드캐시의 크기도 설정 가능하다. 이러한 컴파일 관련 설정들은 옵션을 통해 바꿀 수 있으며 이러한 작업이 컴파일러를 튜닝하는 일이다. 

컴파일러 종류

JIT 컴파일러는 사용하는 옵션(-client, -server)에 따라 클라이언트, 서버 컴파일러로 나뉜다.
※ 컴파일러 종류는 java -version을 보면 확인 가능

서버 컴파일러
- 컴파일전에 많은 정보를 수집하여 최적화에 중점을 둔다
- 서버 컴파일러는 절대로 모든 코드를 컴파일하지 않는다

클라이언트 컴파일러
- 서버 컴파일러보다 먼저 컴파일을 시작한다
- 최적화를 위한 대기시간이 짧다
- Start-Up 시간이 빠르다. 하지만 최적화가 덜하기 때문에 코드실행은 서버가 더 빠르다. 

Tiered Compile ( -server -xx:+TieredCompilation )

- "-server -xx:+TieredCompilation" 옵션으로 명시한다
- 먼저 클라이언트 컴파일러로 스타트업 시간을 빠르게 하고, 많이 쓰이는 부분을 서버 컴파일러로 다시 컴파일하여 대체한다. 
- JAVA 8 부터는 기본 옵션이다
- 스타트업 시간 :  -client < -xx:+TieredCompilation < -server
- 코드실행 시간 : - xx:+TieredCompilation = -server < -client

Client VS Server VS Tiered

어플리케이션의 성격에 따라서 옵션을 조절하자. 
특히 배치같이 많은 작업을 실행하는 어플리케이션일 경우 Tiered, Server 컴파일러가 빠르다. 

 
CodeCache 코드 캐시 튜닝

컴파일된 코드가 캐싱되는 곳
코드 캐시는 고정 크기이며, 가득차면 JVM은 더이상 코드를 컴파일할 수 없다.

※ 코드 캐시가 부족할 경우
- 일부 핫스팟은 컴파일만 컴파일 되고, 다른 영역은 컴파일 되지 않는다. 따라서 많은 양의 코드가 인터프리터로 실행되어 느려진다. 
- JVM Warning이 발생한다. 하지만 이 로그는 확인하기 어렵다. 따라서 컴파일 로그를 따로 보는 편이 좋다. 

일반 서버 컴파일러를 사용할 때는 컴파일 대상이 되는 클래스의 개수가 코드 캐시를 가득 채울 일은 그다지 없다. 하지만 클라이언트나 티어드 컴파일을 사용할 때는 주의해야 한다. 코드 캐시가 부족한 상황이 나타날 가능성이 크다. 

-XX:ReservedCodeCacheSize=N
코드 캐시의 최대 크기를 지정한다

-XX:InitialCodeCacheSize=N
초기 코드 캐시의 크기를 지정한다

적당한 코드 캐시가 어느정도인지 알아낼 방법은 없다. 보통 단순히 디폴트의 2~4배로 늘린다. 
그리고 모니터링을 통해 코드캐시가 부족하지 않은지 확인한다. 
디폴트 코드 캐시의 크기는 문서를 찾아보면 있으며, 64bit JAVA7 서버 컴파일러의 경우 48M

※ 코드 캐시 모니터링
- jconsole 을 이용하여 가능하다
- JDK가 깔려있다면 $ jconsole 입력하면 실행가능

캐시의 크기 조정은 백그라운드에서 동작하며 성능에 영향을 주지 않는다. 
코드 캐시의 크기는 머신에서 사용 가능한 물리적인 메모리의 크기에 의존한다.
물리적인 메모리의 크기만 충분하다면 코드 캐시의 최대 크기를 마음껏 늘려도 된다.

 
컴파일 임계치란?
컴파일 임계치 = method entry counter + back-edge loop counter

많이 사용되는 메소드들을 컴파일한다
많이 사용된다는 기준이 컴파일 임계치이다.

메소드가 호출된 횟수, 메소드 내의 루프가 있다면 루프를 빠져 나오기까지 돈 횟수 두 개를 기반으로 측정한다. 두 카운터의 합계를 확인하고 메소드가 컴파일 될 자격이 있는지 결정한다. 자격이 있다면 컴파일되기 위해 큐에서 대기한다. 큐에 있는 메소드들은 컴파일 스레드에 의해 컴파일 된다. 

OSR : On - Stack Replacement

처음 실행됬지만, 루프가 정말 길 경우 중간에 컴파일될 필요가 있다. 그래야 남은 반복을 빠르게 실행할 수 있다. 따라서 루프의 실행을 그때그때 카운트하고 임계치를 넘게되면 전체 메소드가 아닌 루프만을 컴파일하여 컴파일된 버전을 바로 실행시킨다. 스택상에서 컴파일된 버전을 바로 실행시키는 것을 OSR이라고 한다. 

-XX:CompileThreshold=N

컴파일 임계치를 지정한다
클라이언트 컴파일러에서 기본 값은 1,500
서버 컴파일러의 기본 값은 10,000

컴파일 임계치를 변경하는 것은 권고된다

- 어플리케이션이 워밍업 하는데 필요한 시간을 절약한다
==> 어짜피 10,000번 실행되서 컴파일될 코드는 8,000번으로 줄여도 큰 차이 없다
- 절대로 컴파일 되지 않을 일부 서버 메소드들을 컴파일 할 수 있다
==> 컴파일 임계치에 가까이 있지만, 아슬아슬하게 걸쳐서 컴파일되지 않는 것들을 컴파일해서 실행속도를 높힐 수 있다.

카운터 값은 최근 일정 기간에 대한 상대적 측정값이다.
따라서 lukewarm method(긴 시간에 걸쳐서 많이 호출되는 메소드)들은 카운터값이 낮을 수 밖에 없다. 따라서 컴파일 임계치를 낮춰서 컴파일을 해주는 것이 더 좋다.