포스트

메모리 가시성

메모리 가시성

메모리 가시성(memory visibility)이란?

  • 멀티 스레드 환경에서 한 스레드가 변경한 값이 다른 스레드에서 언제 보이는지에 대한 것

스레드간 변수 변경 시 적용 안되는 상황

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public class VolatileFlagMain {

  public static void main(String[] args) {
    MyTask task = new MyTask();
    Thread thread = new Thread(task, "work");
    log("runFlag = " + task.runFlag);
    thread.start();

    sleep(1000);

    log("runFlag를 false로 변경 시도");
    task.runFlag = false;
    log("runFlag = " + task.runFlag);
    log("main 종료");
  }

  static class MyTask implements Runnable {

    boolean runFlag = true;

    @Override
    public void run() {
      log("task 시작");
      while (runFlag) {
        // runFlag가 false로 변하면 탈출
      }
      log("task 종료");
    }
  }
}

위 코드를 실행해보면 task.runFlag 값이 false로 출력 됨에도 MyTask는 계속 실행되고 있어 프로그램이 종료하지 않습니다.

그 이유는 스레드 별로 변수를 캐시 메모리에 저장하기 때문입니다.

메모리 접근 방식

일반적으로 생각하는 메모리 접근 방식

실제 메모리 접근 방식

💡 캐시 메모리 <-> 메인 메모리

서로 값을 반영하는 타이밍은 일반적으로 컨텍스트 스위칭이 일어날 때이지만 이것도 보장되는 것은 아닙니다.

메인 메모리에 바로 접근 - volatile

변수 앞에 volatile 키워드를 쓰면 메인 메모리에 바로 접근 하는 변수가 됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public class VolatileFlagMain {

  public static void main(String[] args) {
    MyTask task = new MyTask();
    Thread thread = new Thread(task, "work");
    log("runFlag = " + task.runFlag);
    thread.start();

    sleep(1000);

    log("runFlag를 false로 변경 시도");
    task.runFlag = false;
    log("runFlag = " + task.runFlag);
    log("main 종료");
  }

  static class MyTask implements Runnable {

    // volatile 추가
    volatile boolean runFlag = true;

    @Override
    public void run() {
      log("task 시작");
      while (runFlag) {
        // runFlag가 false로 변하면 탈출
      }
      log("task 종료");
    }
  }
}

이제 두 스레드 모두 메인 메모리에 있는 runFlag 값을 참조하기 때문에 정상적으로 로직이 실행되는 것을 볼 수 있습니다.

volatile는 메인 메모리에 바로 접근 하기 때문에 성능 저하가 있습니다.

자바 메모리 모델(Java Memory Model)

  • 자바 프로그램이 어떻게 메모리에 접근하고 수정할 수 있는지를 규정

happens-before

  • JMM에서 스레드 간의 작업 순서를 정의하는 개념

규칙

  • 한 동작이 다른 동작보다 먼저 발생함을 보장
  • 스레드 간의 메모리 가시성을 보장하는 규칙
  • happens-before 관계가 성립하면, 한 스레드의 작업을 다른 스레드에서 볼수 있게 됨
    • 즉, 한 스레드에서 수행한 작업을 다른 스레드가 참조할 때 최신 상태가 보장 됨

happens-before 관계가 발생하는 경우

  • 프로그램 순서 규칙
    • 순서대로 작성된 모든 명령문은 happens-before 순서로 실행
  • volatile 변수 규칙
    • volatile 변수에 대한 쓰기 작업은 그 변수를 읽는 작업보다 happens-before 관계를 형성
  • 스레드 시작 규칙
    • 한 스레드에서 Thread.start()를 호출하면, 해당 스레드 내의 모든 작업은 start() 호출 이후에 실행된 작업보다 happens-before 관계가 성립
  • 스레드 종료 규칙
    • 한 스레드에서 Thread.join()을 호출하면, join 대상 스레드의 모든 작업은 join()이 반환된 후의 작업 보다 happens-before 관계를 가짐
  • 인터럽트 규칙
    • 한 스레드에서 Thread.interrupt()를 호출하는 작업이 인터럽트된 스레드가 인터럽트를 감지하는 시점의 작업 보다 happens-before 관계가 성립
  • 객체 생성 규칙
    • 객체의 생성자에서 초기화된 필드는 생성자가 완료된 후 다른 스레드에서 참조될 때 happens-before 관계가 성립
  • 모니터 락 규칙
    • 한 스레드에서 synchronized 블록을 종료한 후, 그 모니터락을 얻는 모든 스레드는 해당 블록 내의 모든 작업을 볼 수 있음
    • ReentrantLock과 같이 락을 사용하는 경우에도 happens-before 관계가 성립
  • 전이 규칙
    • A가 B보다 happens-before 관계에 있고, B가 A보다 happens-before 관계에 있다면, A는 C보다 happens-before 관계에 있음
    • 즉, (A > B, B > C = A > C)

💡 한줄 요약

volatile 또는 스레드 동기화 기법(synchronized, ReentrantLock)을 사용하면 메모리 가시성 문제가 발생하지 않는다.

참고

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.