관리 메뉴

나구리의 개발공부기록

메모리 가시성, volatile, 메모리 가시성, 자바 메모리 모델(Java Memory Model) 본문

인프런 - 실전 자바 로드맵/실전 자바 - 고급 1편, 멀티스레드와 동시성

메모리 가시성, volatile, 메모리 가시성, 자바 메모리 모델(Java Memory Model)

소소한나구리 2025. 2. 11. 11:04
728x90

출처 : 인프런 - 김영한의 실전 자바 - 고급1편 (유료) / 김영한님  
유료 강의이므로 정리에 초점을 두고 코드는 일부만 인용


1. volatile, 메모리 가시성

1) 예제1

(1) VolatileFlagMain

  • work스레드는 MyTask를 실행하고 여기에는 runFlag를 체크하는 무한 루프가 있으며 runFlag 값이 false가 되면 무한 루프를 탈출하며 작업을 종료
  • 이후에 main 스레드가 runFlag의 값을 false로 변경하면 work 스레드가 무한 루프를 탈출하며 작업을 종료하기를 기대했지만 실행해보면 출력문만 출력되고 무한루프는 종료되지 않는 것을 확인할 수 있음

** 주의!

  • 여기서는 volatile 키워드를 사용하지 않는 변수를 써야함
  • 무한 루프에 다른 코드가 있으면 다르게 동작함
package thread.volatile1;

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

        t.start();

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

    static class MyTask implements Runnable {

        boolean runFlag = true;
//        volatile boolean runFlag = true;

        @Override
        public void run() {
            log("task 시작");
            while (runFlag) {
                // runFlag가 false로 변하면 탈출
            }
            log("task 종료");
        }
    }
}
/* 실행 결과
08:43:20.167 [     main] runFlag = true
08:43:20.168 [     work] task 시작
08:43:21.171 [     main] runFlag를 false로 변경 시도
08:43:21.172 [     main] runFlag = false
08:43:21.172 [     main] main 종료
*/

 

(2) 동작 설명

  • main 스레드, work 스레드 모두 MyTask 인스턴스에 있는 runFlag를 사용하며 이 값을 false로 변경하면 work 스레드의 작업을 종료할 수 있음
  • 그러나 main 스레드에서 task.runFlag의 값을 false로 변경하면 출력은 runFlag의 값이 false로 변경된 것이 확인되지만 task 종료가 출력되지 않고 work 스레드가 while문에서 빠져나오지 못하고 자바 프로그램이 계속 실행되고 있음

** 참고

  • 실행 환경에 따라 실제 실행 결과는 달라질 수 있어 예제 코드와 똑같이 작성해야 비슷한 결과를 얻을 가능성이 높아짐

2) 메모리 가시성(memory visibility) 문제

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

  • main 스레드와 work스레드는 각각의 CPU코어에 할당되어서 실행되고 CPU코어가 1개라면 빠르게 번갈아 가면서 실행될 수 있음
  • 자바 프로그램을 실행하고 main 스레드와 work 스레드는 모두 메인 메모리의 runFlag의 값을 읽고 프로그램의 시작 시점에는 runFlag를 변경하지 않기 때문에 모든 스레드에서 runFlag의 초기값인 true의 값을 읽음
  • work 스레드는 while(runFlag)의 조건이 만족하므로 while문을 반복해서 수행함
  • 여기서 main 스레드는 runFlag의 값을 false로 설정하면 메인 메모리의 runFlag값이 false로 설정되고 work 스레드도 runFlag의 데이터를 메인 메모리에서 확인하여 while문의 조건이 false가 되고 while이 탈출할 것을 기대하지만 실제로는 이런 방식으로 동작하지 않음

(2) 실제 메모리의 접근방식

  • CPU는 처리 성능을 개선하기 위해 중간에 캐시 메모리라는 것을 사용하기 때문에 이런 문제가 발생함
  • 메인 메모리는 CPU입장에서 보면 거리도 멀고 속도도 상대적으로 느리지만 가격이 저렴하여 큰 용량을 쉽게 구성할 수 있음
  • CPU 연산은 매우 빠르기 때문에 CPU 연산의 빠른 성능을 따라가려면 CPU 가까이에 매우 빠른 메모리가 필요한데 이것이 바로 캐시 메모리임
  • 캐시 메모리는 CPU와 가까이 붙어있고 속도도 매우 빠른 메모리인데 상대적으로 가격이 비싸기 때문에 큰 용량을 구성하기는 어려움
  • 현대의 CPU 대부분은 코어 단위로 캐시 메모리를 각각 보유하고 있으며 여러 코어가 공유하는 캐시 메모리도 있음
  • 자바 프로그램을 실행하고 각 스레드가 runFlag의 값을 사용하면 CPU는 이 값을 효율적으로 처리하기 위해 먼저 runFlag를 캐시 메모리에 불러온 후 캐시 메모리에 있는 runFlag를 사용하게 됨
  • main스레드가 runFlag를 false로 설정하면 이때 캐시 메모리의 runFlag의 값이 false로 변경됨
  • 즉, 캐시 메모리의 runFlag의 값만 변하고 메인 메모리에 이 값이 즉시 반영되지 않음
  • work 스레드가 사용하는 CPU 코어2의 캐시 메모리에있는 runFlag의 값은 여전히 true이므로 반복문이 종료되지 않고 계속 수행되었던 이유가 여기에 있음

