들어가기 전에

JVM에 대한 포스팅에서 언급된 JIT 컴파일러는 JVM 내에서 동작하는 인터프리터의 느린 속도를 개선해주는 역할을 한다고 언급되었는데, 어떻게 속도를 빠르게 하는지 좀 더 자세히 공부해보고자 포스팅을 하게 되었다.


▶ 왜 속도가 개선되는가?

운영체제 시스템은 0과 1로만으로 구성된 바이너리 코드로 된 명령만을 실행할 수 있다. 

보통 C, C++, Forttarn과 같은 언어는 컴파일형 언어라 한 번에 모든 코드를 읽고 해석하여 바이너리 코드로 변환한다. 

Perl, PHP, Python, Java는 한 번에 한 줄의 코드만 읽고 해석하는 인터프리터 언어라고 한다. 그러므로 컴파일형 언어에 비해 느리다는 단점이 있다. 

 

Java에서는 이에 절충안으로 JIT 컴파일러를 사용한다. JIT 컴파일러는 바이트 코드를 한 줄씩 읽으면서 바이너리 코드로 해석한 뒤 CodeCache에 캐싱해놓고 다음에 같은 코드가 있다면 캐싱 값을 이용하여 매번 바이너리 코드로 해석하는 비용을 줄여준다. 인터프리터와 컴파일러의 방식을 적절히 혼합하여 속도를 개선한다.


 핫스팟(Hot Spot) 컴파일

일반적인 프로그램은 전체 코드를 골고루 다 실행하는 것이 아닌 일부만 주로 실행되며, 이 일부분의 실행 속도가 애플리케이션의 성능을 좌우한다. 이 영역이 핫스팟이다.

JIT 컴파일러는 HotSpot Detection을 이용해 일정 시간동안 인터프리터가 코드를 해석하는 과정에서 일정 이상 자주 호출되는 메서드를 식별하게 되면 핫스팟이라고 판단 후 컴파일을 진행한다.

 

JVM이 특정 메서드나 루프를 실행하는 시간이 길어질 수록 코드에 대해 얻어지는 정보가 많다. 이 정보를 통해 코드 컴파일 때 최적화를 많이 적용할 수 있다.

b = obj1.equals(obj2);

인터프리터는 위 문장을 읽을 때 실행시킬 equals 메서드가 무엇인지 알기 위해서 obj1의 타입을 찾기 위해 동적 look up을 해야하는데 이는 시간이 꽤 걸린다.

JVM이 시간이 흐르면서 이 문장을 많이 실행했고, 매번 obj1의 타입이 java.lang.String이라는 사실을 알게되면 JVM은 obj1.equals()를 String.equals()로 최적화한 코드를 만들 수 있다.

 


 JIT 컴파일러 종류

  1. 클라이언트 컴파일러 (Client Compiler)
    • Start-up 시간이 빠르며, 최적화를 위한 대기시간이 짧다.
    • 최적화가 덜하므로 코드 실행이 서버 컴파일러보다 느림.
  2. 서버 컴파일러 (Server Compiler)
    • Start-up 시간이 느리다.
    • 컴파일 전에 많은 정보를 수집하여 최적화가 좋음.
  3. 티어드 컴파일러 (Tiered Compiler)
    • 클라이언트, 서버 컴파일러의 장점을 조합한 컴파일러이다.
    • 클라이언트 컴파일러로 스타트업 시간을 빠르게 하고, 많이 쓰이는 부분을 서버 컴파일러로 재 컴파일하는 방식을 사용한다. "-server -xx:+TieredCompilation" 옵션으로 명시한다.

시간 비교

  1. Start-up: -client < -xx:+TieredCompilation < -server
  2. 코드 실행: -xx:+ TieredCompilation = -server < -client

옵션별 성능 비교 - 스타트업 시간

https://velog.io/@youngerjesus/자바-JIT-컴파일러#4-자바와-jit-컴파일러-버전

Hellow World와 같은 소형 애플리케이션은 성능 차이가 없다고 볼 수 있다.

 

NetBeans는 자바 GUI 애플리케이션이며 start-up 때 약 10,000개의 클래스를 로드하고 몇 개의 그래픽 객체를 초기화시키는 등의 작업을 한다.

이 경우 클라이언트 컴파일러가 start-up 성능이 우세하다. 서버 컴파일러보다 약 1초나 더 빠른 큰 차이를 보인다.

