염몽 개발일지
Published 2024. 8. 24. 16:44
Java. 메모리 가시성 Study/Java

1. 메모리 가시성

<code />
package thread.volatile1; import util.MyLogger; import util.ThreadUtils; import static util.MyLogger.log; import static util.ThreadUtils.*; 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 종료"); } } }

위의 코드에서 우리가 기대하는 것은 runFlagfalse가 되면 MyTask 스레드가 "task 종료" 가 되면서, 스레드가 종료되는 것이다.
하지만, 위의 코드의 실행 결과는 아래와 같다.

<code />
15:00:07.950 [ main] runFlag = true 15:00:07.953 [ work] task 시작 15:00:08.957 [ main] runFlag를 false로 변경 시도 15:00:08.958 [ main] runFlag = false 15:00:08.958 [ main] main 종료

while문을 빠져나오지 못하고, 무한루프를 돌고 있는 것이다.
분명 runFlagfalse로 변경한 것을 콘솔에 출력해서 확인했지만, 왜 while문을 빠져나오지 못한 것일까?

각 스레드가 runFlag의 값을 사용하면 CPU는 이 값을 효율적으로 처리하기 위해 캐시메모리에 불러온다.

그 이후 캐시 메모리에 있는 runFlag 값을 사용한다.

while(runFlag\[true\]) 는 메인 메모리, 힙 영역의 runFlag 변수 값을 읽는 것이 아니라 캐시메모리에 저장된 runFlag를 읽는다.

핵심은 캐시 메모리의 runFlag 값만 변한다!

캐시 메모리는 CPU 코어당 하나씩 가지고 있다고 생각하면 된다.

  • 언제 캐시 메모리 값을 메인 메모리의 값에 반영하고, 반대로 메인 메모리에 변경된 runFlag값이 언제 CPU 코어 캐시 메모리에 반영될까?
  • 정답은 "알 수 없다" 이다. CPU 설계 방식과 종류에 따라 다르며, 평생 반영되지 않을 수 있다.
  • 주로 컨텍스트 스위칭이 될 때, 캐시 메모리도 함께 갱신이 되는데 이 부분도 환경에 따라 달라질 수 있다.

이러한 문제를 메모리 가시성(memory visibility) 이라고 한다.

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

그렇다면 한 스레드에서 변경한 값이 다른 스레드에서 즉시 보이게 하려면 어떻게 해야할까?

캐시 메모리에 접근하는 성능을 약간 포기하는 대신에, 값을 읽을 때, 값을 쓸 때 모두 메인 메모리에 직접 접근하면 된다.
자바에서는 volatile 이라는 키워드로 이런 기능을 제공한다.

기존 코드에서 volatile 키워드로 추가해보자.

<code />
15:48:13.216 [ main] runFlag = true 15:48:13.220 [ work] task 시작 15:48:14.225 [ main] runFlag를 false로 변경 시도 15:48:14.226 [ main] runFlag = false 15:48:14.227 [ main] main 종료 15:48:14.238 [ work] task 종료

이렇게 하면 runFlag에 대해서는 캐시 메모리를 사용하지 않고, 값을 읽거나 쓸 때 항상 메인 메모리의 값을 읽거나 쓴다.
단, 캐시 메모리에서 값을 읽는 것이 아니기 때문에, 성능이 느려지는 단점이 있으므로, 꼭 필요한 곳에만 쓰자.

<code />
package thread.volatile1; import util.MyLogger; import static util.MyLogger.log; import static util.ThreadUtils.sleep; 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; @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 + " 종료"); } } }
<code />
16:08:58.895 [ work] flag = true, count = 100000000 in while() 16:08:59.226 [ work] flag = true, count = 200000000 in while() 16:08:59.489 [ main] flag = false, count = 275718895 in main 16:08:59.576 [ work] flag = true, count = 300000000 in while() 16:08:59.577 [ work] flag = false, count = 300000000 종료

main 스레드에서 count 값이 275718895일 때 flag 값을 false로 바꿨지만,
work 스레드의 flag 값이 한 참 뒤에서 false로 변경된 것으로 보인다.

여기서 정확히 300000000 에서 정확하게 값이 떨어져서 flag 값의 false로 변경된 이유가 무엇일까?
이는 콘솔에 출력 등을 할 때 스레드가 잠시 쉬는데, 이럴 때 컨텍스트 스위칭이 발생하면서 캐시 메모리의 값이 갱신된다.
이 부분은 주로 그렇다는 것이지 확실하게 캐시의 갱신을 보장하지는 않는다.

결국엔 이 상황에서 메모리 가시성 문제를 해결하려면 volatile 키워드를 사용해야한다.

<code />
volatile boolean flag = true; volatile long count;
<code />
16:27:06.624 [ main] flag = false, count = 50770369 in main 16:27:06.625 [ work] flag = false, count = 50770369 종료

Java Memory Model
JMM은 자바 프로그램이 어떻게 메모리에 접근하고 수정할 수 있는지를 규정,
핵심은, 여러 스레드들의 작업 순서를 보장하는 happens-before 관계에 대한 정의

happens-before
자바 메모리 모델에서 스레드 간의 작업 순서를 정의하는 개념
A 작업이 B 작업보다 happens-before 관계가 있다면, A 작업에서 변경된 내용은 B 작업이 시작 되기 전에 모두 메모리에 반영된다.

volatile 키워드 등을 넣으면 happens-before 관계가 성립되는 것이다.

happens-before 관계가 발생하는 경우:
그냥 읽고 넘어가면 된다.

  1. 프로그램 순서 규칙
  2. volatile 변수 규칙
  3. 스레드 시작 규칙
  4. 스레드 종료 규칙
  5. 인터럽트 규칙
  6. 객체 생성 규칙
  7. 모니터 락 규칙
  8. 전이 규칙

다음과 같이 정리할 수 있다.
volatile 또는 스레드 동기화 기법(synchronized, ReentrantLock)을 사용하면 메모리 가시성의 문제가 발생하지 않는다.

'Study > Java' 카테고리의 다른 글

Java. 고급 동기화 - concurrent.Lock  (3) 2024.08.28
Java. 동기화 - synchronized  (0) 2024.08.25
Java. 멀티 스레드와 동시성  (0) 2024.08.24
Java. 형식화 클래스  (0) 2023.05.12
Java. Calendar 클래스의 add 와 roll 메소드  (0) 2023.04.03
profile

염몽 개발일지

@염몽이

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!