volatile, 메모리 가시성 1
volatile과 메모리 가시성을 이해하기 위해, 간단한 예제를 만들어보자.
package thread.volatile1;
import static util.MyLogger.log;
import static util.ThreadUtils.sleep;
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를 사용해서 스레드의 작업을 종료한다.
- work 스레드는 MyTask를 실행한다. 여기에는 runFlag를 체크하는 무한 루프가 있다.
- runFlag 값이 false 면 무한 루프를 탈출하며 작업을 종료한다.
- 이후에 main 스레드가 runFlag 값을 false로 변경한다
- runFalg 값이 false 가 되었으므로 work 스레드는 무한 루프를 탈출하며, 작업을 종료한다.
주의! - 여기서는 아직 volatile 키워드를 사용하면 안 된다! 다음과 같이 꼭! 작성해야 한다.
boolean runFlag = true;
메모리 그림
- main 스레드, work 스레드 모두 MyTask 인스턴스 (x001)에 있는 runFlag를 사용한다.
- 이 값을 false로 변경하면 work 스레드의 작업을 종료할 수 있다.
프로그램은 아주 단순하다.
- main 스레드는 새로운 스레드인 workr 스레드를 생성하고 작업을 시킨다.
- work 스레드는 run() 메서드를 실행하면서 while(runFlag)가 true 인 동안 계속 작업을 한다.
- 만약 runFlag 가 false로 변경되면 반복문은 빠져 나오면서 "task 종료"를 출력하면서 작업을 종료한다.
- main 스레드는 sleep()을 통해 1초간 쉰 다음 runFlage를 false로 설정한다.
- work 스레드는 run() 메서드를 실행하면서 while(runFlag)를 체크해야 하는데 , 이제 runFlag 가 false 가 되었으므로 "task 종료"를 출력하고 작업을 종료해야 한다.
기대했던 실행결과
20:22:55.913 [ main] runFlag = true
20:22:55.915 [ work] task 시작
20:22:56.918 [ main] runFlag를 false로 변경 시도
20:22:56.918 [ work] task 종료
20:22:56.918 [ main] runFlag = false
20:22:56.919 [ main] main 종료
실제 실행 결과
실행결과를 보면 task 종료가 출력되지 않는다. 그리고 자바 프로그램도 멈추지 않고 계속 실행된다.
정확히는 work 스레드가 while 문에서 빠져나오지 못하고 있는 것이다.
분명히 runFlag 가 false 변경한 것을 콘솔에 출력해서 두 눈으로 확인했다.
이때 work 스레드가 실행하는 while(runFlag) 조건은 false 가 된다. 따라서 work 스레드는 while 문을 빠져나오고 task 종료를 출력해야 한다. 도대체 어떻게 된 일일까?
volatile, 메모리 가시성 2
메모리 가시성 문제
멀티스레드에서 메모리 가시성에 대해 알아보자.
먼저 우리가 일반적으로 생각하는 메모리 접근 방식에 대해서 설명하겠다
일반적으로 생각하는 메모리 접근 방식
- main 스레드가 work 스레드는 각각의 CPU 코어에 할당되어서 실행된다.
- 물론 CPU 코어가 1개라면 번갈아 가면서 실행될 수 있다.
- 빨간색 선 위쪽은 스레드의 실행 흐름을 나타내고, 점선 아래쪽은 하드웨어를 나타낸다.
- 자바 프로그램을 실행하고 main 스레드가 work 스레드는 모두 메인 메모리의 runFlag의 값을 읽는다.
- 프로그램의 시작 시점에는 runFlag를 변경하지 않기 때문에 모든 스레드에서 true의 값을 읽는다.
- 참고로 runFlag의 초기값은 true이다.
- work 스레드의 경우 while(runFlag [true]) 가 만족하기 때문에 while 문을 계속 반복해서 수행한다.
0. main 스레드는 runFlag 값을 false로 설정한다.
1. 이때 메인 메모리의 runFlag 값이 false 로 설정된다.
2. work 스레드는 while(runFlag)를 실행할 때 runFlag의 데이터를 메인 메모리에서 확인한다.
3. runFlag 의 값이 false 이므로 while 문을 탈출하고 "task 종료"를 출력한다.
실제 메모리의 접근 방식
CPU는 처리 성능을 개선하기 위해 중간에 캐시 메모리라는 것을 사용한다.
- 메인 메모리는 CPU 입장에서 보면 거리도 멀고, 속도도 상대적으로 느리다. 대신에 상대적으로 가격이 저렴해서 큰 용량을 쉽게 구성할 수 있다.
- CPU 연산이 매우 빠르기 때문에 CPU 연산의 빠른 성능을 따라가려면, CPU 가까이에 매우 빠른 메모리가 필요한데, 이것이 캐시 메모리이다. 캐시 메모리는 CPU와 가까이 붙어있고, 속도도 매우 빠른 메모리이다. 하지만 상대적으로 가격이 비싸기 때문에 큰 용량을 구성하기는 어렵다.
- 현대의 CPU 대부분은 코어단위로 캐시 메모리를 보유하고 있다.
- 참고로 여러 코어가 공유하는 캐시 메모리도 있다.
- 각 스레드가 runFlag의 값을 사용하면 CPU는 이 값을 효율적으로 처리하기 위해 먼저 runFlag를 캐시 메모리에 불러온다.
- 그리고 이후에는 캐시메모리에 있는 runFlag 를 사용하게 된다.
- 빨간 선 위는 스레드의 실행 흐름을 나타내고 , 아래쪽은 하드웨어를 나타낸다.
- 자바 프로그램을 실행하고 main 스레드와 work 스레드 모두 runFlag 값을 읽는다.
- CPU는 이 값을 효율적으로 처리하기 위해 먼저 캐시 메모리를 불러온다.
- 프로그램의 시작 시점에는 runFlag를 변경하지 않기 때문에 모든 스레드에서 true 값을 읽는다.
- 참고로 ` runFlag ` 의 초기값은 ` true이다.
- ` work ` ` 스레드의 경우 while(runFlag [true]) 가 만족하기 때문에 while 문을 계속 반복해서 수행한다.
0. main 스레드는 runFlag 값을 false로 설정한다.
1. 이때 캐시 메모리의 runFlag 값이 false 로 설정된다.
여기서 핵심은 캐시 메모리의 runFlag 값만 변한다는 것이다! 메인 메모리에 이 값이 즉시 반영 않는다.
- main 스레드가 runFlag의 값을 변경해도 CPU 코어 1 이 사용하는 캐시 메모리의 runFlag 값만 false로 변경된다.
- work 스레드가 사용하는 cpu 코어 2의 캐시메모리의 runFlag 값은 여전히 true이다.
- work 스레드의 경우 while(runFlag [true]) 가 만족하기 때문에 while 문을 계속 반복해서 수행한다.
캐시 메모리에 있는 runFlag의 값이 언제 메인 메모리에 반영될까?
이 부분에 대한 정답은 알 수 없다 이다. CPU 설계 방식과 종류의 따라 다르다. 극단적으로 보면 평생 반영되지 않을 수도 있다.
메인 메모리에 반영을 한다고 해도, 문제는 여기서 끝이 아니다.
메인 메모리에 반영된 runFlag 값을 work 스레드가 사용하는 캐시 메모리에 다시 불러와야 한다.
- 메인 메모리에 변경된 runFlga 값이 언제 CPU 코어 2의 캐시 메모리에 반영될까?
- 이 부분에 대한 정답도 "알 수 없다"이다. CPU 설계 방식과 종류의 따라 다르다. 극단적으로 보면 평생 반영되지 않을 수도 있다!
캐시 메모리를 메인 메모리에 반영하거나, 메인 메모리의 변경 내역을 캐시 메모리에 다시 불러오는 것은 언제 발생할 까? 이 부분은 CPU 설계 방식과 실행 환경에 따라 다를 수 있다. 즉시 반영될 수도 있고, 몇 밀리초 후에 될 수도 있고, 몇 초 후에 될 수도 있고, 평생 반영되지 않을 수도 있다.
주로 콘텍스트 스위칭이 될 때, 캐시 메모리도 함께 갱신되는데, 이 부분도 환경에 따라 달라질 수 있다.
예를 들어 ` Thread.sleep() ` 이나 콘솔에 내용을 출력할 때 스레드가 잠시 쉬는데, 이럴 때 컨텍스트 스위칭이 되면서 주로 갱신된다. 하지만 이것이 갱신을 보장하는 것은 아니다.
메모리 가시성(memory visibility)
이처럼 멀티 스레드 환경에서 한 스레드가 변경한 값이 다른 스레드에서 언제 보이는지에 대한 문제를 메모리 가시성(memory visibility)이라 한다. 이름 그대로 메모리에 변경한 값이 보이는가? 보이지 않는가의 문제이다.
그렇다면 한 스레드에서 변경한 값이 다른 스레드에게 즉시 보이게 하려면 어떻게 해야 할까?
volatile, 메모리 가시성 3
캐시 메모리를 사용하면 CPU의 처리 성능을 개선할 수 있다. 하지만 때로는 이런 성능보다는, 여러 스레드에서 같은 시점에 정확히 같은 데이터를 보는 것이 더 중요할 수 있다.
해결방안은 아주 단순하다. 성능을 약간 포기하는 대신에, 값을 읽을 때, 값을 쓸 때 모두 메인 메모리에 직접 접근하면 도니다.
자바에서 volatile이라는 키워드는 이런 기능을 제공한다.
package thread.volatile1;
import static util.MyLogger.log;
import static util.ThreadUtils.sleep;
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 종료");
}
}
}
- 기존 코드에서 boolean runFlag 앞에 volatile 이라는 키워드만 하나 추가해 보자
- volatile boolean runFlag = true;
- 이렇게 하면 runFlag에 대해서는 캐시 메모리를 사용하지 않고, 값을 읽거나 쓸 때 항상 메인 메모리에 직접 접근한다.
실행 결과
21:44:43.420 [ main] runFlag = true
21:44:43.424 [ work] task 시작
21:44:44.432 [ main] runFlag를 false로 변경 시도
21:44:44.432 [ work] task 종료
21:44:44.432 [ main] runFlag = false
21:44:44.433 [ main] main 종료
실행결과를 보면 runFlag 가 false로 변경하자마자 task 종료가 출력되는 것을 확인할 수 있다.
그리고 모든 스레드가 정상 종료되기 때문에 자바 프로그램도 종료된다.
여러 스레드에서 같은 값을 읽고 써야 한다면 volatile 키워드를 사용하면 된다. 단 캐시 메모리를 사용할 때 보다 성능이 느려지는 단점이 있기 때문에 꼭 필요한 곳에만 사용하는 것이 좋다.
'JAVA' 카테고리의 다른 글
고급 동기화 - concurrent.Lock (1) | 2025.01.05 |
---|---|
생산자 소비자 문제1 (1) | 2024.12.31 |
익명 클래스 (0) | 2024.12.24 |
중첩 클래스, 내부 클래스 2 (1) | 2024.12.24 |
중첩 클래스, 내부 클래스 1 (0) | 2024.12.23 |