GUI 프로그램의 경우엔 start-up이 빠를수록 사용자에게 성능이 더 좋다고 느껴지므로 전반적으로 성능이 확실히 더 중요한 것이 아니라면 클라이언트 컴파일러를 사용하는 것이 적합하다.

 

BigApp은 20,000개 이상의 클래스를 로드하고 막대한 초기화를 수행하는 대규모 서버 프로그램이다.

이 프로그램의 초기 start-up 성능에 영향을 주는 요인은 컴파일러보단 디스크에서 읽어야 하는 JAR 파일 개수이므로, 클라이언트 컴파일러를 사용하여도 크게 성능이 좋아지진 않는다. 

 

이렇게 애플리케이션의 성격에 따라 옵션을 조절하는 것이 좋다.

특히 배치같은 많은 작업을 실행하는 애플리케이션일 경우 tiered, server 컴파일러가 더 성능이 좋다. 

 


 컴파일 임계치

JIT 컴파일러가 컴파일 하는 조건은 얼마나 자주 코드가 실행됐는가이다. 코드가 일정한 횟수만큼 실행되면 컴파일 임계치에 도달하여 컴파일 대상이 된다.

 

컴파일 기준 카운터

  1. JVM 내의 메서드가 호출된 횟수 (Method entry counter)
  2. 메서드가 루프를 빠져나오기까지 돈 횟수 (Back-edge loop counter)

JVM은 두 카운터의 합계로 메서드의 컴파일 자격 여부를 판단한다. 자격이 되면 컴파일 큐에 넣고 대기시키며 큐에서 차례가 되면 컴파일 스레드에 의해 컴파일 된다. 이 방식을 일반 컴파일(standard compilation)이라고 부른다.

 

일반 컴파일은 -XX:CompileThreshold=N 플래그 값에 따라 트리거된다. 

클라이언트 컴파일러의 경우 N의 디폴트 값은 1,500이고, 서버 컴파일러는 10,000이다. CompileThreshold 값을 낮춰서 더 빨리 컴파일 되게 할 수 있다.

 

컴파일 임계치를 낮추는 이유는 다음 두가지 이유 때문이다.

  1. 애플리케이션 워밍업하는 데 필요한 시간을 약간 절약한다.
  2. 컴파일 임계치를 낮추지 않으면 절대로 컴파일 되지 않을 메소드들이 있다.

2번의 경우 컴파일 임계치에 도달하지 않았기 때문이 아니다. 메서드와 루프가 실행될 때마다 카운터는 증가하지만 시간이 지남에 따라 감소하기 때문에 임계치에 도달하지 못한다.

주기적으로 JVM은 세이브포인트에 이르면 각 카운터의 값은 감소한다. 이를 통해 나름 사용한 메서드라 하더라도 컴파일 되지 않을 수 있다. 이를 lukewarm 메서드라고 한다.

 


 인라이닝

컴파일러의 최적화 기법 중 하나는 인라이닝 메서드로 만드는 것이다. 

훌륭한 객체 지향 설계를 따르는 코드는 getter(), setter()로 접근되는 메서드가 많다. 이런 메서드의 호출로 인한 오버헤드는 생각보다 매우 크다. 

JVM은 이런 종류의 메서드를 기계적으로 인라인으로 만들 수 있다.

다음 예제를 보자.

Point p = getPoint(); 
p.setX(p.getX() * 2)

이 코드를 인라인 메서드로 컴파일한 코드는 다음과 같다.

Point p = getPoint(); 
p.x = p.x * 2;

인라이닝은 디폴트로 사용 가능하며, 반드시 사용해야 될 정도로 성능을 매우 효과적으로 향상시킨다.

 

메서드의 인라인화를 결정하는 요소는 다음과 같다.

  1. 메서드가 얼마나 자주 호출되는가?
  2. 메서드의 크기

Flag

  • -XX:MaxFreqInlineSize=N
    자주 호출되는 메서드의 바이트 코드 크기가 N보다 작다면 인라인화 된다. (Default=325 byte)
  • -XX:MaxInlineSize=N
    자주 호출되지 않는 메서드의 바이트 코드 크기가 N보다 작다면 인라인화 된다. (Default=35 byte)

때로는 MaxInlineSize를 보다 높여서 더 많은 메서드를 인라인화 하기도 한다. 하지만 35 byte보다 크게 설정하면 메서드가 처음 호출될 때 인라인화 될 수도 있다.

 


참조

https://velog.io/@youngerjesus/%EC%9E%90%EB%B0%94-JIT-%EC%BB%B4%ED%8C%8C%EC%9D%BC%EB%9F%AC

https://beststar-1.tistory.com/3