본문 바로가기
JAVA

Code Cache

by oncerun 2023. 2. 18.
반응형

애플리케이션 모니터링을 도입하던 도중 위젯에 code cache에 관해 설명하는 부분이 있었다.

Granafa에서 필요 없는 모니터링 위젯을 제거하는 과정에서  code cache가 무엇인지 몰라 정리를 늦췄는데 이번에 한번 알아보려고 한다. 

 

이 글은 다음 블로그를 참고했다. 

https://julio-falbo.medium.com/

 

Júlio Falbo – Medium

Read writing from Júlio Falbo on Medium. https://www.linkedin.com/in/juliofalbo/. Every day, Júlio Falbo and thousands of other voices read, write, and share important stories on Medium.

julio-falbo.medium.com

 

 

JIT 컴파일러는 두 가지 상태를 가진다. 

 

C1컴파일을 수행할 땐 javavc가 이미 컴파일한 bytes 코드를 OS에 맞게 실행하며,

warmup이 된 code block, method는 미리 native code로 컴파일하여 사용하는데 이를 code cache라고 부른다. 이 상태는 C2 상태이다. 

 

어떻게 특정 코드가 natvie machine language로 컴파일되어 있는지 확인할 수 있을까?

 

그건 JVM flag를 사용하면 이러한 의문점을 도와줄 수 있다.

-XX:+PrintCompilation이라는 옵션을 사용해 보자. 

 

그 예시는 다음과 같다.

50    1       3       java.lang.StringLatin1::hashCode (42 bytes)53    2       3       java.lang.Object::<init> (1 bytes)53    3       3       java.lang.String::isLatin1 (19 bytes)54    4       3       java.util.concurrent.ConcurrentHashMap::tabAt (22 bytes)
60    5       3       java.lang.String::charAt (25 bytes)
60    6       3       java.lang.StringLatin1::charAt (28 bytes)
60    7       3       java.lang.String::coder (15 bytes)
…
88   40     n 0       java.lang.invoke.MethodHandle::linkToSpecial(LLLLLLLL)L (native)   (static)
88   39   !   3       java.util.concurrent.ConcurrentHashMap::putVal (432 bytes)
90   41     n 0       java.lang.System::arraycopy (native)   (static)
91   42       3       java.lang.String::length (11 bytes)
...
129    3       3       java.lang.String::isLatin1 (19 bytes)   made not entrant
...
138  150     n 0       java.lang.Object::getClass (native)

 

이러한 log는 다음과 같은 format을 가진다.

timestamp compilation_id attributes tiered_level method_name size deopt

 

 

timestamp ( miliseconds)는  JVM이 시작하고 compilation 시간을 의미한다. 

 

comilation_id는  내부적으로 사용하는 식별자이다. 

이 식별자 번호는 단조롭게 증가하는 특정을 보인다. 다만 가끔 단조롭게 증가하지 않는데 그 이유는 우리가 멀티 스레드를 사용하여 진행하는 경우이다. 

 

attirbutes 필드는 컴파일 중인 코드의 상태를 나타내는 다섯 개의 문자열이다.

특정 속성이 지정된 컴파일에 적용되면 해당 속성의 문자가 출력되고 그렇지 않으면 공백이 출력됩니다. 

 

% - The compilation is OSR (on-stack replacement).
s - The method is synchronized.
! - The method has an exception handler.
b - Compilation occurred in blocking mode.
n - Compilation occurred for a wrapper to a native method.

 

% 특성은 on-stack replacement(OSR)을 말합니다.

JIT 컴파일러는 비동기적으로 실행된다.

따라서 JVM이 특정 코드 블록을 컴파일해야 한다고 결정하면 해당 블록은 큐에 저장됩니다. 

컴파일을 기다리는 대신 JVM은 메소드를 계속해석하고, 다음에 메서드를 호출할 때 메서드의 컴파일된 버전을 실행합니다.

 

OSR은 코드가 더욱이 최적화된 방식으로 실행되는 것을 말하며 우리가 warmup을 통해 native machine language를 사용하도록 컴파일하여 교체하는 과정을 말합니다. 

 

 

* Queue : 여기서 말하는 Queue는 FIFO 구조를 따르지 않는다. 호출 카운터가 높은 메서드가 우선순위를 가지게 됩니다. 

그렇기에 프로그램이 실행을 시작하고 컴파일할 코드가 매우 많은 경우에도 이 우선순위 순서는 가장 중요한 코드가 먼저 컴파일되도록 하는 데 도움이 됩니다.

 

s and ! 속성은 이해하기 쉽습니다.

"s"의 의미는 메서드가  synchronized 메서드라는 것을 의미합니다. 

"!"의 의미는  메서드에 Exception handler가 포함되어 있다는 것을 의미합니다.

 

blocking flag를 의미하는 "b" 현재 자바 버전에서는 출력되지 않습니다.

