자바 동시성의 규칙; Java Memory Model (JMM)

matia·2024년 8월 4일
0

이 글은 JVM(Java Virtual Machine)과 Java의 기본 문법 지식을 요구합니다. 이 글을 더욱 자세히 이해하고 싶으시면, 먼저 관련 내용을 학습한 후 이 글을 읽는 것을 추천합니다.

메모리 모델(Memory Model)이란?

메모리 모델은 프로그램의 실행 방식과 메모리 접근 규칙을 정의합니다. Java Memory Model(이하 JMM)은 멀티 스레드 환경에서 변수의 값을 어떻게 읽고 쓰는지, 그리고 동기화를 어떻게 수행하는지를 규정합니다.

JMM을 이해하기 위해서는 두 가지 중요한 개념을 알아야 합니다:

  1. 명령어 재정렬 (Instruction Reorder)
  2. 메모리 가시성 (Memory Visibility)

1. 명령어 재정렬 (Instruction Reorder)

예제를 먼저 살펴보겠습니다.

public class InstructionReorderTest {

    private boolean flag;
    private int x;
    private int result;

    public void actor1() {
        if (this.flag) {
            result = x;
        } else {
            result = -1;
        }
    }

    public void actor2() {
        x = 5;
        flag = true;
    }
}

두 스레드가 각각 actor1()actor2()를 동시에 실행한다고 가정해 봅시다. result에는 어떤 값이 존재할까요?

x에는 5를 할당하고, 이후 flagtrue를 할당하기에 actor1()이 먼저 실행되면 5가, actor2()가 먼저 실행되면 result에는 -1이 들어있을까요?

OpenJDK에서는 Java의 동시성 테스트를 지원해 주는 JCStress라는 툴을 제공하고 있습니다. 이 테스트 툴을 활용해서 결과를 확인해 봅시다.

@Outcome(id = {"0"}, expect = Expect.ACCEPTABLE_INTERESTING,
				desc = "예상하지 못한 결과")
@Outcome(id = {"-1", "5"}, expect = Expect.ACCEPTABLE,
				desc = "예상한 결과")
@JCStressTest(Mode.Continuous)
@State
public class InstructionReorderTest {

    private boolean flag;
    private int x;

    @Actor // 동시에 실행
    public void actor1(I_Result result) {
        if (this.flag) {
            result.r1 = this.x;
        } else {
            result.r1 = -1;
        }
    }

    @Actor // 동시에 실행
    public void actor2(I_Result result) {
        this.x = 5;
        this.flag = true;
    }
}

테스트 결과

실행 환경
Intel Core i9, RAM 32GB, Mac OS X, JDK 17(Corretto 17.0.1)

RESULTSAMPLESFREQEXPECTDESCRIPTION
51,212,781,98467.98%Acceptable예상한 결과
-1571,150,57132.02%Acceptable예상한 결과
047,477<0.01%Interesting예상하지 못한 결과

일반적으로 우리는 5 또는 -1만 가능할 것이라고 예상합니다. 하지만 실제로는 0이 나올 수도 있습니다. 왜 이런 현상이 발생할까요? 이는 명령어 재정렬(Instruction Reorder) 때문입니다.

// 재정렬된 actor2() 메서드
public void actor2() {
    flag = true;  // 순서가 바뀜
    x = 5;
}

성능 최적화를 위해 컴파일러, JVM, CPU등 명령어의 실행 순서를 변경할 수 있는데요. 이를 명령어 재 정렬이라고 합니다. 더 자세한 내용을 알고 싶으신 분들은 Synchronization and the Java Memory Model글을 보시는 것을 추천드립니다.

2. 메모리 가시성 (Memory Visibility)

또 다른 예시를 살펴보겠습니다.

@Outcome(id = "TERMINATED", expect = Expect.ACCEPTABLE_INTERESTING, 
				desc = "정상적으로 종료")
@Outcome(id = "STALE", expect = Expect.ACCEPTABLE,
				desc = "종료되지 않음")
@JCStressTest(Mode.Termination)
public class VisibilityTest {

    private boolean flag = true;

    @Actor // 실행
    public void actor() {
        while (flag) {
						// 어떤일을 한다..
        }
    }

    @Signal // 종료 신호
    public void signal() {
        this.flag = false;
    }
}

한 스레드는 while문을 실행시키고, 다른 스레드가 종료시키기 위해 flagfalse로 변경하면 무한 루프가 종료될까요? 결과를 확인해 봅시다.

테스트 결과

실행 환경
Intel Core i9, RAM 32GB, Mac OS X, JDK 17(Corretto 17.0.1)

RESULTSAMPLESFREQEXPECTDESCRIPTION
TERMINATED7,19999.92%Acceptable정상적으로 종료
STALE60.08%Interesting종료되지 않음

결과를 확인해 보니 낮은 확률이지만 6건 정도는 while문에서 탈출하지 못하는 현상이 발생하네요! 이 현상은 왜 그럴까요? 이는 메모리 가시성과 관련이 있습니다.

출처: https://jenkov.com/tutorials/java-concurrency/false-sharing.html

각 스레드는 빠른 연산을 위해 메인 메모리에 바로 flush 하지 않고, 연산한 값들을 로컬 캐시에 저장하게 됩니다. 그 결과 flag가 변경된 결과는 메인 메모리에 바로 반영이 되지 않을 수 있고, while문을 실행하는 스레드도 메인 메모리에 있는 값을 안 가져올 수 있습니다.

이러한 상황들이 발생하면서 whlie문에서 탈출하지 못하고, 프로그램이 종료되지 않는 사례가 발생합니다.

volatile 키워드

