← all posts
DEV 2026.05.02 · 14 min read Advanced

Java 동시성의 모든 규칙은 하나의 질문에서 나온다

CPU 캐시 가시성 문제부터 JMM 추상화, happens-before 전이성, volatile 메모리 펜스, 명령어 재정렬, DCL 함정까지 — 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 관계가 없으면, 어떤 값이든 나타날 수 있다.

명제 1 · JMM 가시성 보장 조건

스레드 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 읽기
  • 모니터 락 규칙: synchronized unlock →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이 보장하지 않는 것

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→ 이후 모든 접근. volatilesynchronized도 필요 없다.

정리

  • 가시성 문제의 뿌리는 하드웨어다 — 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으로의 전환 과정을 추적한다.