volatile 없이 발생하는 메모리 가시성 문제실험 시나리오: runFlag로 스레드 종료 제어
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 종료");
}
}
}
12:40:50.608 [ main] runFlag = true
12:40:50.612 [ work] task 시작
12:40:51.617 [ main] runFlag를 false로 변경 시도
12:40:51.618 [ main] runFlag = false
12:40:51.618 [ main] main 종료
main 스레드가 runFlag = false로 변경하면, work 스레드는 이를 감지해 while 문을 탈출해야 합니다.기대한 실행 흐름
main: runFlag = false
→ work: runFlag 감지 → "task 종료"
→ 프로그램 정상 종료
실제 결과
main: runFlag = false
→ work: 여전히 true 읽음 → 반복문 무한 실행
→ "task 종료" 출력되지 않음
→ 프로그램 종료되지 않음
원인: 메모리 가시성(Memory Visibility) 문제
runFlag는 처음엔 메인 메모리 → 이후엔 각자의 캐시 메모리에서만 읽습니다.main 스레드가 false로 바꿔도, work 스레드는 자신의 캐시된 값(true)만 참조하기 때문에 변경을 감지하지 못합니다.핵심 그림 요약
| 구성 | 설명 |
|---|---|
| main 스레드 | runFlag = false (캐시 1에 적용) |
| work 스레드 | runFlag = true (캐시 2에서 계속 true 읽음) |
| 메인 메모리 | 변경이 반영되지 않거나, 동기화되지 않음 |
| 결과 | work 스레드는 계속 true로 인식 → 무한 루프 |
정리
boolean runFlag = true;만으로는 스레드 간 변경이 즉시 보장되지 않습니다.volatile 키워드가 필요합니다.volatile 키워드를 사용한 메모리 가시성 보장해결 전략: volatile 키워드 사용
volatile boolean runFlag = true;
volatile을 붙이면, 해당 변수는 항상 메인 메모리로부터 직접 읽고, 직접 씁니다.실행 흐름 비교
| 항목 | volatile 미적용 | volatile 적용 |
|---|---|---|
| main 스레드에서 false로 설정 | 캐시에만 반영 | 메인 메모리에 즉시 반영 |
| work 스레드에서 읽기 | 오래된 값(true) 계속 읽음 | 메인 메모리에서 최신 값(false) 읽음 |
| 결과 | 무한 루프 → 종료되지 않음 | 즉시 while 탈출 → 정상 종료 |
실행 결과 비교
미적용
main: runFlag = false
work: 계속 true 읽음
→ task 종료되지 않음
적용
main: runFlag = false
work: false 인식 → "task 종료"
→ 프로그램 정상 종료
요약
| 키워드 | 역할 |
|---|---|
volatile | 값을 읽고 쓸 때 항상 메인 메모리 사용 |
synchronized | 임계 영역 보호 + 가시성 보장 |
ReentrantLock | 락 기반 동기화 + 가시성 보장 |
volatile은 간단한 플래그나 상태값 동기화에 적합, 하지만 복합 연산 보호에는 부적절합니다.
count & flag)실험 코드 요약 (volatile 미적용)
boolean flag = true;
long count = 0;
while (flag) {
count++;
if (count % 100_000_000 == 0) {
log("flag = " + flag + ", count = " + count);
}
}
main 스레드는 1초 후 flag = false 설정합니다.work 스레드는 flag가 false가 되면 루프를 빠져나가야 합니다.결과 분석 (volatile 미적용)
main은 flag = false를 설정했지만work는 한참 후에서야 flag가 false인 걸 인식count는 약 12억이 시점에 콘솔 출력이 있어서 컨텍스트 스위칭이 발생 → 캐시 갱신 → flag = false 감지
문제 핵심
work 스레드는 자신의 캐시 메모리에 있는 flag 값(true)을 계속 읽습니다.main이 바꾼 flag = false는 캐시에 반영되지 않습니다.해결: volatile 적용
volatile boolean flag = true;
volatile long count = 0;
flag와 count를 모두 메인 메모리에서 읽고 씁니다.main이 설정한 값이 즉시 work에서 보입니다.결과 분석 (volatile 적용)
flag 변경 후 즉시 감지됩니다.count가 약 2.2억에서 종료됨 → 미적용보다 5배 이상 빠른 종료실행 성능 차이 비교
| 항목 | volatile 미적용 | volatile 적용 |
|---|---|---|
| count 종료 시점 | 약 12억 | 약 2.2억 |
| flag 변경 감지 시점 | 느림 (랜덤) | 빠름 (즉시) |
| 종료 반응성 | 매우 낮음 | 높음 |
단점: volatile 사용 시 성능은 약간 저하됩니다. (항상 메인 메모리를 사용해야 하므로)
결론
volatile이 매우 효과적입니다.count++)이 원자적이지 않으므로, 동기화가 필요한 경우엔 synchronized나 AtomicLong을 사용해야 합니다.happens-before 규칙1) 메모리 가시성의 정의
Memory Visibility (메모리 가시성): 하나의 스레드가 변경한 메모리 값이 다른 스레드에게 언제 보이는지를 다루는 문제입니다.
멀티스레드 환경에서는 각 스레드가 자신만의 CPU 캐시를 사용하므로, 값을 변경해도 다른 스레드가 즉시 그 변화를 감지하지 못할 수 있습니다.
2) Java Memory Model (JMM)이란?
3) happens-before란?
“A 작업이 B 작업보다 먼저 발생한 것이 보장되면, A는 B보다 happens-before 관계에 있다.”
happens-before 관계 예시
| 상황 | 설명 |
|---|---|
| 동일 스레드 내 코드 순서 | 순차적으로 실행된 코드들은 앞선 코드가 뒤의 코드보다 happens-before |
volatile 변수 쓰기 → 읽기 | volatile로 선언된 변수에 쓰기 → 다른 스레드에서 읽기: happens-before |
Thread.start() 호출 → 새로운 스레드의 실행 | start() 호출 이전 작업 → 새 스레드의 run() 내용보다 먼저 발생 |
Thread.join() 호출 → join 이후 코드 | 대상 스레드의 모든 작업 → join() 반환 후의 작업보다 먼저 발생 |
synchronized 블록 종료 → 다음 스레드의 진입 | 락을 해제한 후 → 그 락을 획득한 스레드의 작업보다 먼저 발생 |
ReentrantLock.unlock() → lock() 획득 후 작업 | 락 해제 후 락을 획득한 스레드의 작업보다 먼저 발생 |
4) happens-before의 전이 규칙
5) 요약 정리
🔐 volatile, synchronized, ReentrantLock 같은 동기화 도구를 사용하면 happens-before 관계가 성립되고, 메모리 가시성 문제가 발생하지 않습니다.
happens-before 관계 예시 상세 설명동일 스레드 내 코드 순서
상황: 순차적으로 실행된 코드들은 앞선 코드가 뒤의 코드보다 happens-before
int a = 1; // A
int b = a + 1; // B
A는 B보다 먼저 실행됨이 명확히 보장됩니다.결론: 보장됩니다.
volatile 변수 쓰기 → 읽기
상황: 한 스레드에서 volatile 변수에 쓰기 → 다른 스레드에서 읽기: happens-before
volatile boolean flag = false;
// 스레드 A
flag = true; // 쓰기
// 스레드 B
if (flag) { // 읽기
// 여기는 반드시 true가 보장됨
}
volatile은 메모리 가시성을 보장하기 때문에 A 스레드가 true로 바꾸면, B 스레드는 반드시 변경된 값을 볼 수 있습니다.flag = true는 if (flag)보다 happens-before결론: 가시성과 순서 보장됩니다.
Thread.start() 호출 → 새로운 스레드의 실행
상황: start() 호출 이전 작업 → 새 스레드의 run()보다 happens-before
int a = 0;
Thread t = new Thread(() -> {
System.out.println(a); // 이 시점엔 a의 값이 반드시 42
});
a = 42;
t.start(); // t.run()은 이후 실행됨
a = 42는 t.start() 이전 작업입니다.run() 내부에서는 a == 42가 보장됩니다.결론: 스레드 시작 전 작업 → run() 내부 코드보다 먼저 발생합니다.
Thread.join() 호출 → join 이후 코드
상황: 대상 스레드의 모든 작업 → join() 반환 후의 작업보다 happens-before
Thread t = new Thread(() -> {
result = 10; // 스레드 t의 작업
});
t.start();
t.join(); // 대기
System.out.println(result); // result == 10 이 보장됨
t.join()이 반환되면, 스레드 t의 작업은 모두 완료된 상태입니다.result = 10 → System.out.println(result)가 happens-before 관계결론: join 이후의 코드는 안전하게 결과를 읽을 수 있습니다.
synchronized 블록 종료 → 다음 스레드의 진입
상황: 락을 해제한 후 → 그 락을 획득한 스레드의 작업보다 happens-before
synchronized(lock) {
sharedValue = 5;
} // A 스레드 락 해제
// B 스레드
synchronized(lock) {
System.out.println(sharedValue); // 반드시 5 출력
}
결론: 락 해제 → 다음 스레드 진입 시점 사이에 happens-before가 성립됨
ReentrantLock.unlock() → lock() 획득 후 작업
상황: 락 해제 후 락을 획득한 스레드의 작업보다 happens-before |
ReentrantLock lock = new ReentrantLock();
// A 스레드
lock.lock();
sharedValue = 100;
lock.unlock(); // A의 작업 종료
// B 스레드
lock.lock();
System.out.println(sharedValue); // 100 보장
lock.unlock();
ReentrantLock도 락 기반 동기화이므로 synchronized와 동일한 happens-before 관계를 가집니다.결론: lock/unlock을 사용한 명시적 동기화도 순서 보장
정리
| 동기화 방식 | happens-before 보장 여부 |
|---|---|
| 동일 스레드 내 순차 실행 | ✅ 보장됨 |
volatile 읽기/쓰기 | ✅ 보장됨 |
Thread.start(), join() | ✅ 보장됨 |
synchronized, ReentrantLock | ✅ 보장됨 |
핵심 요약: 동기화 도구를 사용하면 happens-before 관계가 생기고, 메모리 가시성 문제가 사라집니다.