이는 컴파일이 백그라운드에서 수행되지 않았음을 나타냅니다.

 

"n"은 natvie를 나타냅니다. JVM이 생성할 때 native 메서드로 호출을 용이하게 하기 위해 컴파일된 코드로 생성합니다.

 

 

tiered_level

만약 -XX:-TieredCompilation 옵션을 통해 tiered 컴파일을 중지시킨다면 tiered_level 필드가 공백으로 출력됩니다. 

 

tiered_level을 0부터 4까지의 범위를 가진 숫자입니다. 

 

0 tier는 코드가 컴파일되지 않았음을 의미합니다. 이는 코드가 interpereted 되었다는 것을 의미합니다. 

 

1,2,3 tier는 코드가 C1에 의해 컴파일되었음을 나타냅니다. 그중 가장 최적화된 것은 profiling overhead가 적은 tier 1입니다.

 

tier 4는 C2로 컴파일된 것을 의미합니다. 이제 코드가 가능한 높은 수준의 컴파일되었다는 것을 의미하고 코드 캐시에 추가되었다는 것을 의미합니다. 

 

 

method_name 

메서드 네임필드는 다음과 같은 포맷을 가집니다. ClassName::method

 

 

size( in bytes)

size는 컴파일 중인 코드의  크기를 나타냅니다.

컴파일된 코드의 크기를 나타내지 않습니다. 자바의 bytes 코드의 크기이므로 이 값을 사용해요 코드 캐시 크기를 예측할 수 없다는 것을 알아야 합니다.

 

deopt

이 상태는 deoptimization가 발생함을 의미합니다. 이는 made not entrant 혹은 made zombie가 될 수 있음을 의미합니다.

 

 

C1, C2, deoptimization에 대해서는 지금 이해하지 않아도 상관없습니다. 

밑에서 추가적으로  설명하겠습니다. 

 

 

JVM 내부에 실제로는 두 가지의 컴파일러가 존재합니다. 

C1은 Client Compiler의 약자입니다, C2는 Server Compiler입니다. 

 

C1 컴파일러는 3단계 레벨 컴파일을 수행합니다

각 단계는 점진적으로 매우 복잡해집니다.

C2 컴파일러는 4단계  레벨의 컴파일을 수행합니다.

 

 

JVM은 코드에 대해 어떤 컴파일 레벨을 적용할지 다음과 같은 기준으로 결정합니다.

 

1. 얼마나 자주 실행되는지 

2. 얼마나 복잡한지

3. 혹은 시간을 얼마나 소비하는지

 

우리는 JVM이 어떤 컴파일 레벨을 적용할지 결정하는 과정을 "profiling"이라고 부르기로 했습니다.

따라서 아까의 로그에서 1~3의 숫자가 있는 모든 메서드의 코드는 C1 컴파일러는 사용하여 컴파일되었습니다. 수치가 높을수록 복잡함을 의미합니다. 

 

분명하게 말하면 Java의 Profiling은 Method Execution, Thread Execution, Object Creation, and Garbage Collection과 같은 다양한 JVM 수준의 매개변수를 모니터링하는 프로세스입니다.

 

만약 코드가 충분히 호출되었다면 우리는 해당 코드가 level 4에 도달했고, C2 컴파일러를 사용했다고 생각하면 됩니다. 

 

이 경우 우리의 코드가 C1 컴파일러를 사용하여 컴파일되었을 때보다 훨씬 더 최적화되어 있음을 의마하며 JVM은 코드의 해당 부분이 너무 많이 사용되어 레벨 4로 컴파일할 뿐만 아니라 컴파일된 코드를 코드 캐시에 저장합니다. 

 

 

What is Code Cache?

 

코드 캐시는 생각보다 간단합니다. 코드 캐시는 메모리에 접근할 수 있는 가장 빠른 방법이기 때문에 메모리의 특정 영역을 의미합니다.

 

이전에 4 teir, 4 level로 코드를 컴파일하는 것은 가장 최적화된 코드라는 것을 의미합니다. 

 

그런데 JVM은 모든 코드를 level 4로 컴파일하지 않을까요?

 

이 질문에 대한 기본적인 답은 리소스입니다. 

 

JVM은  성능과 리소스에 대해 tradeoff를 진행한다고 생각하면 될 것 같습니다. 

 

 

Deoptimization

 

Deoptimization은 undo를 의미합니다. 즉 컴파일러가 이전에 컴파일한 코드를 다시 되돌리는 것이죠. 

이는 컴파일러가 다시 해당 코드를 재컴파일 하지 전까지는 애플리케이션 성능에 영향을 미칠 수 있습니다. 

 

 JVM 코드를 실행할 때 코드를 즉각적으로 컴파일을 시작하지 않는데 여기에는 두 가지 이유가 존재합니다. 

 