(3) 캐시메모리 -> 메인 메모리, 메인 메모리 -> 캐시 메모리 데이터 반영

  • 캐시 메모리에 있는 runFlag의 값이 언제 메인 메모리에 반영되는지는 명확하게 알 수 없음
  • 메인 메모리에 반영이 된다고 해도 문제는 메인 메모리에 반영된 runFlag의 값을 work 스레드가 사용하는 캐시 메모리에 다시 불러와야 하는데 이 부분도 마찬가지임
  • CPU 설계 방식과 실행 환경에 따라 다르기 때문에 즉시 반영될 수도 있고 몇 밀리초 후에 반영될 수도 있고 몇 초 후에 될수도 있고 평생 반영되지 않을 수도 있음
  • 주로 컨텍스트 스위칭이 될 때 캐시 메모리도 함께 갱신되는데 이 부분도 환경에 따라 달라질 수 있음
  • Thread.sleep()이나 콘솔에 내용을 출력할 때 스레드가 잠시 쉬는데 이럴 때 컨텍스트 스위칭이 되면서 주로 갱신되지만 이것이 갱신을 보장하는 것은 아님

** 참고

  • while 반복문에 출력문을 찍어보면 실행되다가 종료가 되는것을 확인할 수 있음

(4) 메모리 가시성(memory visibility)

  • 이처럼 멀티 스레드 환경에서 한 스레드가 변경한 값이 다른 스레드에서 언제 보이는지에 대한 문제를 메모리 가시성이라함

3) Volatile 적용

(1) volatile

  • 캐시 메모리를 사용하면 CPU 처리 성능을 개선할 수 있지만 때로는 이런 성능 향상 보다는 여러 스레드에서 같은 시점에 정확히 같은 데이터를 보는 것이 더 중요할 수 있음
  • 성능을 약간 포기하는 대신 값을 읽고 쓸 때 모두 메인 메모리에 직접 접근하면 문제를 해결할 수 있는데 자바에서는 volatile이라는 키워드로 이런 기능을 제공함

(2) VolatileFlagMain - volatile 적용

  • 기존 코드에서 boolean runFlag = true 대신에 주석 처리해두었던 volatile boolean runFlag = true로 실행해보면 정상적으로 task 종료 출력문과 함께 반복문도 종료되는 것을 확인할 수 있음
  • 즉, 캐시 메모리를 사용하지 않고 바로 메인 메모리에 직접 접근하여 runFlag의 값을 false로 변경했다는 뜻임
  • 여러 스레드에서 같은 값을 읽고 써야 한다면 volatile 키워드를 사용하면 되지만 캐시 메모리를 사용할 때 보다 성능이 느려지는 단점이 있기 때문에 꼭 필요한 곳에만 사용하는 것이 좋음
package thread.volatile1;

public class VolatileFlagMain {
    // ... 기존 코드 동일

    static class MyTask implements Runnable {

//        boolean runFlag = true;
        volatile boolean runFlag = true; // 사용

    // ... 기존 코드 동일

}
/* 실행 결과
09:27:51.492 [     main] runFlag = true
09:27:51.493 [     work] task 시작
09:27:52.496 [     main] runFlag를 false로 변경 시도
09:27:52.496 [     work] task 종료
09:27:52.496 [     main] runFlag = false
09:27:52.497 [     main] main 종료
*/

4) 실시간성이 있는 예제

(1) VolatileCountMain - volatile 미적용

  • work스레드는 반복문에서 count++을 사용하여 값을 계속 증가시키고 반복문을 1억번 실행할 때마다 count의 값을 출력함
  • flag 값이 false가 되면 반복문을 탈출하고 count 값을 출력함
  • main 스레드는 1초간 대기하다가 flag 값을 false로 변경하면 해당 시점의 count값을 출력함
  • 프로그램 실행 결과를 보면 main에서 flag의 값을 false로 변경하여도 work스레드는 계속 반복문을 수행함
  • work 스레드가 다음 값을 출력할 때에도 캐시 메모리에서 값을 읽어서 flag의 상태는 true값이 출력되며 이후에 false로 변경되는 것을 확인할 수 있음
  • 이 예제는 정확한 예제는 아니지만 대략적으로 반영되는 차이를 느낄 수 있음
