본문 바로가기
Programming & Etc

Java synchronized 키워드와 Memory Barrier

by 탁종민 2021. 11. 3.

자바를 공부해본 사람이라면 effective java (3rd) 라는 유명한 책에서 다음과 같은 예제 코드를 본 적이 있을 것이다.

public class StopThread {
    private static boolean stopRequested;
    public static void main(String[] args)
            throws InterruptedException {
        Thread backgroundThread = new Thread(() -> {
            int i = 0;
            while (!stopRequested)
                i++;
        });
        backgroundThread.start();
        TimeUnit.SECONDS.sleep(1);
        stopRequested = true;
    }
}

 

실행 시 JVM은 먹통이 되고 실행되지 않는다. 책에서는 hosting이라는 최적화가 원인이라고 설명하며 synchronized 키워드를 사용하라고 한다. 대체 JVM의 내부적인 동작과 hosting이 무슨 관계이길레 코드를 먹통으로 만들고 synchronized를 사용하면 작동할까? 사실 알고보면 이는 절대 간단하지 않은 문제이다. 이를 알기 위해선 우선 기본적으로 JVM이 바이트코드를 어떻게 다루는지 알 필요가 있다.

 

JVM의 Bytecode Interpreter와 JIT-Compiler

일반적으로 JVM이 .java 파일을 컴파일해 실행시키는 프로세스를 다음과 같이 나타내곤 한다.

1. javac가 .java 파일을 컴파일해 .class 파일을 만든다.

2. jvm이 class파일을 로드시키면서 해당 아키텍쳐(cpu)에 맞게 머신코드로 컴파일한다.

3. 컴파일된 함수를 class와 연관시켜 class의 method가 호출될 때 compile된 macine code를 실행시킨다.

하지만 조금 깊게 파고들어 설명하면 과정을 완전히 달라진다.

https://developers.redhat.com/articles/2021/06/23/how-jit-compiler-boosts-java-performance-openjdk#hotspot_s_jit_execution_model

 

사실 JVM은 .class파일을 실행할 때 바이트코드를 처음부터 바로 머신코드로 컴파일하지는 않는다. 우선 bytecode interpreter를 이용해 bytecode를 실행한다. 바이트코드는 compiled된 머신코드보다 훨씬 공간 절약적이고( 이후에 보겠지만 monitorenter같은 바이트코드는 무지막지하게 긴 머신코드로 컴파일 된다. ) 컴파일이라는 작업 역시 시간을 잡아먹는 동작이다. 따라서 전체 life-cycle동안 단 한번 실행되는 코드처럼 굳이 컴파일 할 필요가 없는 바이트코드는 인터프리터로 곧바로 실행하는 것이 더 효율적이다. 그러다 여러번 실행을 거치며 해당 코드가 많이 실행되는 코드라고 판단되면(보통 이런상황을 여타 VM에서는 Warmed, Hot 이라는 표현을 쓴다.) 비로소 JIT-Compiler를 이용해 컴파일을 진행한다.

 

JIT-Compiler의 Hoisting최적화