우리의 코드가 딱 한 번만 실행된다고 상상해 봅시다. 이는 컴파일하는 작업을 낭비하는 것과 같습니다. 

이보다는 인터프리터를 통해 byte 코드를 실행하는 것이 더 빠를 수 있습니다. 

대신 코드가 매우 빈번하게 호출되는 메서드이거나 많은 반복이 있는 루프 구조라면 컴파일하는 작업은 많은 이점을 가져옵니다. 

 

이를 컴파일하는 과정에 드는 노력이 매번 해당 코드를 실행하는 것보다 더 많은 이점이 있기 때문이죠

 

이러한 트레이드오프가 존재하기 때문에  JVM의 컴파일러는 우선적으로 인터프리터를 통해 바이트 코드를 해석하고 실행합니다. 

 

두 번째 이유는 최적화 때문입니다.

JVM이 특정 메서드나 루프를 실행하는 횟수가 많을수록 코드에 대한 정보가 많아집니다. (profiling이라 부름) 

이러한 정보를 통해 JVM은 코드를 컴파일할 때 많은 최적화를 수행할 수 있습니다.

 

Scott Oaks의 간단한 예시는 equals() 메서드입니다. 

equals() 메서드는 모든 자바 객체에 존재합니다. 왜냐하면 Object는 모든 객체의 상위 클래스이기 때문이죠 

그리고 이는 자주 오버라이드됩니다. 

 

인터프러터가 다음과 같은 코드를 만나면 b = obj1.equals(obj2) 어떤 메서드가 실행되는지 알기 위해 obj1 class를 찾습니다. 

 

이러한 동적 조회는 시간 소모량이 커집니다. 시간이 지남에 따라 JVM이 명령문이 실행될 때마다 obj1이 java.lang.String type임을 알게 됩니다. 그런 다음 JVM은 String.equals() 메서드를 직접 호출하는 컴파일된 코드를 생성할 수 있습니다. 

 

이제 코드는 컴파일되었기 때문에 호출할 시 메서드의 조회를 건너뛸 수 있기 때문에 기존보다 성능이 향상됩니다. 

 

만약 다음번 실행에 obj1의 타입이 String이 아닐 수도 있습니다. 

 

JVM은 해당 가능성을 다루는 컴파일 코드를 만들고, depotimizing을 진행한 다음 다시 최적화하는 과정을 거칩니다. 

그럼에도 전체 컴파일 된 코드는 실행할 메서드 조회를 건너뛰기 때문에 더 빠릅니다. 

 

이러한 과정을 조금 생각해 보면 최적화를 진행하기 위해서 정보가 필요하다는 것을 알 수 있습니다.

실제 코드가 실행된 이후 이를 관찰하고 적절한 시기가 오면 컴파일 과정을 거칩니다. 

 

이러한 두 번째 이유가 JIT 컴파일러가 즉시 컴파일을 기다리는 이유 중 하나입니다. 

 

Deoptimization이 발생하는 이유는 두 가지 경우가 존재합니다. 

 

code is made not entrant와 made zombie를 만드는 경우입니다. 

 

Not entrant code

 

두 가지 이유로 not entrant code가 될 수 있습니다.

 

첫 번째는 다형성과 인터페이스를 사용할 때이며, 두 번째는 계층화된 컴파일에서 발생할 수 있습니다.

 

하나의 인터페이스에 두 가지의 구현체가 있다고 생각해 봅시다. 

package main;

public class DeoptimizationExample {
    public static void main(String[] args) {
        for (int i = 0; i < 50000; i++) {
            MyInterface myInterface;
            if (i < 45000) {
                // The first 45.000 executions will enter here
                myInterface = new MyInterfaceImpl();
            } else {
                myInterface = new MyInterfaceLoggerImpl();
            }
            myInterface.addARandomNumber(50);
        }
    }
}

interface MyInterface {
    void addARandomNumber(double value);
}

class MyInterfaceImpl implements MyInterface {
    @Override
    public void addARandomNumber(double value) {
        double random = Math.random();
        double finalResult = random + value;
    }
}

class MyInterfaceLoggerImpl implements MyInterface {
    @Override
    public void addARandomNumber(double value) {
        System.out.println("The value is: " + Math.random() + value);
    }
}

 

45000번을 MyInterfaceImpl의 생성자를 호출하고 5000번의 MyInterfaceLoggerImpl을 실행한 경우입니다. 

이 경우 XX:+PrintCompilation 옵션을 통한 로그는 다음과 같습니다. 

 

152  184  3  main.MyInterfaceImpl::addARandomNumber (10 bytes)   made not entrant

 

컴파일러는 MyInterface 객체의 유형이 MyinterfaceImple이라는 것을 알게 됩니다. 

이후 해당 코드를 성능 최적화를 위해  알게 된 정보를 기반으로 최적화를 수행합니다. 

 