package thread.volatile1;

public class VolatileCountMain {
    public static void main(String[] args) {
        MyTask task = new MyTask();
        Thread t = new Thread(task, "work");

        t.start();
        sleep(1000);

        task.flag = false;
        log("flag = " + task.flag + ", count = " + task.count + " in main");
    }

    static class MyTask implements Runnable {

        boolean flag = true;
        long count = 0;

        @Override
        public void run() {
            while (flag) {
                count++;
                // 1억번에 한번씩 출력
                if (count % 100_000_000 == 0) {
                    log("flag = " + flag + ", count = " + count + " in while()");
                }
            }
            log("flag = " + flag + ", count = " + count + " 종료");
        }
    }
}
/* 실행 결과
09:40:33.275 [     work] flag = true, count = 100000000 in while()
09:40:33.393 [     work] flag = true, count = 200000000 in while()
09:40:33.494 [     work] flag = true, count = 300000000 in while()
09:40:33.592 [     work] flag = true, count = 400000000 in while()
09:40:33.688 [     work] flag = true, count = 500000000 in while()
09:40:33.784 [     work] flag = true, count = 600000000 in while()
09:40:33.880 [     work] flag = true, count = 700000000 in while()
09:40:33.976 [     work] flag = true, count = 800000000 in while()
09:40:34.072 [     work] flag = true, count = 900000000 in while()
09:40:34.168 [     work] flag = true, count = 1000000000 in while()
09:40:34.177 [     main] flag = false, count = 1008774453 in main
09:40:34.264 [     work] flag = true, count = 1100000000 in while()
09:40:34.264 [     work] flag = false, count = 1100000000 종료
*/

 

(2) 시점의 차이

  • main 스레드가 flag를 false로 변경한 시점의 count 값은 1008774453이지만 work 스레드는 1100000000임
  • 결과적으로 main 스레드가 flag 값을 false로 변경하고 한참이 지나서야 work 스레드의 flag 값이 false로 변경됨
  • 여기서 11억에서 변경된 flag 값을 읽을 수 있었던 이유는 12억에서 콘솔에 결과를 출력하여 출력하는 동안 스레드가 잠시 대기하면서 컨텍스트 스위칭이 발생하며 캐시 메모리의 값이 갱신되었기 때문임
  • 이 부분은 주로 그렇다는 것이지 확실하게 캐시의 갱신을 보장하지 않으며 환경에 따라 결과가 달라질 수 있으므로 메모리 가시성 문제를 확실하게 해결하려면 volatile 키워드를 사용해야 함

(3) VolatileCountMain - volatile 적용

  • 공유 변수들에 volatile 키워드를 적용하여 실행해보면 main스레드와 work스레드가 flag를 false로 변경하는 시점의 count값이 똑같은 것을 확인할 수 있음
  • 추가적으로 volatile을 적용하면 캐시 메모리가 아니라 메인 메모리에 항상 직접 접근하기 때문에 성능이 상대적으로 떨어져 count의 값이 약 1.2억일 때 반복문이 종료되는 것을 확인할 수 있음
package thread.volatile1;

public class VolatileCountMain {
    // ... 기존 코드 동일

    static class MyTask implements Runnable {

        volatile boolean flag = true;
        volatile long count = 0;
        
    // ... 기존 코드 동일
}
/* 실행 결과
09:56:23.274 [     work] flag = true, count = 100000000 in while()
09:56:23.473 [     main] flag = false, count = 125299644 in main
09:56:23.473 [     work] flag = false, count = 125299644 종료
*/

 

** 참고

  • boolean flag 변수에만 volatile 키워드를 적용하면 count의 값이 약 5.2억에 반복문이 종료되어 성능저하가 덜하고 출력되는 값도 main스레드와 work스레드가 동일한 것을 확인할 수 있음
  • volatile이 선언된 변수가 사용되면 나머지 변수들도 함께 동기화 되기 때문인데 이 부분이 항상 보장되는 것은 아니라고함
volatile boolean flag = true;
long count = 0;

/* boolean flag만 사용했을 때 결과
10:04:53.175 [     work] flag = true, count = 100000000 in while()
10:04:53.395 [     work] flag = true, count = 200000000 in while()
10:04:53.601 [     work] flag = true, count = 300000000 in while()
10:04:53.807 [     work] flag = true, count = 400000000 in while()
10:04:54.012 [     work] flag = true, count = 500000000 in while()
10:04:54.070 [     main] flag = false, count = 527792290 in main
10:04:54.070 [     work] flag = false, count = 527792290 종료
*/

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

1) 자바 메모리 모델

