Java 동시성의 모든 규칙은 하나의 질문에서 나온다
CPU 캐시 가시성 문제부터 JMM 추상화, happens-before 전이성, volatile 메모리 펜스, 명령어 재정렬, DCL 함정까지 — Java 동시성 설계의 단일 원리를 추적한다.
- 01 Java 스레드는 OS와 어떻게 연결되는가
- 02 Java 동시성의 모든 규칙은 하나의 질문에서 나온다
- 03 Java 락은 어떻게 스스로를 최적화하는가
- 04 Java CAS의 설계 철학 — 원자성, 경쟁, 그리고 트레이드오프
- 05 Java 동시성 자료구조는 어떻게 락을 줄이는가
- 06 Virtual Thread는 왜 OS 스레드의 한계를 넘는가
- 07 Java 동시성 버그는 왜 재현이 어려운가
volatile을 왜 써야 하는가? synchronized가 가시성을 왜 보장하는가? count++에 왜 AtomicInteger가 필요한가? 이 질문들은 표면적으로 달라 보이지만 하나의 뿌리에서 나온다 — “다른 코어에서 쓴 값을, 내 코어는 언제 보는가?” Java 동시성의 모든 규칙은 이 질문에 대한 답이다.
문제의 출발: CPU는 거짓말을 한다
현대 CPU는 단일 스레드 성능을 극대화하기 위해 두 가지 트릭을 쓴다.
첫 번째는 캐시 계층이다. RAM 접근(~100ns)은 L1 캐시 접근(~1ns)보다 100배 느리다. 그래서 CPU는 변수를 L1/L2/L3 캐시에 복사해 두고 그것을 읽는다. 64바이트 단위(캐시 라인)로 움직인다.
두 번째는 **Write Buffer(스토어 버퍼)**다. 쓰기 연산을 즉시 캐시에 반영하지 않고 버퍼에 쌓아뒀다가 나중에 일괄 처리한다. 쓰기 지연을 숨겨 파이프라인 스톨을 막는다.
이 두 트릭이 멀티코어에서 충돌한다. Core 0이 x = 1을 쓰면 Store Buffer에 기록되고, Core 1은 자신의 캐시에서 여전히 x = 0을 읽는다. 같은 변수를 두 코어가 다른 값으로 보는 것이 하드웨어의 정상 동작이다.
MESI 프로토콜은 캐시 일관성을 제공하지만, Invalidate 신호가 전달되는 데 시간이 걸린다. 그 사이 간격이 가시성 문제의 물리적 원인이다.
JMM: 하드웨어 복잡성을 두 계층으로 추상화
Java는 이 복잡한 하드웨어를 개발자에게 직접 노출하지 않는다. 대신 **Java Memory Model(JMM)**이라는 추상화를 제공한다.
JMM은 두 계층만 정의한다. 주 메모리 — 모든 스레드가 공유하는 인스턴스/정적 변수. 작업 메모리 — 각 스레드가 독립적으로 가지는 복사본 (하드웨어의 레지스터+캐시에 대응). 스레드는 작업 메모리를 통해서만 변수를 읽고 쓰며, 두 스레드의 작업 메모리는 서로 직접 접근할 수 없다.
이 추상화의 결론은 단순하다: happens-before 관계가 없으면, 어떤 값이든 나타날 수 있다.
스레드 A의 쓰기 결과가 스레드 B의 읽기에 반드시 보이려면, A의 쓰기 →HB→ B의 읽기 (happens-before) 관계가 성립해야 한다. 관계가 없으면 JMM은 어떤 값도 보장하지 않는다.
JMM은 작업 메모리가 주 메모리와 언제 동기화될지를 명시하지 않는다. 따라서 happens-before 관계 없이는 B가 A의 쓰기 이전 값을 볼 수도, 이후 값을 볼 수도 있으며, 둘 다 스펙 위반이 아니다. happens-before가 성립할 때만 “B는 A의 모든 쓰기를 볼 수 있음”이 JLS에 의해 보장된다.
happens-before의 8가지 규칙과 전이성
happens-before는 실행 순서가 아니라 논리적 인과관계다. JMM은 이를 8가지 규칙으로 정의한다.
실무에서 가장 중요한 4가지:
- 프로그램 순서 규칙: 같은 스레드 내 앞의 작업 →HB→ 뒤의 작업
- volatile 규칙: volatile 쓰기 →HB→ 이후 volatile 읽기
- 모니터 락 규칙:
synchronizedunlock →HB→ 이후 lock - 스레드 종료 규칙: 스레드 모든 작업 →HB→
Thread.join()반환
그리고 전이성: A →HB→ B이고 B →HB→ C이면 A →HB→ C.
전이성이 핵심이다. 이것이 volatile 하나로 일반 변수까지 가시성을 보장하는 원리다:
int data = 0;
volatile boolean flag = false;
// Thread A
data = 42; // (1)
flag = true; // (2) volatile 쓰기
// Thread B
if (flag) { // (3) volatile 읽기
print(data); // (4)
}
체인: (1) →HB→ (2) →HB→ (3) →HB→ (4). 전이성으로 (1) →HB→ (4). Thread B는 data = 42를 반드시 본다.
volatile: 메모리 펜스 4종의 조합
volatile이 실제로 하는 일은 메모리 펜스(barrier) 삽입이다.
volatile 읽기:
[LoadLoad 펜스]
v = volatileVar;
[LoadStore 펜스]
volatile 쓰기:
[StoreStore 펜스]
volatileVar = v;
[StoreLoad 펜스] ← 가장 비싸다
x86에서 volatile 쓰기는 lock addl $0x0, (%rsp)로 구현된다. Store Buffer를 강제로 플러시한다. 비용은 약 100~300사이클. 반면 volatile 읽기는 x86 TSO 모델 덕분에 추가 명령어 없이 거의 무료다.
volatile은 단일 읽기/쓰기의 가시성과 재정렬 제한만 보장한다. count++는 read → increment → write의 3단계이고, 두 스레드가 동시에 count=5를 읽고 각자 6을 쓰면 결과는 6이다(7이어야 함). 복합 연산에는 AtomicInteger 또는 synchronized가 필요하다.
재정렬: 컴파일러·JIT·CPU의 3단계
가시성 문제는 하드웨어만의 문제가 아니다. 명령어 재정렬이 3단계에서 발생한다.
JIT(HotSpot C2): 루프 내 변수를 레지스터에 캐시한다. while (running) {} — running이 레지스터에 올라가면 주 메모리 변경을 읽지 않는다. volatile은 이 레지스터 캐싱을 금지한다.
CPU(Out-of-Order Execution): 의존성 없는 명령어는 순서를 바꿔 실행된다. x86에서는 StoreLoad 재정렬이 발생한다 — 쓰기가 Store Buffer에 있는 동안 이후 읽기가 먼저 완료된다.
False Sharing: 다른 변수가 같은 64바이트 캐시 라인에 있을 때, 한 스레드의 쓰기가 다른 스레드의 무관한 변수 캐시를 Invalidate한다. JDK의 LongAdder가 @Contended를 쓰는 이유다.
DCL: 재정렬이 만드는 함정
Double-Checked Locking은 이 모든 개념이 교차하는 지점이다.
// 잘못된 DCL (volatile 없음)
private static Singleton instance;
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton(); // 재정렬 위험!
}
}
}
return instance; // 부분 초기화된 객체 반환 가능
}
instance = new Singleton()은 세 단계다: ① 힙 메모리 할당, ② 생성자 실행, ③ instance에 참조 저장. JIT/CPU가 ① → ③ → ②로 재정렬하면, Thread B가 instance != null을 봤을 때 생성자가 아직 실행 중이다. 필드는 기본값(0, null)이다.
volatile instance를 추가하면 StoreStore 펜스가 ③ 이전에 삽입되어, ② 생성자 완료 전에 ③이 실행될 수 없다.
더 나은 해결책은 Initialization-on-demand Holder 패턴이다:
class HolderSingleton {
private HolderSingleton() {}
private static class Holder {
static final HolderSingleton INSTANCE = new HolderSingleton();
}
public static HolderSingleton getInstance() {
return Holder.INSTANCE;
}
}
JVM은 클래스를 처음 사용할 때 단 한 번, 스레드 안전하게 초기화한다. 클래스 초기화 락 해제 →HB→ 이후 모든 접근. volatile도 synchronized도 필요 없다.
정리
- 가시성 문제의 뿌리는 하드웨어다 — Store Buffer 지연, 캐시 계층, MESI 전파 지연.
- JMM은 이를 happens-before 관계로 추상화한다. 관계가 없으면 어떤 값도 보장되지 않는다.
volatile은 메모리 펜스(특히 StoreLoad)로 Store Buffer를 플러시하고 재정렬을 제한한다. 가시성은 보장하지만 복합 연산의 원자성은 보장하지 않는다.- JIT와 CPU의 재정렬을 과소평가하지 마라.
volatile없는 루프 플래그는 x86-server모드에서도 무한루프가 된다. - DCL에는 반드시
volatile을 추가하거나, Holder 패턴으로 교체하라.
다음 글에서는 synchronized의 내부 구조 — Object Header, Mark Word, Thin Lock에서 Heavyweight Lock으로의 전환 과정을 추적한다.