Java Memory Model은 무엇을 추상화하는가
CPU 캐시 불일치와 명령어 재정렬이 만드는 가시성 문제부터, Happens-Before·volatile·final·Memory Barrier가 이를 해결하는 방식까지, JMM 전체 철학을 추적한다.
- 01 JVM 클래스로더는 어떻게 JVM을 지탱하는가
- 02 JVM은 메모리를 어떻게 나누는가
- 03 JVM 바이트코드는 어떻게 플랫폼을 초월하는가
- 04 JVM은 바이트코드를 어떻게 실행하는가
- 05 JVM GC는 어떻게 살아있는 객체를 판단하는가
- 06 Java Memory Model은 무엇을 추상화하는가
- 07 JVM 동기화는 어떻게 작동하는가
- 08 JVM 튜닝의 철학 — 덜 건드릴수록 더 잘 돌아간다
- 09 JVM은 객체를 어떻게 들여다보는가
다음 코드는 직관적으로 안전해 보인다. Thread 1이 value = 42를 쓴 뒤 ready = true를 세팅하고, Thread 2는 ready가 true일 때만 value를 읽는다. 그런데 value가 0으로 출력될 수 있고, Thread 2가 영원히 루프를 빠져나오지 못할 수도 있다. Java Memory Model(JMM)은 왜 이런 일을 허용하는가?
하드웨어가 만드는 혼돈
CPU는 느린 RAM 대신 빠른 L1/L2 캐시에서 데이터를 읽고 쓴다. L1은 1ns, RAM은 100ns — 100배 차이다. 멀티코어 환경에서 CPU 0이 x = 1을 쓰면, 그 값은 즉시 CPU 0의 L1 캐시에 들어간다. CPU 1의 캐시에는 아직 x = 0이 남아 있다. 이것이 Visibility Problem의 물리적 기원이다.
MESI 프로토콜은 이를 해결하려 한다. CPU 0이 캐시 라인을 수정(Modified)하면, CPU 1의 해당 라인을 무효화(Invalid)하는 메시지를 보낸다. 그러나 이 메시지는 수십 나노초의 지연이 있고, Invalidation Queue에 쌓여 즉시 처리되지 않을 수 있다. 여기에 컴파일러와 CPU의 명령어 재정렬까지 더해진다. 파이프라인 최적화를 위해 CPU는 의존성 없는 명령어를 재배치한다 — 단일 스레드에서는 관찰 불가능하지만, 멀티스레드에서는 다른 스레드가 “중간 상태”를 포착할 수 있다.
JMM은 이 하드웨어 복잡성 전체를 개발자에게 감춘다. 대신 Happens-Before라는 단일 추상화를 제공한다.
Happens-Before — 가시성의 규칙
“A happens-before B”는 A의 모든 변경을 B가 반드시 본다는 의미다. 실행 순서가 아니라 가시성 보장이다. 재정렬은 여전히 일어날 수 있지만, 관찰 시점에 A의 결과가 B에게 보인다.
JMM이 정의하는 규칙 8가지 중 실무에서 가장 중요한 네 가지는 다음과 같다.
- Volatile Variable Rule:
volatile쓰기 →volatile읽기. 쓰기 전 모든 변경이 읽기 후에 보인다. - Monitor Lock Rule:
synchronizedunlock → lock. 이전 블록의 모든 변경이 다음 블록에 보인다. - Thread Start/Join Rule:
start()전 모든 변경이 새 스레드에 보인다. 스레드 종료 후join()이 반환되면 그 스레드의 모든 변경이 보인다. - Transitivity: A→B, B→C이면 A→C.
전이성이 핵심이다. x = 42; ready = true;(Thread 1)에서 Program Order Rule로 x = 42 happens-before ready = true이고, Volatile Rule로 ready = true happens-before if (ready)(Thread 2)이다. Transitivity에 의해 Thread 2는 x = 42도 반드시 본다. volatile 하나가 연쇄 보장을 만든다.
volatile과 final — 두 가지 경량 해법
volatile은 두 가지를 보장한다: 가시성과 재정렬 금지. 내부적으로 Memory Barrier를 삽입한다. 쓰기 전 StoreStore Barrier로 이전 쓰기가 먼저 완료되게 하고, 쓰기 후 StoreLoad Barrier로 그 값이 모든 코어에 전파된 뒤 다음 읽기가 실행되게 한다. x86에서 이 StoreLoad Barrier의 비용은 약 20ns(mfence 명령)이고, 일반 변수 대비 5~10배 느리다.
volatile이 보장하지 않는 것이 있다: 원자성. counter++는 읽기→증가→쓰기의 3단계 복합 연산이다. volatile int counter여도 두 스레드가 동시에 실행하면 증가분이 사라진다. 복합 연산에는 AtomicInteger 또는 synchronized가 필요하다.
final은 다른 방식으로 안전성을 제공한다. 생성자가 완료되는 시점에 JVM은 StoreStore Barrier를 삽입해 모든 final 필드 값을 캐시에서 플러시한다 — Freeze. 이후 어떤 스레드도 final 필드의 기본값(0, null)을 관찰할 수 없다. volatile 없이도 불변 객체를 안전하게 공개할 수 있는 이유다.
// volatile 없이도 안전
class ImmutablePoint {
private final int x;
private final int y;
ImmutablePoint(int x, int y) {
this.x = x;
this.y = y;
} // ← StoreStore Barrier (Freeze)
}
// Thread 1
point = new ImmutablePoint(1, 2);
// Thread 2
if (point != null) {
System.out.println(point.x); // 1 보장
}
final의 함정은 생성자 탈출이다. 생성자 안에서 this를 외부로 넘기면(리스너 등록, 내부 클래스 생성 등), Freeze 전에 다른 스레드가 미완성 객체에 접근할 수 있다. 생성자는 초기화만 하고 this 노출을 금지해야 한다. Spring에서 @PostConstruct가 존재하는 이유가 바로 이것이다.
synchronized — 완전한 동기화의 비용
synchronized는 원자성과 가시성을 동시에 제공한다. 진입 시 LoadLoad + LoadStore Barrier, 종료 시 StoreStore + StoreLoad Barrier를 삽입해 Critical Section 안팎의 모든 재정렬을 차단한다.
synchronized의 성능은 경쟁 여부에 크게 달라진다. JVM은 단일 스레드 독점 시 Biased Lock(~10ns), 경쟁 없을 때 Lightweight Lock(~50ns), 경쟁 심할 때 Heavyweight Lock(~1μs)으로 전환한다. “synchronized는 항상 느리다”는 오해다. 문제는 경쟁 시의 OS Mutex 비용이다. 임계 영역을 최소화하고 락을 분할하면 대부분의 상황에서 충분히 빠르다.
volatile과 synchronized의 선택 기준은 단순하다. 단순 플래그나 단일 변수 읽기/쓰기는 volatile로 충분하다. 복합 연산(i++), 다중 변수 원자성, Check-Then-Act 패턴은 synchronized 또는 Atomic 계열이 필요하다.
Memory Barrier — 하드웨어 추상화의 밑바닥
JMM의 모든 보장은 결국 CPU 명령어 수준의 Memory Barrier로 구현된다. x86(TSO 모델)에서는 Load-Load, Store-Store, Load-Store 재정렬이 하드웨어에서 이미 금지되어 있어 대부분의 Barrier가 무비용이다. 유일하게 Store-Load 재정렬이 허용되므로, volatile 쓰기 후에만 mfence(~20ns)가 삽입된다. ARM은 Weak Ordering 모델이라 모든 종류의 재정렬이 가능하고, dmb 명령이 매번 필요해 x86 대비 4배 이상 비용이 든다.
이 차이가 실무에서 의미하는 바는 한 가지다: JVM 위에서 올바르게 작성된 코드는 하드웨어 플랫폼이 바뀌어도 동일하게 동작해야 한다. x86에서 우연히 맞게 작동하는 코드가 ARM에서 깨지는 사례가 그래서 생긴다. JMM의 규칙을 따르지 않은 코드는 “x86에서만 우연히 동작하는 코드”일 수 있다.
정리
- JMM은 CPU 캐시, 재정렬, Store Buffer라는 하드웨어 복잡성을 Happens-Before 하나로 추상화한다.
volatile은 가시성과 재정렬 금지를 보장하지만 원자성은 아니다. 복합 연산에는synchronized또는Atomic이 필요하다.final은 생성자 완료 시 Freeze를 통해 동기화 없이 불변 객체를 안전하게 공개한다. 단, 생성자 탈출은 이를 무효화한다.synchronized는 완전한 동기화를 제공하되, 경쟁이 심할 때만 실질적인 성능 비용이 발생한다.- JMM을 따르지 않은 코드는 x86에서 우연히 동작하고 ARM에서 깨진다.