volatile 이해하기 위한 여정 3편

Chu Sang Yoon·2026년 3월 18일

volatile

목록 보기
3/3

volatile 이해하기 위한 여정 3편: JMM, happens-before, 그리고 volatile

1편에서 캐시 일관성, 2편에서 명령어 재정렬을 다뤘다. 이제 Java가 이 문제들을 어떻게 해결하는지 볼 차례다.

2편에서 컴파일러, CPU, 메모리 시스템이 각자의 이유로 명령어 순서를 바꾼다는 걸 봤다. 그러면 자바 개발자 입장에서 "어디까지 믿을 수 있는가"가 명확해야 한다. 그 기준을 정의한 게 JMM(Java Memory Model) 이고, 그 핵심 개념이 happens-before다.


JMM이 필요한 이유

int data = 0;
boolean ready = false;

// Thread 1
data = 42;
ready = true;

// Thread 2
if (ready) {
    System.out.println(data);  // 42? 0? 뭐가 나올까?
}

2편을 읽었다면 이미 답을 알 것이다. ready = truedata = 42보다 먼저 실행될 수 있다. Thread 2가 ready = true를 봤을 때 data가 아직 0일 수 있다.

JMM은 이런 상황에서 개발자가 예측 가능하게 코드를 짤 수 있도록 규칙을 정의한다. 어떤 조건에서 한 스레드의 쓰기가 다른 스레드에 보이는지를 명세한다.


Happens-Before

A happens-before B 라는 관계가 성립하면, A에서의 모든 메모리 변경사항이 B에서 보인다는 것이 보장된다.

한 가지 중요한 점: happens-before는 실행 순서의 보장이 아니라 가시성의 보장이다. A가 B보다 물리적으로 먼저 실행된다는 게 아니라, B가 실행될 때 A의 결과를 반드시 볼 수 있다는 의미다.

JMM은 happens-before 관계가 성립하는 규칙들을 정의한다.


Program Order — 프로그램 순서

같은 스레드 내에서는 코드 순서대로 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) 순서로 실행해도
// 싱글 스레드 관점에선 결과가 같음
// 하지만 다른 스레드가 보기엔 순서가 뒤죽박죽!

Monitor Lock — synchronized

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 Variable

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

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

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

thread.interrupt() 호출은, 인터럽트된 스레드가 인터럽트를 감지하는 시점보다 happens-before 관계가 성립한다.

Thread t = new Thread(() -> {
    while (!Thread.interrupted()) {
        // 작업
    }
    // interrupted() == true를 봤다면
    // interrupt() 호출 전의 모든 쓰기가 보임
});

data = 42;
t.interrupt();  // happens-before interrupted() 리턴

이 규칙이 없으면 인터럽트를 걸었는데 한참 나중에야 반영될 수 있다.


Final 필드

생성자에서의 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)되면 보장이 안 된다.


Transitivity — 전이성

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이 일반 변수의 가시성까지 함께 보장해주는 이유다.


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의 한계 — 원자성은 보장 안 된다

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;

마치며 — 동시성 제어 방법 한눈에 보기

volatilesynchronizedAtomicReentrantLock
가시성 보장
재정렬 방지
원자성 보장✅(단일 변수)
상호 배제
Blocking
데드락 위험
타임아웃
적합한 상황단순 플래그, 참조 교체복합 연산, 상태 전이카운터, 단일 변수 연산복잡한 락 제어

3편에 걸쳐서 결국 volatile 한 줄을 이해하기 위해 CPU 캐시 구조부터 파고 들었다. 정리하면:

  • 1편: 멀티코어에서 캐시 복사본이 여럿 생기고(캐시 일관성 문제), MESI 프로토콜로 이를 해결한다.
  • 2편: 캐시 일관성이 보장돼도 컴파일러/CPU/메모리 시스템이 명령어 순서를 바꾸기 때문에, 메모리 배리어로 재정렬을 막아야 한다.
  • 3편: JMM의 happens-before 규칙이 "어떤 조건에서 한 스레드의 쓰기가 다른 스레드에 보이는가"를 정의하고, volatile은 쓰기/읽기 시 배리어를 삽입해서 가시성과 재정렬 방지를 보장한다. 단, 원자성은 보장하지 않는다.

volatile은 재정렬을 방지하기 위한 메모리 배리어로서, write가 read의 happens-before 관계에 있음을 보장한다. 그리고 happens-before는 실행 순서의 보장이 아닌 가시성의 보장이다.

0개의 댓글