45000번의 반복 이후 MyInterfaceLoggerImpl이라는 구현체가 실행됩니다. 

 

이제는 컴파일러가 myInterface 객체에 대해 만든 가정이 잘못된다는 것을 알고 이전에 진행한 최적화를 폐기시킵니다. 

이는 deoptimization을 의마하고 MyInterfaceLoggerImpl에 대해 더 많은 호출이 발생하면 JVM을 해당 코드를 컴파일하고 새로운 최적화를 수행합니다. 

 

not entrant code에 대한 첫 번째 시나리오로 다형성을 이용한 인터페이스를 적용 시 발생하는 일입니다. 

 

두 번째 시나리오는 다음과 같습니다. 

 

코드가  C2 컴파일러에 대해 컴파일되는 경우 JVM은 C1 컴파일러에 대해 이미 컴파일된 코드를 교체해야 합니다. 

 

이 과정에서 deoptimization으 발생하고 이전 코드를 제거하고 새롭게 컴파일된 코드를 사용하게 됩니다. 

따라서 계층화된 컴파일로 실행되는 경우 컴파일 로그에는 not entrant라는 로그가 발생할 수 있습니다. 

사실 이 경우는 코드를 더 빠르게 실행할 수 있도록 만드는 과정이라고 볼 수 있습니다.

 

 

made zombie code

 

예시코드를 다시 확인해 봅시다. 

 

package main;

public class DeoptimizationExample {
    public static void main(String[] args) {
        for (int i = 0; i < 50000; i++) {
            MyInterface myInterface;
            if (i < 45000) {
                // The first 45.000 executions will enter here
                myInterface = new MyInterfaceImpl();
            } else {
                myInterface = new MyInterfaceLoggerImpl();
            }
            myInterface.addARandomNumber(50);
        }
    }
}

interface MyInterface {
    void addARandomNumber(double value);
}

class MyInterfaceImpl implements MyInterface {
    @Override
    public void addARandomNumber(double value) {
        double random = Math.random();
        double finalResult = random + value;
    }
}

class MyInterfaceLoggerImpl implements MyInterface {
    @Override
    public void addARandomNumber(double value) {
        System.out.println("The value is: " + Math.random() + value);
    }
}

 

MyInterfaceImpl 클래스는 여전히 메모리에 존재합니다. 이후 모든 객체는 GC에 의해 수집될 것입니다. 

이러한 GC가 발생할 때 컴파일러는 zobmbie code로 마킹되는 코드라는 것을 인지하게 됩니다.

 

성능적으로 생각해 보면 이러한 과정은 좋은 영향을 미칩니다. 

 

컴파일된 코드는 고정 크기를 가진 code cache에 저장됩니다. 

zombie method가 식별되면 code cache에 저장된 코드는 제거됩니다. 

그렇기 때문에  다른 코드를 컴파일하여 추가할 수 있기 때문입니다. 

 

 

정리해 보죠.

 

JVM은 두 가지의 컴파일러를 사용합니다. 

C1, C2를 사용하며 런타임에  profiling을 통해 정보를 수집하고 적절한 한계에 도달하면 이를 코드캐시에 저장하는 과정을 거칩니다. 이로 인해 성능적으로 이점을 얻을 수 있습니다. 

 

이를 확인할 수 있는 JVM 옵션과 로그에 format에 대한 속성 설명을 통해 직접 확인할 수도 있습니다. 

 

하지만 not entrant code, zombie code에 대해선 성능에 영향을 미칠 수 있는 deoptimization이 발생하지만 이는 나쁠 수도 더 좋은 성능을 위한 스위치작업이라고 할 수도 있습니다. 

 

성능적으로 code cache는 중요한 역할을 맡고 있습니다. 

 

기존 기술 블로그에서 warm up 과정에 대한 글을 본 적이 있습니다.  해당 글에서 설명하는 것이 아마  C2 컴파일러를 통한 code cache에 저장하는 과정을 진행하는 것 같습니다. 

 

물론 리소스 사용에 대한 이야기는 없었는데 새로 알게 된 사실입니다. 

 

code cache는 고정된 크기를 가진 메모리 영역이라고 했습니다. 

 

리소스 여유가 있다면 code cache의 크기를 증가시켜도 되지 않을까요? 

 

다음에는 code cahe size를 증가시키는 것에 대한 글이 있어 읽고 정리해 보려 합니다. 

반응형

'JAVA' 카테고리의 다른 글

ZGC (HotSpot)  (0) 2023.07.11
Java Duration and Period Class  (0) 2023.02.23
Java 11 HttpClient Class  (0) 2022.12.13
S3 다중 업로드를 병렬로 처리 시 발생할 수 있는 문제..(CountDownLatch)  (0) 2022.12.03
keytool  (0) 2022.11.14

댓글