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 종료");
}
}
}
위의 코드에서 우리가 기대하는 것은 runFlag
가 false
가 되면 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
문을 빠져나오지 못하고, 무한루프를 돌고 있는 것이다.
분명 runFlag
를 false
로 변경한 것을 콘솔에 출력해서 확인했지만, 왜 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 관계가 발생하는 경우:
그냥 읽고 넘어가면 된다.
- 프로그램 순서 규칙
- volatile 변수 규칙
- 스레드 시작 규칙
- 스레드 종료 규칙
- 인터럽트 규칙
- 객체 생성 규칙
- 모니터 락 규칙
- 전이 규칙
다음과 같이 정리할 수 있다.
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 |