Java 메모리 모델 — happens-before와 가시성의 진짜 의미
flag = true로 바꿨는데 다른 스레드는 여전히false를 읽고 있다. 이건 버그일까, 아니면 정상 동작일까?
이건 Java 메모리 모델(JMM)이 허용하는 동작 이다. JMM이 왜 존재하는지, happens-before가 무엇을 보장하는지, volatile과 synchronized가 메모리 수준에서 정확히 무엇을 하는지 정리한다.
TIP: 이 글의 코드 예제를 직접 실행해보고 싶다면 Java 기본기 핸드북을 확인해보세요.
1. JMM이 왜 필요한가 — CPU 캐시와 가시성 문제
한 스레드가 변수를 수정했는데 다른 스레드에서 그 변경을 볼 수 없는 상황이 실제로 발생한다. 원인은 현대 CPU 아키텍처에 있다.
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Thread 1 │ │ Thread 2 │ │ Thread 3 │
└────┬─────┘ └────┬─────┘ └────┬─────┘
│ │ │
┌────▼─────┐ ┌────▼─────┐ ┌────▼─────┐
│ CPU 캐시 │ │ CPU 캐시 │ │ CPU 캐시 │
│ (L1/L2) │ │ (L1/L2) │ │ (L1/L2) │
└────┬─────┘ └────┬─────┘ └────┬─────┘
│ │ │
└───────────────┼───────────────┘
│
┌──────▼──────┐
│ 메인 메모리 │
└─────────────┘
각 CPU 코어는 자체 캐시를 가지고 있다. 스레드가 변수를 읽으면 메인 메모리가 아니라 ** 자기 캐시에 있는 복사본 **을 읽는다. Thread 1이 변수를 바꿔도, Thread 2의 캐시에는 이전 값이 남아있을 수 있다.
public class VisibilityProblem {
private boolean running = true; // 일반 변수 — 가시성 보장 없음
public void stop() {
running = false; // Thread 1이 변경
}
public void run() {
while (running) { } // Thread 2 — 영원히 안 끝날 수 있다
System.out.println("종료됨");
}
}
JIT 컴파일러가 running의 값이 루프 안에서 변하지 않는다고 판단하고, 아예 while(true)로 최적화해버릴 수도 있다.
JMM의 역할
Java Memory Model(JMM)은 멀티스레드 환경에서 어떤 쓰기가 어떤 읽기에 보이는지를 정의하는 규칙이다. JMM이 없다면 같은 Java 코드라도 CPU 아키텍처(x86, ARM 등)에 따라 동작이 달라진다.
happens-before 관계가 성립하면 한 스레드의 쓰기가 다른 스레드의 읽기에 ** 반드시** 보인다. 관계가 없으면 보일 수도 있고 안 보일 수도 있다.
2. happens-before 관계 — 6가지 핵심 규칙
happens-before는 ** 시간적 순서가 아니라 메모리 가시성의 보장 **이다. "A happens-before B"는 "A의 결과가 B에게 반드시 보인다"는 뜻이다.
규칙 1: 프로그램 순서 (Program Order)
같은 스레드 안에서 코드 순서대로 happens-before가 성립한다. 당연해 보이지만 ** 같은 스레드 내에서만** 해당된다.
규칙 2: 모니터 락 (Monitor Lock)
같은 모니터에 대해 unlock → 다음 lock은 happens-before 관계다.
synchronized (lock) { x = 10; } // Thread 1 — unlock
synchronized (lock) { print(x); } // Thread 2 — lock → x == 10 보장
규칙 3: volatile 변수
volatile 쓰기 → 같은 변수의 읽기는 happens-before 관계다. 핵심은 volatile 쓰기 이전의 ** 모든 쓰기 **가 volatile 읽기 이후에 함께 보인다는 점이다.
volatile boolean ready = false;
int data = 0;
// Thread 1
data = 42; // 일반 쓰기
ready = true; // volatile 쓰기
// Thread 2
if (ready) { // volatile 읽기
print(data); // 반드시 42 — ready의 happens-before 덕분에 data도 보임
}
규칙 4: Thread.start()
thread.start() 호출 전의 모든 작업은 그 스레드의 run()에 보인다.
규칙 5: Thread.join()
스레드 종료 후 join()이 리턴되면, 그 스레드 안의 모든 작업이 보인다.
규칙 6: 전이성 (Transitivity)
A hb B이고 B hb C이면, A hb C이다.
| 규칙 | happens-before 관계 |
|---|---|
| 프로그램 순서 | 같은 스레드 내 코드 순서 |
| 모니터 락 | unlock → 다음 lock |
| volatile | 쓰기 → 같은 변수 읽기 |
| Thread.start() | start() 호출 → run() 시작 |
| Thread.join() | run() 종료 → join() 리턴 |
| 전이성 | A hb B, B hb C → A hb C |
3. volatile의 메모리 의미론
volatile은 단순히 "캐시 안 쓰게 해주는 키워드"가 아니다. JMM 관점에서 정확히 정리하자.
보장하는 것: 가시성
public class VolatileExample {
private volatile boolean flag = false;
private int data = 0;
public void writer() {
data = 42; // (1) 일반 쓰기
flag = true; // (2) volatile 쓰기 — (1)이 메인 메모리에 반영됨
}
public void reader() {
if (flag) { // (3) volatile 읽기
// data == 42 보장 — (2) happens-before (3)
System.out.println(data);
}
}
}
volatile 쓰기가 일어나면 해당 스레드의 ** 모든 변경사항 **이 메인 메모리에 반영(flush)되고, volatile 읽기가 일어나면 캐시가 무효화되어 메인 메모리에서 다시 읽는다.
보장하지 않는 것: 원자성
private volatile int count = 0;
public void increment() {
count++; // read(0) → modify(1) → write(1) — 세 단계라 원자적이지 않다!
// 두 스레드가 동시에 read(0)을 하면 둘 다 1을 쓴다 → 결과: 1 (기대값 2)
}
언제 쓰고, 언제 안 쓰나
- ** 쓰는 경우 **: 상태 플래그(단일 쓰기, 다수 읽기), happens-before 파이프라인 구성
- ** 안 쓰는 경우 **: 복합 연산(
count++), 여러 변수의 일관성이 필요한 경우, 불변 조건 보호
4. synchronized의 메모리 의미론
synchronized는 상호 배제(mutual exclusion)만 하는 게 아니다. ** 메모리 가시성도 보장한다 **.
synchronized (monitor) {
// ── lock 획득 (acquire barrier) ──
// 메인 메모리에서 최신 값을 읽어온다
sharedData = 42;
// ── unlock (release barrier) ──
// 모든 변경사항을 메인 메모리에 반영한다
}
- acquire barrier: 이후의 읽기/쓰기가 이 배리어 위로 올라갈 수 없다
- release barrier: 이전의 읽기/쓰기가 이 배리어 아래로 내려갈 수 없다
synchronized vs volatile 핵심 차이
// volatile — 개별 변수의 가시성만 보장, 원자성 없음
private volatile int x;
private volatile int y;
x = 1; // 다른 스레드가 여기서 읽으면 x=1, y=0 상태를 볼 수 있다
y = 2;
// synchronized — 블록 안의 모든 변수에 대해 원자성 + 가시성 보장
synchronized (lock) {
x = 1;
y = 2;
// 다른 스레드는 (x=0, y=0) 또는 (x=1, y=2) 중 하나만 본다
}
5. final 필드의 특수한 보장
final 필드는 생성자가 완료된 후에는 ** 별도의 동기화 없이도** 다른 스레드에서 올바른 값이 보인다.
public class ImmutableConfig {
private final int timeout;
private final String host;
public ImmutableConfig(int timeout, String host) {
this.timeout = timeout;
this.host = host;
}
// 생성자 완료 후, 어떤 스레드에서든 timeout과 host 값이 보장된다
}
단, ** 생성자 안에서 this가 외부로 누출되면** 이 보장이 깨진다.
public class Broken {
private final int value;
public Broken() {
SomeRegistry.register(this); // 위험! 생성자 끝나기 전에 this 누출
value = 42;
}
}
다른 스레드가 SomeRegistry를 통해 접근하면 value가 아직 0인 상태를 볼 수 있다.
6. double-checked locking — 왜 volatile이 필요한가
잘못된 버전
public class BrokenSingleton {
private static BrokenSingleton instance; // volatile 아님!
public static BrokenSingleton getInstance() {
if (instance == null) { // (1) 락 밖에서 체크
synchronized (BrokenSingleton.class) {
if (instance == null) { // (2) 락 안에서 체크
instance = new BrokenSingleton(); // (3) 문제 지점
}
}
}
return instance;
}
}
new BrokenSingleton()은 내부적으로 메모리 할당 → 생성자 실행 → 참조 대입 세 단계인데, CPU가 이를 재배치할 수 있다:
1. 메모리 할당
3. instance에 참조 대입 ← 먼저!
2. 생성자 실행 ← 아직 안 끝남
Thread B가 (1)에서 instance != null로 판단하고, ** 초기화 안 된 객체 **를 사용할 수 있다.
올바른 버전
public class Singleton {
private static volatile Singleton instance; // volatile이 재배치 방지
public static Singleton getInstance() {
Singleton localRef = instance; // volatile 읽기 횟수 줄이기
if (localRef == null) {
synchronized (Singleton.class) {
localRef = instance;
if (localRef == null) {
instance = localRef = new Singleton();
}
}
}
return localRef;
}
}
double-checked locking에서 volatile이 필요한 이유: CPU가 객체 초기화를 재배치하면 다른 스레드가 ** 초기화가 덜 된 객체 **를 참조할 수 있다. volatile이 이 재배치를 방지한다.
더 간단한 대안: holder 패턴
public class Singleton {
private Singleton() {}
// 내부 클래스는 처음 참조될 때 로딩 — 클래스 로딩 자체가 스레드 안전
private static class Holder {
private static final Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance() {
return Holder.INSTANCE;
}
}
JVM의 클래스 로딩 메커니즘이 스레드 안전성을 보장하므로, volatile이나 synchronized가 필요 없다.
7. Memory Barrier와 Reordering
컴파일러와 CPU는 성능 최적화를 위해 ** 결과가 같다면** 명령어 순서를 바꿀 수 있다. 단일 스레드에서는 문제가 없지만, 다른 스레드가 중간 상태를 관찰할 수 있으면 문제가 된다.
| 재배치 종류 | 설명 |
|---|---|
| 컴파일러 재배치 | JIT가 코드 순서 변경 (루프 밖으로 불변 코드 이동 등) |
| CPU 재배치 | 명령어 파이프라인 최적화 (store buffer로 쓰기 지연) |
| 메모리 시스템 재배치 | 캐시 계층에서 순서 변경 (write buffer 병합) |
** 메모리 배리어 **는 이 재배치의 경계선 역할을 한다. volatile, synchronized, java.util.concurrent 패키지가 내부적으로 적절한 배리어를 삽입해준다.
쓰기 A, 쓰기 B
─── Store Barrier ─── ← 이 위의 쓰기가 아래로 내려갈 수 없음
volatile 쓰기
volatile 읽기
─── Load Barrier ──── ← 이 아래의 읽기가 위로 올라갈 수 없음
읽기 A, 읽기 B
x86은 비교적 강한 메모리 모델(TSO)을 가져서 재배치가 드물다. 그래서 x86에서 테스트하면 멀쩡한 코드가 ARM 환경에서 터지는 경우가 있다. JMM이 존재하는 이유가 바로 ** 하드웨어에 상관없이 동일한 보장 **을 제공하기 위해서다.
8. volatile vs synchronized vs Atomic — 언제 뭘 쓰나
| 특성 | volatile | synchronized | Atomic* |
|---|---|---|---|
| 가시성 보장 | O | O | O |
| 원자성 보장 | X (단일 읽기/쓰기만) | O (블록 전체) | O (개별 연산) |
| 상호 배제 | X | O | X |
| 성능 | 빠름 | 상대적으로 느림 | 빠름 (CAS 기반) |
| 사용 사례 | 상태 플래그 | 복합 연산, 불변 조건 | 카운터, 누적값 |
선택 가이드
하나의 변수만 관련?
├── YES → 단순 읽기/쓰기? → volatile
│ 복합 연산(read-modify-write)? → AtomicInteger 등
└── NO → 여러 변수의 일관성 필요? → synchronized (또는 Lock)
코드 비교
// 1. volatile — 상태 플래그
private volatile boolean shutdown = false;
public void shutdown() { shutdown = true; }
public void doWork() {
while (!shutdown) { /* 작업 */ }
}
// 2. synchronized — 불변 조건 보호 (체크와 반영이 원자적이어야 함)
private int balance = 0;
public synchronized void transfer(int amount) {
if (balance >= amount) { balance -= amount; }
}
// 3. AtomicInteger — 단순 카운터
private final AtomicInteger count = new AtomicInteger(0);
public void increment() {
count.incrementAndGet(); // CAS 기반 원자적 증가
}
9. 실전 예제 — 잘못된 코드와 수정
예제 1: 가시성 문제
// 잘못된 코드 — 무한 루프에 빠질 수 있다
public class StopThread {
private boolean stopRequested = false; // volatile 아님
public static void main(String[] args) throws InterruptedException {
StopThread st = new StopThread();
Thread worker = new Thread(() -> {
int i = 0;
while (!st.stopRequested) { i++; }
});
worker.start();
Thread.sleep(1000);
st.stopRequested = true; // worker에 안 보일 수 있다
}
}
JIT 컴파일러가 stopRequested를 루프 밖으로 끌어올려(hoisting) while(true)로 최적화할 수 있다. volatile을 붙이면 매번 메인 메모리에서 읽도록 강제하여 이 문제를 해결한다.
private volatile boolean stopRequested = false;
예제 2: 복합 연산
// 잘못된 코드 — volatile은 복합 연산을 보호하지 못한다
private volatile int nextId = 0;
public int generateId() {
return nextId++; // read-modify-write — 원자적이지 않음!
}
// 수정: AtomicInteger 사용
private final AtomicInteger nextId = new AtomicInteger(0);
public int generateId() {
return nextId.getAndIncrement(); // CAS 기반 원자적 연산
}
예제 3: 안전하지 않은 객체 게시
// 잘못된 코드 — 초기화 안 된 객체가 보일 수 있다
private Config config;
public void initialize() {
config = new Config(); // 다른 스레드가 초기화 중인 config를 볼 수 있다
}
// 수정: volatile로 안전한 게시
private volatile Config config;
public void initialize() {
Config c = new Config(); // 완전히 생성한 뒤
config = c; // volatile 쓰기로 안전하게 게시
}
10. 정리 테이블
| 개념 | 핵심 정리 |
|---|---|
| JMM | 멀티스레드에서 메모리 가시성 규칙을 정의하는 명세 |
| happens-before | "A의 결과가 B에 반드시 보인다"는 가시성 보장 관계 |
| 가시성 | 한 스레드의 쓰기가 다른 스레드에 보이는가의 문제 |
| volatile | 가시성 + 재배치 방지, 원자성 미보장 |
| synchronized | 상호 배제 + 가시성 + 원자성 보장 |
| final 필드 | 생성자 완료 후 별도 동기화 없이 가시성 보장 |
| Atomic* | CAS 기반 비블로킹 원자적 연산 |
| Memory Barrier | 재배치의 경계선 (acquire / release) |
| Reordering | 컴파일러/CPU가 성능을 위해 명령어 순서를 바꾸는 것 |
| DCL | volatile 없으면 초기화 안 된 객체 참조 가능 |
주의할 점
x86에서 테스트해서 문제없었는데 ARM에서 터지는 경우
x86은 비교적 강한 메모리 모델(TSO)을 가져서 재배치가 드물다. 동기화 없이도 "우연히" 동작하는 코드가 ARM이나 다른 아키텍처에서 깨질 수 있다. JMM 규칙(happens-before)에 맞게 작성해야 하드웨어에 상관없이 동작한다.
volatile 변수 두 개로 복합 상태를 관리하는 실수
volatile 변수 두 개(volatile int x, volatile int y)를 써도 두 변수의 일관성 은 보장되지 않는다. 다른 스레드가 x=1, y=0 같은 중간 상태를 볼 수 있다. 여러 변수의 일관성이 필요하면 synchronized로 감싸야 한다.
생성자에서 this를 누출하면 final 필드 보장이 깨진다
final 필드는 생성자 완료 후 별도 동기화 없이 가시성이 보장된다. 하지만 생성자 안에서 this를 외부에 노출시키면(이벤트 리스너 등록 등) 다른 스레드가 초기화 중인 객체를 볼 수 있다.