← all posts
DEV 2026.05.02 · 13 min read Intermediate

Java Memory Model은 무엇을 추상화하는가

CPU 캐시 불일치와 명령어 재정렬이 만드는 가시성 문제부터, Happens-Before·volatile·final·Memory Barrier가 이를 해결하는 방식까지, JMM 전체 철학을 추적한다.


다음 코드는 직관적으로 안전해 보인다. Thread 1이 value = 42를 쓴 뒤 ready = true를 세팅하고, Thread 2는 readytrue일 때만 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: synchronized unlock → 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 비용이다. 임계 영역을 최소화하고 락을 분할하면 대부분의 상황에서 충분히 빠르다.

volatilesynchronized의 선택 기준은 단순하다. 단순 플래그나 단일 변수 읽기/쓰기는 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에서 깨진다.