1편에서 캐시 일관성, 2편에서 명령어 재정렬을 다뤘다. 이제 Java가 이 문제들을 어떻게 해결하는지 볼 차례다.
2편에서 컴파일러, CPU, 메모리 시스템이 각자의 이유로 명령어 순서를 바꾼다는 걸 봤다. 그러면 자바 개발자 입장에서 "어디까지 믿을 수 있는가"가 명확해야 한다. 그 기준을 정의한 게 JMM(Java Memory Model) 이고, 그 핵심 개념이 happens-before다.
int data = 0;
boolean ready = false;
// Thread 1
data = 42;
ready = true;
// Thread 2
if (ready) {
System.out.println(data); // 42? 0? 뭐가 나올까?
}
2편을 읽었다면 이미 답을 알 것이다. ready = true가 data = 42보다 먼저 실행될 수 있다. Thread 2가 ready = true를 봤을 때 data가 아직 0일 수 있다.
JMM은 이런 상황에서 개발자가 예측 가능하게 코드를 짤 수 있도록 규칙을 정의한다. 어떤 조건에서 한 스레드의 쓰기가 다른 스레드에 보이는지를 명세한다.
A happens-before B 라는 관계가 성립하면, A에서의 모든 메모리 변경사항이 B에서 보인다는 것이 보장된다.
한 가지 중요한 점: happens-before는 실행 순서의 보장이 아니라 가시성의 보장이다. A가 B보다 물리적으로 먼저 실행된다는 게 아니라, B가 실행될 때 A의 결과를 반드시 볼 수 있다는 의미다.
JMM은 happens-before 관계가 성립하는 규칙들을 정의한다.
같은 스레드 내에서는 코드 순서대로 happens-before 관계가 성립한다.
int a = 1; // (1)
int b = a + 1; // (2)
int c = b + 1; // (3)
// (1) happens-before (2) happens-before (3)
단, as-if-serial 원칙이 적용된다. 결과만 같으면 실제 실행 순서는 바뀔 수 있다.
int a = 1; // (1)
int b = 2; // (2) - a와 무관
int c = 3; // (3) - a, b와 무관
// CPU가 (3) → (1) → (2) 순서로 실행해도
// 싱글 스레드 관점에선 결과가 같음
// 하지만 다른 스레드가 보기엔 순서가 뒤죽박죽!
synchronized 블록을 종료(unlock)하는 것은, 같은 락을 획득(lock)하는 모든 스레드보다 happens-before 관계에 있다.
synchronized (lock) { // 락 획득
sharedData = 42;
} // 락 해제 ─────────────────────────┐
│ happens-before
synchronized (lock) { // 락 획득 ◀────┘
int local = sharedData; // 42 보장!
}
Thread 1 Thread 2
│
┌────────────────┐
│ synchronized │
│ data = 42; │
│ } │ ── unlock ─────────────┐
└────────────────┘ │ happens-before
▼
┌────────────────────┐
│ synchronized │
│ use(data); ← 42 │
└────────────────────┘
volatile 변수에 대한 쓰기는, 그 변수를 읽는 모든 스레드보다 happens-before 관계에 있다.
volatile boolean flag = false;
int data = 0;
// Thread 1
data = 42; // (1) 일반 쓰기
flag = true; // (2) volatile 쓰기
// Thread 2
if (flag) { // (3) volatile 읽기
System.out.println(data); // (4) 일반 읽기 → 42 보장!
}
(2) happens-before (3), (1) happens-before (2) (Program Order), (3) happens-before (4) (Program Order). 전이성에 의해 (1) happens-before (4)가 성립한다. data = 42가 println에서 보인다.
thread.start() 호출은, 그 스레드 내의 모든 동작보다 happens-before 관계에 있다.
int data = 0;
data = 42; // (1)
Thread t = new Thread(() -> {
System.out.println(data); // (2) 42 보장!
});
t.start(); // start() happens-before 스레드 내 첫 명령
Main Thread New Thread
│
│ data = 42
│
│ t.start() ──────────────────▶ │
│ happens-before │
│ ▼
│ println(data) ← 42 보임
thread.join() 반환은, join 대상 스레드의 모든 동작보다 happens-before가 아니라, 모든 동작이 join 반환보다 happens-before다.
Thread t = new Thread(() -> {
data = 42; // (1)
});
t.start();
t.join(); // 스레드 종료 대기
System.out.println(data); // (2) 42 보장!
Main Thread Worker Thread
│ │
│ t.start() │
│ ▼
│ data = 42
│ │
│ ▼
│ 스레드 종료 ──────┐
│ │ happens-before
│ ▼
t.join() 리턴 ◀────────────────────────────────┘
│
▼
use(data) ← 42 보임
thread.interrupt() 호출은, 인터럽트된 스레드가 인터럽트를 감지하는 시점보다 happens-before 관계가 성립한다.
Thread t = new Thread(() -> {
while (!Thread.interrupted()) {
// 작업
}
// interrupted() == true를 봤다면
// interrupt() 호출 전의 모든 쓰기가 보임
});
data = 42;
t.interrupt(); // happens-before interrupted() 리턴
이 규칙이 없으면 인터럽트를 걸었는데 한참 나중에야 반영될 수 있다.
생성자에서의 final 필드 초기화 완료는, 그 객체 참조를 통한 final 필드 읽기보다 happens-before 관계에 있다.
class Holder {
final int value;
public Holder(int v) {
this.value = v;
}
}
// Thread 1
holder = new Holder(42);
// Thread 2
if (holder != null) {
System.out.println(holder.value); // 42 보장!
}
단, 객체 참조가 생성자 완료 전에 외부로 노출(escape)되면 보장이 안 된다.
A happens-before B이고, B happens-before C이면, A happens-before C다.
data = 42; // (A)
volatile flag = true; // (B) - Program Order: A hb B
// 다른 스레드
if (flag) { // (C) - volatile: B hb C
use(data); // (D) - Program Order: C hb D
}
// A hb B hb C hb D
// 전이성: A hb D
// data = 42가 use(data)에 보임!
이 전이성이 volatile이 일반 변수의 가시성까지 함께 보장해주는 이유다.
JVM은 volatile 변수 접근 시 메모리 배리어를 삽입한다.
Volatile Write 시:
data = 42; ─┐
metadata = "ready"; │ 일반 쓰기들
count = 10; ─┘
│
▼
┌─────────────────┐
│ StoreStore │ ← 이전 Store들이 이 지점 뒤로 내려가지 못함
│ Barrier │
└─────────────────┘
│
▼
flag = true; ← volatile 쓰기
│
▼
┌─────────────────┐
│ StoreLoad │ ← 이후 Load가 이 Store 이후에 실행됨을 보장
│ Barrier │
└─────────────────┘
Volatile Read 시:
if (flag) { ← volatile 읽기
│
▼
┌─────────────────┐
│ LoadLoad │ ← 이후 Load들이 이 Load 이후에 실행됨을 보장
│ Barrier │
└─────────────────┘
│
▼
┌─────────────────┐
│ LoadStore │ ← 이후 Store들이 이 Load 이후에 실행됨을 보장
│ Barrier │
└─────────────────┘
│
▼
use(data); ─┐
use(metadata); │ 일반 읽기들 (최신 값 보장)
use(count); ─┘
이 배리어들이 2편에서 다룬 세 가지 재정렬(컴파일러, CPU OoO, 메모리 시스템)을 모두 막는다.
volatile int count = 0;
// Thread 1 & 2
count++; // 원자적이지 않음!
// count++의 실제 동작:
// 1. temp = count; (volatile read)
// 2. temp = temp + 1;
// 3. count = temp; (volatile write)
// 문제 시나리오:
// T1: read count (0)
// T2: read count (0)
// T1: write count (1)
// T2: write count (1) ← 2가 아니라 1!
volatile은 가시성과 재정렬 방지를 보장하지만 원자성은 보장하지 않는다. read-modify-write 연산은 여전히 unsafe하다.
volatile이 적합한 경우:
// 1. 상태 플래그 (단순 쓰기/읽기)
private volatile boolean shutdown = false;
// 2. 불변 객체의 참조 교체 (Single Writer)
private volatile Config config;
public void updateConfig(Config newConfig) {
this.config = newConfig; // 원자적 참조 교체
}
// 3. Double-Checked Locking
private static volatile Singleton instance;
| volatile | synchronized | Atomic | ReentrantLock | |
|---|---|---|---|---|
| 가시성 보장 | ✅ | ✅ | ✅ | ✅ |
| 재정렬 방지 | ✅ | ✅ | ✅ | ✅ |
| 원자성 보장 | ❌ | ✅ | ✅(단일 변수) | ✅ |
| 상호 배제 | ❌ | ✅ | ❌ | ✅ |
| Blocking | ❌ | ✅ | ❌ | ✅ |
| 데드락 위험 | ❌ | ✅ | ❌ | ✅ |
| 타임아웃 | ❌ | ❌ | ❌ | ✅ |
| 적합한 상황 | 단순 플래그, 참조 교체 | 복합 연산, 상태 전이 | 카운터, 단일 변수 연산 | 복잡한 락 제어 |
3편에 걸쳐서 결국 volatile 한 줄을 이해하기 위해 CPU 캐시 구조부터 파고 들었다. 정리하면:
volatile은 재정렬을 방지하기 위한 메모리 배리어로서, write가 read의 happens-before 관계에 있음을 보장한다. 그리고 happens-before는 실행 순서의 보장이 아닌 가시성의 보장이다.