이러한 동시성 문제를 해결해 주는 keyword가 있습니다. 바로 volatile입니다. volatile은 로컬 캐시에 저장하지 않고, 바로 메인 메모리에 flush 하게 됩니다. 하지만 메인 메모리에만 flush 하는 것으로는 모든 것을 해결할 수 없습니다. 바로 명령어 재정렬 현상이 발생하기 때문입니다.

다른 스레드가 메인 메모리에 있는 값들을 가져오더라도, volatile 이전에 연산된 값(이하 Happens Before)들이 보장되지 않으면 올바른 연산을 보장하지 못하기 때문입니다.

예전 JDK1.4 이전에서는 Happens Before를 보장하지 않았습니다. 많은 자바 개발자가 고통받은 후 JDK1.5부터 새로운 JMM을 도입하게 되어, 연산의 순서를 보장할 수 있었습니다. 이는 JSR-133에서 자세히 설명하고 있습니다.

public class VolatileKeyword() {

	int a, int b;
	volatile int c;

	public void write() {
		a = 1;
		b = 2;
		c = 3;
	}
		
	public void read() {
		if (c == 3) { // volatile을 읽어들인 이후
			System.out.println(b); // Happens Before 보장
			System.out.println(a);
		}
    }
}

위 예시에서도 volatilec를 읽어 들이면, Happens Before를 보장하여 b2a3을 출력하게 됩니다.

명령어 재정렬과 메모리 가시성을 예시로 든 코드를 volatile을 붙인 후 다시 한번 결과를 확인해 볼까요?

@Outcome(id = {"0"}, expect = Expect.ACCEPTABLE_INTERESTING,
				desc = "예상하지 못한 결과")
@Outcome(id = {"-1", "5"}, expect = Expect.ACCEPTABLE,
				desc = "예상한 결과")
@JCStressTest(Mode.Continuous)
@State
public class InstructionReorderTest {

    private volatile boolean flag;
    private int x;

    @Actor // 동시에 실행
    public void actor1(I_Result result) {
        if (this.flag) {
            result.r1 = this.x;
        } else {
            result.r1 = -1;
        }
    }

    @Actor // 동시에 실행
    public void actor2(I_Result result) {
        this.x = 5;
        this.flag = true;
    }
}
RESULTSAMPLESFREQEXPECTDESCRIPTION
5809,314,39623.44%Acceptable예상한 결과
-12,643,089,31676.56%Acceptable예상한 결과
000.00%Interesting예상하지 못한 결과
@Outcome(id = "TERMINATED", expect = Expect.ACCEPTABLE_INTERESTING, 
				desc = "정상적으로 종료")
@Outcome(id = "STALE", expect = Expect.ACCEPTABLE,
				desc = "종료되지 않음")
@JCStressTest(Mode.Termination)
public class VisibilityTest {

    private volatile boolean flag = true;

    @Actor // 실행
    public void actor() {
        while (flag) {
						// 어떤일을 한다..
        }
    }

    @Signal // 종료 신호
    public void signal() {
        this.flag = false;
    }
}
RESULTSAMPLESFREQEXPECTDESCRIPTION
TERMINATED28,293100.00%Acceptable정상적으로 종료
STALE00.00%Interesting종료되지 않음

결과를 확인해 보니 모두 예상한 결과가 나왔습니다.

volatile만?

Java는 volatile뿐만 아니라 다양한 동기화 키워드와 패키지를 제공하는데요. synchronized, Locks, java.util.concurrent, final, Thread 연산(join, start)도 volatile과 같은 규칙이 적용됩니다.

결론

이런 의문이 들 수 있습니다. “Java 개발하면서 이런것 까지 알아야 할까요? “, “그냥 Java에서 제공해 주는 concurrent 패키지를 쓰면 되지 않나요?”, “정말 낮은 확률인데…?” 등등…

만약 서비스를 운영하다가 동시성 문제가 발생한다면, 원인을 찾기 힘들 수 있습니다. 재현하기도 무척 힘들 수 있습니다. 하지만 이런 지식을 안다면, 원인을 찾기도 그나마 쉽고 동시성 문제가 발생하지 않도록 코드를 작성할 수 있지 않을까요?

JMM을 공부하면서 컴파일러, JVM, CPU가 최적화하는 방식에 대해 “왜 이렇게 프로그래머에게 고난을 주었나”라는 생각이 잠깐 든 적이 있었습니다. 하지만, 컴파일러, JVM, CPU는 죄가 없습니다. 당연히 최적화를 진행하여 최고의 성능을 발휘해야 하는 몫을 가지고 있습니다. 이러한 특성을 고려하고, 안정적인 코드를 작성하는 것은 프로그래머의 몫입니다.

지금까지 정말 간단하게 JMM에 대해 알아보았습니다. JMM에는 이뿐만 아니라 다양한 규칙이 있습니다. 더 궁금하신 분들은 출처에 적은 사이트를 한 번씩 방문하는 것을 추천해 드립니다.

마지막으로 저와 같이 고민해 주고, 토의해 주신 우아한 테크 캠프 캠퍼분들에게 감사합니다!

출처

https://www.cs.umd.edu/~pugh/java/memoryModel/jsr-133-faq.html

https://shipilev.net/blog/2014/jmm-pragmatics/

https://jenkov.com/tutorials/java-concurrency/java-memory-model.html

https://youtu.be/Z4hMFBvCDV4?si=MJQFDHIYxzLN8Kw4

https://youtu.be/qADk_tj4wY8?si=ZGysCOy_8M6pw4CI

윗글에 오류와 궁금하신 사항이 있으시면 아래 이메일로 연락해 주세요!
shuaigejp@naver.com

profile
생각은 복잡하게, 정리는 간단하게

0개의 댓글