JIT 컴파일러와 코드 캐시

진환·2024년 1월 11일
1

바이트 코드란?

Compilation
자바 코드를 컴파일러(javac)를 사용하여 컴파일하면 바이트 코드(class 파일)로 컴파일된다.
그리고 JVM이 해당 자바 바이트코드를 인터프리팅하며 애플리케이션이 실행된다.
그러므로 자바 코드를 컴파일하여 바이트 코드로 만들면 JVM이 실행될 수 있는 모든 플랫폼에서 실행할 수 있는 것이다. 그리고 또한 JVM 호환 바이트 코드로 컴파일 할 수 있는 모든 언어는 JVM에서 실행될 수 있다.

Just In Time Compilation의 개념

처음 애플리케이션이 구동되면 다른 인터프리터와 같이 JVM은 코드를 한 줄씩 해석해나간다.
하지만 이렇게 한 줄씩 해석해 나가면 성능이 좋지 않다.
자바에서는 이러한 문제를 해결하기 위해 Just In Time 컴파일, 줄여서 JIT 컴파일이라는 기능이 있다.

JVM은 가장 자주 실행되는 코드 분기, 메소드 또는 메소드의 일부를 모니터링하여 해당 코드를 네이티브 코드로 컴파일할 수 있다.
따라서 애플리케이션의 일부분은 바이트 코드로써 인터프리팅하여 실행되고, 일부분은 네이티브 코드로 컴파일된 코드를 실행하게 된다. 이렇게 네이티브 코드로 실행하게 되면 더 좋은 성능을 가질 수 있다.
JVM이 코드를 인터프리팅하면서 코드를 프로파일링하고, 컴파일하여 최적화할 수 있는 부분을 찾아내어 해당 코드를 JIT 컴파일러를 이용하여 네이티브 코드로 컴파일하고 컴파일이 됐을 경우 컴파일된 네이티브 코드를 실행하게 된다.
네이티브 코드로 컴파일하는 과정은 별도의 스레드에서 진행하게 되므로 애플리케이션이 정지되는 일은 없다.

JIT 컴파일로 인해 한 가지 주의해야 할 점이 있는데, 이는 컴파일되기 전과 후의 성능이 다르므로 성능을 측정할 경우에 주의해야 한다. 애플리케이션이 시작된 직후의 성능과 시간이 조금 지나고 난 후의 성능이 다를 수 있다.

그렇다면 어떤 메서드, 혹은 코드 블록에 JIT 컴파일이 사용되는지 알아보자.

예시 코드

public class Main {

	public static void main(String args[]) {
    	PrimeNumbers primeNumbers = new PrimeNumbers();
        int input = 5000;
        primeNumbers.generateNumbers(input);
    }
}
public class PrimeNumbers {

	private List<Integer> primes;
    
    public void generateNumbers(int max) {
    	primes = new ArrayList<>();
        primes.add(2);
        int next = 2;
        while (primes.size() <= max) {
        	next = getNextPrimeAbove(next);
            primes.add(next);
        }
    }
    
    private int getNextPrimeAbove(int previous) {
    	int testNumber = prevous + 1;
        while (!isPrime(testNumber))
        	testNumber++;
        return testNumber;
    }
    
    private boolean isPrime(int testNumber) {
    	for (int i = 2; i < testNumber; i++) {
        	if (testNumber % i == 0)
            	return false;
        }
        return true;
    }
}

input 값만큼의 소수를 생성하는 generateNumbers 메서드가 있고 해당 메서드를 5000번 호출한 후 어떤 컴파일이 발생했는지 확인하자.