( 지금부터는 github에 올려둔 예제 코드를 참고하길 바란다. https://github.com/zbvs/synchronized-jit-test )

Hoisting이라는 최적화는 JIT-Compiler와 연관이 있다. Hoisting이라는 최적화는 여타 다른 Compiler에서도 아주 보편적으로 쓰이는 최적화 기법이다. 

https://compileroptimizations.com/category/hoisting.htm

Hoisting은 굳이 루프내에서 행할 필요가 연산들은 루프밖으로 끄집어 내는 최적화다. 맨 처음 소개한 StopThread 예제에서 먹통이 나는 이유 역시 JIT-Compile의 Hoisting최적화 때문이다.  그렇기 때문에 JIT-Compile을 꺼버리고 Bytecode-Interpreter로만 실행하면 문제없이 실행된다.

# 예제코드 컴파일 & 실행
javac -d ./out $(find ./ -name "*.java")
java -Djava.compiler=NONE -cp ./out/ com.jittest.Main

 

그럼 그놈의 Hoisting 최적화된 x64 머신코드 결과물이 어떻길레 StopThread 예제를 먹통으로 만드는 건지 어디 한번 살펴보자. JIT-Compiler된 x64코드를 보려면 hsdis라는 JVM 플러그인이 필요하다. https://github.com/liuzhengyang/hsdis 에서 코드를 다운받고 컴파일 한 뒤 자신의 java의 jre/lib/amd64/server/ 경로에 갖다 놓자. (/usr/lib/jvm/{JAVA-VERSION}/jre/lib/amd64/server/). (*절대 시스템에 설치된 Default Java경로에 갖다 놓지는 말자. 무슨 문제가 생길지 장담 못한다. 기존 java directory를 어디로 복사한 후 복사된 direcory내에 있는 java로 테스트 하길 권한다. )

혹은 jvm의 디버그빌드를 구할 수 있다면 굳이 hsdis disassembler 플러그인을 쓰지 않아도 알아서 머신코드를 출력해 준다.

이제 hsdis disassembler를 활용해 disasseble된 x64코드를 한번 봐보자.

/usr/lib/jvm/java-11-copy/bin/javac -d ./out $(find ./ -name "*.java")
/usr/lib/jvm/java-11-copy/bin/java -XX:+UnlockDiagnosticVMOptions '-XX:CompileCommand=print,*JITTest.*' -cp ./out/ com.jittest.Main

 

무언가 쭈르륵 JIT-Compile이 진행되면서 disassembler된 x64코드들이 나온다. 그림에서 연두색 박스에 보면 r10 레지스터에 intValue값을 가져와 비교한다. 그 후 하늘색 박스에서는 단순히 ebx 레지스터에 0x7788을 더하면서 무한루프를 돈다. 

위에서 나타난 최적화 결과를 hoisting최적화 관점에서 Java 코드로 나타내면 아래와 같다. 이는 JIT-Compiler가 intValue를 완전히 loop independent한 variable이라고 판단해서 그렇다.

원래는 loop를 도는 동안 intValue값은 다른 스레드에서 해당 값을 바꿀 수 있기 때문에 loop내에서 계속 체크를 해야 한다. 하지만 싱글 스레드 환경이라고 가정하면 testValue를 건드리는 코드가 loop내에 없으므로 한번 루프에 진입하면 testValue는 고정된 값이라 판단하게 된다. 그래서 루프 내에서는 굳이 고정된 값을 검사하는 루틴이 필요없기에 빼버린다. 왜 싱글쓰레드라고 가정하지? 라는 의문에 대해 답변을 하자면, 여타 언어와 비슷하게 Java역시 volatile( 혹은 synchronized) 이라고 명시적으로 지정하지 않는 이상 모두 이런 류의 최적화 대상으로 간주하기 때문이다.

        int testValue = 0;
        while(true) {
            int intValue = this.intValue;
            if (intValue == 0x5566) break;
            testValue += 0x7788;
        }

        // -> hoisting 최적화
        // this.intValue는 while 루프 내에서 전혀 바뀌지 않는 loop independent한 값.
        // 그래서 루프 진입전 값이 this.intValue != 0x5566이였다면
        // loop 내에서도 영원히 this.intValue != 0x5566이라고 가정한다.
        // 따라서 최적화 된 이후의 코드를 나타내면 아래와 같다.
        
        if ( this.intValue == 0x5566 )
            return;
        while(true) {
            testValue += 0x7788;
        }

(* isIntValueEqual()라는 함수를 call Instruction으로 호출하는 대신 곧바로 0x5566가 비교하는 머신코드가 생성된 이유는 inlining 최적화 때문이다. CPU는  파이프라이닝을 이용해 머신코드를 순서대로 실행하는 데 최적화 되어있다. 함수호출같은 분기코드는 CPU가 파이프라이이닝을 활용한 코드실행 최적화에 방해가 된다. 또한 함수 스택을 만드는 등 부가적인 연산을 해야 한다. 때문에 JIT-Compiler는 CPU최적화 된 실행을 위해 함수 호출을 Inlining화 해버린다.)

 

synchonized키워드와 Memory Barrier

isIntValueEqual() 함수에 다음과 같이 synchronized 키워드를 넣어보자. 

    public boolean isIntValueEqual(){
        synchronized (this) {
            return intValue == 0x5566;
        }
    }

아래 결과 머신코드를 보면 연두색 박스부분에서 메모리에서 값을 가져와 0x5566 인지 비교하고 있다. 연두색 박스 앞뒤에 있는 하늘색 박스는 monitorenter, monitorexit 바이트코드때문에 생긴 lock, unlock 루틴들이다. 

synchronized블록은 monitorenter, monitorexit 바이트코드들을 생성한다. 이 바이트코드는 JIT-Compiler에게 Memory Barrier생성하게 한다. 아래 표의 3번째 row를 보면  1st operation인 "Volatile Load(volatile 변수에 대한 load로 보임) 나 MonitorEnter" 이후에 2nd operation으로 Normal Load, NormalStore가 있다면 각각 LoadLoad, LoadStore Barrier가 생성되어야 함을 알 수 있다( 참고로 x86  lock cmpxchg 명령어는 mfence 와 같은 memory barrier 역할을 한다. https://stackoverflow.com/a/50279772 ).

http://gee.cs.oswego.edu/dl/jmm/cookbook.html

synchronized를 사용했을 때는 JVM이 무한루프를 돌지 않고 탈출하는 이유는 결국 LoadLoad, LoadStore Barrier때문이다. 이런 Barrier들이 삽입되면 JIT 컴파일 단계에서도 Barrier 이전의 Load연산을 메모리로부터 값을 꺼내오는 머신코드로 컴파일 되며, 특히 x64에서는 lock cmpxchg 명령어로 인해 하드웨어적인 Memory Barrier도 추가된다. 따라서 예제 코드와 같이 루프 내에서 LoadLoad Barrierr가 삽입되면, 다음 Iteration의 LoadLoad Barrierr가 오기전에 루프 내의 모든 Load연산이 메모리로부터 값을 꺼내오게 컴파일된다.

이런 현상은 monitorenter로 인한 Memory Barrier때문에 발생하기 때문에 그냥 어떻게든 monitorenter바이트코드를 집어넣기만 하면 효과는 같다. 예를들어 다음과 같이 이상한 Object에 lock을 걸어도 Memory Barrier가 생성되어 this.intValue가 메모리로부터 값을 꺼내지게 한다.

    public boolean isIntValueEqual(){
        synchronized (System.out) {
            return intValue == 0x5566;
        }
    }

 

- ref

http://gee.cs.oswego.edu/dl/jmm/cookbook.html

https://www.fatalerrors.org/a/analysis-of-synchronized-jvm-source-code-and-the-significance-of-chatting-lock.html

https://dzone.com/articles/printing-generated-assembly

https://stackoverflow.com/questions/15360598/what-does-a-loadload-barrier-really-do

'Programming & Etc' 카테고리의 다른 글

Garbage Collection 알고리즘 - 2  (0) 2021.12.31
Garbage Collection 알고리즘 - 1  (0) 2021.12.31
Git Internal  (0) 2021.11.02

댓글