동시성 버그의 뿌리는 두 가지다 — 가시성과 원자성.
가시성 — 왜 안 보이나
class Worker {
private boolean running = true;
void stop() { running = false; }
void run() { while (running) { /* 일한다 */ } } // 멈추지 않을 수 있다
}
각 CPU 코어에는 자기 캐시가 있다
→ 스레드 A 가 running=false 로 바꿔도 A 의 캐시에만 반영될 수 있다
→ 스레드 B 는 자기 캐시의 옛 값(true)을 계속 읽는다
→ JIT 이 while(running) 을 while(true) 로 최적화해 버리기도 한다
volatile이 하는 일
private volatile boolean running = true; // 이제 멈춘다
-
① 가시성 — 쓰면 즉시 메인 메모리로, 읽으면 항상 메인 메모리에서
-
② 순서 — 앞뒤 명령의 재정렬을 막는다 (메모리 배리어)
-
③ 원자성 — 보장하지 않는다 — ← 여기가 핵심
volatile로 안 되는 것
private volatile int count = 0;
void inc() { count++; } // 여전히 깨진다
count++ 는 세 단계다
- ① 읽는다 ② 1 더한다 ③ 쓴다
두 스레드가 ①을 동시에 하면 둘 다 같은 값을 읽고 같은 값을 쓴다
- 한 번의 증가가 사라진다 volatile 은 각 단계를 '보이게' 할 뿐 '나눌 수 없게' 만들지 않는다
private final AtomicInteger count = new AtomicInteger();
count.incrementAndGet(); // CAS 로 원자성까지 보장
happens-before — JMM의 규율
A happens-before B = A 의 결과가 B 에게 반드시 보인다
주요 규칙
- 같은 스레드 안의 순서
- unlock → 이후의 lock
- volatile 쓰기 → 이후의 volatile 읽기
- Thread.start() → 그 스레드의 첫 동작
- 스레드의 마지막 동작 → 다른 스레드의 join() 반환
이 관계가 성립하지 않으면 무엇이 보일지 정의되지 않는다.
언제 쓰나
✅ 상태 플래그 (한 스레드가 쓰고 여럿이 읽는다) ✅ 이중 검사 잠금의 인스턴스 필드 ✅ 불변 객체 참조를 안전하게 발행할 때
❌ 증감 · 누적 (원자성 필요 → Atomic 계열) ❌ 여러 필드의 일관성 (락 필요)
면접 함정
- ❌ "volatile이 동기화를 대체한다" → 원자성은 없다. 복합 연산에는 부족하다.
- ❌ "멀티코어라서 생기는 문제" → 컴파일러·JIT의 재정렬로 단일 코어에서도 논리적으로 발생할 수 있다.
재현해 보기
public class VisibilityDemo {
static boolean running = true; // volatile 을 빼 본다
public static void main(String[] a) throws Exception {
new Thread(() -> { while (running) { } System.out.println("멈춤"); }).start();
Thread.sleep(1000);
running = false;
System.out.println("플래그를 내렸다");
}
}
// -server 모드에서 "멈춤" 이 영원히 안 찍히는 것을 볼 수 있다
JIT이 while(running)을 while(true)로 끌어올리는(hoisting) 것이 직접적 원인이다. 캐시 문제이기 이전에 컴파일러 최적화 문제다.
volatile이 배리어를 어떻게 거나
volatile 쓰기 앞 → StoreStore 배리어 (앞의 쓰기가 먼저 보이도록)
volatile 쓰기 뒤 → StoreLoad 배리어 (가장 비싼 배리어)
volatile 읽기 뒤 → LoadLoad · LoadStore 배리어
x86 은 원래 강한 메모리 모델이라 대부분 공짜이고 StoreLoad(lock 접두어 명령) 만 실제 비용이 있다 ARM 은 약한 모델이라 배리어 비용이 더 크다
안전한 발행이라는 개념
// final 필드는 생성자가 끝나는 시점에 보이는 것이 보장된다 (final field semantics)
class Config { final int timeout; Config(int t) { timeout = t; } }
// → 불변 객체는 volatile 없이도 안전하게 공유할 수 있는 이유