위의 main 메서드를 실행하기에 앞서 VMOptions으로 -XX:+PrintCompilation 플래그를 설정한다.
(java -XX:+PrintCompilation Main)
그러면 아래와 같은 결과를 볼 수 있다.

												...
	 87   31       3       java.util.ImmutableCollections$SetN$SetNIterator::nextIndex (56 bytes)
     88   32       3       java.util.ImmutableCollections$SetN$SetNIterator::hasNext (13 bytes)
     88   30       3       java.util.concurrent.ConcurrentHashMap::spread (10 bytes)
     98   33     n 0       java.lang.invoke.MethodHandle::linkToStatic(LLLLLLL)L (native)   (static)
     99   34       1       java.lang.Enum::ordinal (5 bytes)
    100   35     n 0       java.lang.invoke.MethodHandle::linkToStatic(LL)I (native)   (static)
    100   36     n 0       java.lang.Object::hashCode (native)   
    100   37   !   3       java.util.concurrent.ConcurrentHashMap::putVal (432 bytes)
    101   38     n 0       java.lang.invoke.MethodHandle::invokeBasic(LLLLLL)L (native)   
    101   39     n 0       java.lang.invoke.MethodHandle::linkToSpecial(LLLLLLLL)L (native)   (static)
    101   41     n 0       jdk.internal.misc.Unsafe::compareAndSetLong (native)   
    103   40       3       java.util.concurrent.ConcurrentHashMap::addCount (289 bytes)
    104   42     n 0       java.lang.System::arraycopy (native)   (static)
    104   43       3       java.lang.String::length (11 bytes)
												...
	186  206 %     4       jvm.section2.firstexample.PrimeNumbers::isPrime @ 2 (35 bytes)
    186  200       1       java.lang.Boolean::booleanValue (5 bytes)
    186  205       1       java.util.ArrayList::size (5 bytes)
    186  208       3       java.lang.invoke.MethodType::hashCode (53 bytes)
    186  209     n 0       java.lang.Object::clone (native)   
    187  207       3       java.util.ArrayList::add (25 bytes)
    187  210       3       jvm.section2.firstexample.PrimeNumbers::getNextPrimeAbove (43 bytes)
    188  211       3       java.lang.Class::getName (18 bytes)
    190  210       3       jvm.section2.firstexample.PrimeNumbers::getNextPrimeAbove (43 bytes)   made not entrant
    190  212       3       jvm.section2.firstexample.PrimeNumbers::getNextPrimeAbove (43 bytes)
    191  213       4       jvm.section2.firstexample.PrimeNumbers::isPrime (35 bytes)
    194  214       1       java.lang.invoke.MethodType::ptypes (5 bytes)
    194  215     n 0       java.lang.invoke.MethodHandle::linkToStatic(L)L (native)   (static)
    194  201       3       jvm.section2.firstexample.PrimeNumbers::isPrime (35 bytes)   made not entrant
    194  216       3       java.lang.invoke.MethodType::parameterCount (6 bytes)
    197  217       3       java.lang.String::startsWith (138 bytes)
    237  218       4       jvm.section2.firstexample.PrimeNumbers::getNextPrimeAbove (43 bytes)
    251  212       3       jvm.section2.firstexample.PrimeNumbers::getNextPrimeAbove (43 bytes)   made not entrant

첫 번째 열은 가상 머신이 시작된 이후 경과된 시간 (밀리초)
두 번째 열은 메서드나 코드 블록이 컴파일된 순서(컴파일ID)를 의미한다.
두 번째 열이 순서대로 정렬되어 있지 않은 이유는 일부 부분이 다른 부분보다 컴파일하는 데 시간이 더 오래 걸렸다는 것을 의미한다. 멀티스레딩 문제 또는 컴파일되는 코드의 복잡성이나 길이로 인해 발생할 수 있다.
그리고 속성들이 나타난다.
%는 OSR이 발생했음을 의미 (OSR: 스택 프레임에 컴파일되지 않은 코드를 실행하고 있다면 컴파일된 코드로 교체한다.)
s는 동기화 메서드를 의미한다.
!는 메서드에 예외 처리가 포함되었다는 것을 뜻한다.
그리고 나타나는 숫자가 컴파일 단계를 뜻한다.

위의 결과를 보면 isPrime 메서드가 4단계로 컴파일 된 것을 볼 수 있다.

C1, C2 컴파일러

JVM에는 C1, C2라고 불리는 JIT 컴파일러가 있다. 이 컴파일러들을 이용해 네이티브 코드로 컴파일하는 것인데 각 컴파일러가 수행할 수 있는 컴파일 단계가 있다.

레벨 0 - 인터프리터 코드

JVM은 애플리케이션 구동 시 모든 바이트 코드들을 인터프리팅하며 코드를 읽는다. 이 단계에서는 일반적으로 컴파일된 언어에 비해 성능이 좋지 않다. 그 후 JIT 컴파일러는 런타임에 핫 코드를 컴파일한다. 이 단계에서 수집된 프로파일링 정보를 바탕으로 최적화를 수행한다.
(자주 실행되는 코드 섹션에 대한 바이트 코드를 핫스팟이라고 한다.)

레벨 1 - 간단한 C1 컴파일 코드

이 레벨에서 C1 컴파일러를 사용하여 코드를 컴파일하지만 프로파일링 정보는 수집하지 않는다. 중요하지 않다고 간주되는 코드에 레벨 1을 적용한다. 이 단계에서의 코드들은 중요하지 않고 복잡성이 낮기 때문에 더 높은 단계로 컴파일을 해도 성능이 좋아지지 않는다고 판단하여 프로파일링 정보를 수집하지 않는다.

레벨 2 - 제한된 C1 컴파일 코드

가벼운 프로파일링과 함께 C1 컴파일러를 사용하여 코드를 컴파일한다. C2 컴파일러의 큐가 가득 찼을 경우 코드를 레벨 2로 컴파일한다. 나중에 전체 프로파일링을 사용하여 레벨 3으로 컴파일한 후 C2 큐의 사용량이 줄어들면 레벨 4로 다시 컴파일한다.

레벨 3 - 전체 C1 컴파일 코드

전체 프로파일링과 함께 C1 컴파일러를 사용하여 코드를 컴파일한다. 레벨 3의 컴파일 단계는 기본 컴파일 단계이다. JVM은 레벨 1의 컴파일 단계가 아닐 경우, 컴파일러 큐가 가득 찬 경우를 제외한 모든 경우에 레벨 3단계의 컴파일을 진행한다.

레벨 4 - C2 컴파일 코드