(1) 설명

  • Java Memory Model(JMM)은 자바 프로그램이 어떻게 메모리에 접근하고 수정할 수 있는지를 규정하며 특히 멀티 스레드 프로그래밍에서 스레드 간의 상호작용을 정의함
  • JMM에 여러가지 내용이 있지만 핵심은 여러 스레드들의 작업 순서를 보장하는 happens-before 관계에 대한 정의임

(2) happens-before

  • happens-before 관계는 자바 메모리 모델에서 스레드 간의 작업 순서를 정의하는 개념임
  • A 작업이 B 작업보다 happens-before 관계에 있다면 A 작업에서의 모든 메모리 변경사항은 B 작업에서 볼 수 있다는 개념으로  A작업에서 변경된 내용은 B 작업이 시작되기 전에 모두 메모리에 반영됨
  • 이름 그대로 한 동작이 다른 동작보다 먼저 발생함을 보장하며, 스레드 간의 메모리 가시성을 보장하는 규칙임
  • happens-before 관계가 성립하면 한 스레드의 작업을 다른 스레드에서 볼 수 있게되므로 한 스레드에서 수행한 작업을 다른 스레드가 참조할 때 최신 상태가 보장되는 것임
  • 이 규칙을 따르면 프로그래머가 멀티스레드 프로그램을 작성할 때 예상치 못한 동작을 피할 수 있음

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

(1) 프로그램 순서 규칙

  • 단일 스레드 내에서 프로그램의 순서대로 작성된 모든 명령문은 happens-before 순서로 발생함
  • 예를 들어 int a = 1; int b = 2;에서 a = 1은, b = 2 보다 먼저 실행됨

(2) volatile 변수 규칙

  • 한 스레드에서 volatile 변수에 대한 쓰기 작업은 해당 변수를 읽는 모든 스레드에 보이도록 함
  • 즉, volatile 변수에 대한 쓰기 작업은 그 변수를 읽는 작업보다 happens-before 관계를 형성함

(3) 스레드 시작 규칙

  • 한 스레드에서 start()를 호출하면 해당 스레드 내의 모든 작업은 start() 호출 이후에 실행된 작업보다 happens-before 관계가 성립함
  • start() 호출 전에 수행된 모든 작업은 새로운 스레드가 시작된 후의 작업보다 happens-before 관계를 가짐

(4) 스레드 종료 규칙

  • 한 스레드에서 join()을 호출하면 join 대상 스레드의 모든 작업은 join()이 반환된 후의 작업보다 happens-before 관계를 가짐
  • join() 호출 전에 thread의 모든 작업이 완료되어야 하며 이 작업은 join()이 반환된 후에 참조 가능함

(5) 인터럽트 규칙

  • 한 스레드에서 interrupt()를 호출하는 작업이 인터럽트된 스레드가 인터럽트를 감지하는 시점의 작업보다 happens-before 관계가 성립함
  • interrupt() 호출 후 해당 스레드의 인터럽트 상태를 확인하는 작업이 happens-before 관계에 있다는 뜻이며 이런 규칙이 없다면 인터럽트를 걸어도 한참 나중에 인터럽트가 발생할 수 있음

(6) 객체 생성 규칙

  • 객체의 생성자는 객체가 완전히 생성된 후에만 다른 스레드에 의해 참조될 수 있도록 보장함
  • 즉, 객체의 생성자에서 초기화된 필드는 생성자가 완료된 후 다른 스레드에서 참조될 때 happens-before 관계가 성립함

(7) 모니터 락 규칙

  • 한 스레드에서 synchronized 블록을 종료한 후, 그 모니터 락을 얻는 모든 스레드는 해당 블록 내의 모든 작업을 볼 수 있음
  • synchronized(lock) { ... } 블록 내에서의 작업은 블록을 나가는 시점에 happens-before 관계가 형성될 뿐만 아니라 ReentrantLock과 같이 락을 사용하는 경우에도 happens-before 관계가 성립함

(8) 전이 규칙

  • 만약 A가 B보다 happens-before가 관계에 있고 B가 C보다 happens-before 관계에 있다면 A는 C보다 happens-before관계에 있음

(9) 정리

  • 스레드의 생성과 종료, 인터럽트 등은 스레드의 상태를 변경하기 때문에 어찌보면 당연하므로 아래의 한줄로 정리해볼 수 있음
  • volatile 또는 스레드 동기화 기법(synchronized, ReentrantLock)을 사용하면 메모리 가시성의 문제가 발생하지 않음
728x90