C2 컴파일러를 사용하여 컴파일된 코드의 단계이다. 레벨 1의 중요하지 않다고 판단되지 않은 코드들을 제외한 모든 메서드를 해당 단계로 컴파일할 수 있다. 레벨 4 단계의 코드가 완전히 최적화된 것으로 간주되는 경우 프로파일링 정보 수집을 중지한다.

컴파일 비활성화

-XX:-TieredCompilation 플래그를 사용하여 컴파일을 비활성화할 수 있다. 이 플래그를 설정하면 사용할 JIT 컴파일러를 선택해야 하는데 지정하지 않을 경우 CPU를 기반으로 결정하게 된다. -Xint 플래그를 사용해 컴파일러를 사용하지 않고 인터프리터만 사용할 수 있지만 성능에 좋지 않다.

임계값 설정

계층화된 컴파일이 활성화된 경우 메서드 호출의 임계값을 설정하여 각 컴파일 수준을 결정할 수 있다.
-XX:+PrintFlagsFinal 플래그를 사용하여 기본 임계값을 볼 수 있고, -XX:Tier4CompileThreshold=10000과 같은 설정으로 임계값을 변경할 수 있다.

코드 캐시

코드 캐시는 JVM이 네이티브 코드로 컴파일된 바이트 코드를 저장하는 영역이다.
실행 가능한 네이티브 코드의 각 블록을 nmethod라고 부른다.

코드 캐시 튜닝

코드 캐시의 크기는 고정되어 있다.
코드 캐시에 배치할 코드가 제한된 크기를 넘어설 경우 삽입할 공간을 만들기 위해 일부를 제거할 수 있다.

코드 캐시가 가득 찰 경우 그리고 아래와 같은 경고 문구를 볼 수 있다.

VM warning: CodeCache is full. Compiler has been disabled

코드 캐시가 가득 차면 JIT 컴파일러가 비활성화된다. 그리고 코드 캐시에 저장된 코드들이 활발히 사용되고 있으므로 제거할 코드가 없다는 것을 뜻한다.

-XX:+PrintCodeCache 플래그로 코드 캐시를 확인할 수 있다. (자바 8까지의 경우의 예)

CodeCache: size=32768Kb used=542Kb max_used=542Kb free=32226Kb

코드 캐시는 32mb(size)의 크기를 가졌으며 542kb(used)가 사용 중이며 32226kb(free) 만큼의 메모리를 더 사용할 수 있다는 것을 뜻한다.

코드 캐시의 크기를 변경할 수 있는 플래그가 있다.

  • -XX:InitailCodeCacheSize
    애플리케이션이 시작될 때 코드 캐시의 크기이다.
    기본 크기는 메모리에 따라 다르지만 일반적으로 160kb이다.

  • -XX:ReservedCodeCacheSize
    코드 캐시의 최댓값이다.
    코드 캐시는 시간이 지남에 따라 ReservedCodeCacheSize로 설정한 값까지 커질 수 있다.
    기본값은 48mb이다.

  • -XX:CodeCacheExpansionSize
    코드 캐시가 가득 찰 경우 확장시킬 크기이다.
    코드 캐시를 늘릴 때 얼마만큼 늘릴지 정할 때 사용한다.

자바 9부터는 코드 캐시를 세 개의 영역으로 나누며 각 영역에는 특정 유형의 컴파일된 코드가 포함되어 있다.

CodeHeap 'non-profiled nmethods': size=120032Kb used=72Kb max_used=72Kb free=119959Kb
 bounds [0x0000000116dde000, 0x000000011704e000, 0x000000011e316000]
CodeHeap 'profiled nmethods': size=120028Kb used=261Kb max_used=261Kb free=119766Kb
 bounds [0x000000010f8a7000, 0x000000010fb17000, 0x0000000116dde000]
CodeHeap 'non-nmethods': size=5700Kb used=1085Kb max_used=1099Kb free=4614Kb
 bounds [0x000000010f316000, 0x000000010f586000, 0x000000010f8a7000]
 total_blobs=554 nmethods=225 adapters=243
 compilation: enabled
              stopped_count=0, restarted_count=0
 full_count=0

non-nmethods는 바이트 코트 인터프리터와 같은 jVM 내부 관련 코드가 포함된다.
-XX:NonNMethodCodeHeapSize 플래그를 사용하여 크기를 설정할 수 있다.

profiled nmethods는 수명이 짧은 가볍게 최적화되고 프로파일링된 메서드를 포함한다.
-XX:ProfiledCodeHeapSize 플래그를 사용하여 크기를 설정할 수 있다.

non-profiled nmethods는 완전히 최적화되고 긴 수명을 가진 프로파일링되지 않은 메서드를 포함한다.
-X::NonProfiledCodeHeapSize 플래그를 사용하여 크기를 설정할 수 있다.

References

https://openjdk.org/jeps/197#:~:text=Instead%20of%20having%20a%20single,internal%20(non%2Dmethod)%20code
https://www.baeldung.com/jvm-code-cache
https://www.baeldung.com/jvm-tiered-compilation
https://www.udemy.com/course/java-application-performance-and-memory-management/

profile
끄적끄적

0개의 댓글