Java Memory Model — JLS Chapter 17.4가 정의하는 happens-before
volatile한 글자가 정확히 어떤 보장을 주는지,synchronized블록이 락 외에 무엇을 더 책임지는지,final필드가 왜 생성자 밖에서 다른 모습으로 보일 수 있는지를 같은 그림 위에서 풀어냅니다. Java Language Specification(JLS) Chapter 17.4와 JSR-133을 기준으로, 멀티코어 자바 코드의 정확성을 떠받치는 메모리 모델의 추상 기계를 정리합니다.
자바 동시성 입문서를 펴면 거의 첫 장에 volatile, synchronized, final이라는 세 키워드가 등장합니다. 입문 단계에서는 보통 "락을 거는 것", "변수 값을 캐시에 넣지 않는 것" 정도로 설명되지만, 멀티코어 하드웨어와 공격적인 JIT 컴파일러 위에서 이 키워드들이 제공하는 보장은 그보다 훨씬 정교합니다. 이 보장의 출처가 바로 Java Memory Model(JMM)이고, 그 명세가 JLS Chapter 17.4에 들어 있습니다.
이 글에서는 JMM이 풀려는 문제(컴파일러·CPU·캐시 계층의 reordering)에서 시작해, JSR-133이 도입한 happens-before 관계가 어떻게 멀티스레드 자바 프로그램의 정확성을 추상 기계 수준에서 정의하는지, 그리고 그 위에서 volatile·synchronized·final이 각각 어떤 동기화 액션을 만들어 내는지를 정리합니다. 마지막으로 JSR-133 Cookbook의 메모리 배리어 4종, Java 9에서 들어온 VarHandle의 약한 메모리 순서 모드, JEP 188의 모델 갱신 계획까지 같은 흐름 위에 얹어 마무리합니다.
메모리 모델이 풀려는 문제
다음 코드를 봅시다. 스레드 A는 data를 채운 뒤 ready 플래그를 켭니다. 스레드 B는 플래그가 켜지면 data를 읽습니다.
class Naive {
int data = 0;
boolean ready = false;
void writer() { // thread A
data = 42;
ready = true;
}
int reader() { // thread B
if (ready) {
return data; // 42를 받을 것 같지만?
}
return -1;
}
}
순차적 사고로는 B가 ready == true를 본 이상 data == 42 도 함께 봐야 합니다. 그러나 어떤 동기화도 없는 위 코드에서는 그 보장이 성립하지 않습니다. 이유는 세 가지 다른 계층에서 동시에 발생합니다.
첫째, 컴파일러 reordering입니다. javac와 JIT(C1, C2, Graal)은 데이터 종속성이 없는 한 두 store의 순서를 자유롭게 바꿀 수 있습니다. 단일 스레드의 결과(as-if-serial)만 보존되면 됩니다. 위 코드에서 data = 42와 ready = true는 단일 스레드 관점에서는 순서가 무관하므로, 컴파일러가 둘을 뒤집어도 됩니다.
둘째, CPU reordering입니다. 현대 OOO(out-of-order) 프로세서는 store buffer, write combining, speculative execution을 통해 인스트럭션을 명령어 스트림 순서와 다르게 실행합니다. x86은 TSO(Total Store Order)로 비교적 강한 모델이지만 StoreLoad는 허용하고, ARM·POWER·RISC-V는 훨씬 더 자유롭게 재배열합니다.
셋째, 캐시 가시성입니다. 코어별 L1 캐시에 있는 값이 언제 다른 코어에 보일지는 캐시 일관성 프로토콜(MESI/MOESI)과 store buffer 비우기 시점에 달려 있습니다. 한 스레드의 store가 메모리에 도달했더라도 다른 스레드의 read가 캐시된 옛 값을 볼 수 있습니다.
flowchart LR
src["Java source<br/>(program order)"] --> jit["JIT compiler<br/>(reorder, dead code, hoist)"]
jit --> cpu["CPU<br/>(OOO, store buffer)"]
cpu --> cache["Cache hierarchy<br/>(L1/L2/L3, coherency)"]
cache --> mem["Main memory"]
classDef stage fill:#fff,stroke:#444,color:#000
class src,jit,cpu,cache,mem stage
각 계층은 단일 스레드 의미만 지키면 됩니다. 멀티스레드에서 두 스레드가 같은 변수를 통해 통신하려면, 어딘가에서 명시적으로 순서를 묶어 줘야 합니다. 그 "묶음"의 정의가 메모리 모델입니다.
메모리 모델은 두 가지를 동시에 만족해야 합니다. 첫째, 프로그래머가 멀티스레드 코드의 결과를 합리적으로 예측할 수 있을 만큼 강해야 합니다. 둘째, 컴파일러와 하드웨어가 성능 최적화를 수행할 여지를 충분히 남길 만큼 약해야 합니다. JMM은 이 둘의 균형점을 happens-before라는 부분 순서로 정의합니다.
옛 JMM에서 JSR-133로
Java 1.4 이전의 메모리 모델은 결함이 있었습니다. volatile 쓰기가 비-volatile 읽기/쓰기와 reorder 될 수 있었고, 그 결과 직관과 충돌하는 동작이 허용됐습니다. 더 큰 문제는 final 필드 보장이 모호해서 생성자가 끝난 후에도 다른 스레드가 기본값(0/null)을 볼 수 있는 합법적인 실행이 존재했다는 점입니다. immutable 객체의 안전 발행이 명세상 보장되지 않았던 셈입니다.
JSR-133은 Java 5(2004)에 도입된 새 명세입니다. Bill Pugh와 Doug Lea가 주도했고, 다음 세 가지를 핵심적으로 강화했습니다.
첫째, volatile의 의미를 부분 순서 모델로 다시 정의했습니다. volatile write는 그 시점까지 작성 스레드가 만든 모든 변경을 "함께" 발행하고, 이후 volatile read를 하는 스레드는 그 모든 변경을 봅니다. volatile은 단일 변수의 가시성만이 아니라, 그 시점의 전체 메모리 스냅샷에 대한 동기화 포인트가 됐습니다.
둘째, final 필드의 안전한 발행을 명세에 박았습니다. 생성자가 완료된 시점에 this 참조가 escape 하지 않았다면, 이후 그 객체를 발행받은 모든 스레드는 final 필드의 정확한 값을 동기화 없이 봅니다.
셋째, out-of-thin-air 값을 금지했습니다. 데이터 경쟁이 있는 프로그램이라도 "어디서도 쓰지 않은 값"이 읽기에서 튀어나오는 실행은 허용되지 않습니다.
JEP 188(Doug Lea, 2014~)은 JSR-133을 더 형식화하고 다른 언어(특히 C11/C++11) 메모리 모델과 호환되도록 손보는 후속 작업입니다. 본 글의 기준은 여전히 JSR-133이 정착시킨 JLS 17.4입니다(JLS SE 21까지 동일).
17.4.2 — Actions와 Synchronization Actions
JMM은 프로그램 실행을 추상 액션의 집합으로 본 뒤, 그 액션들 사이의 부분 순서로 가시성과 원자성을 정의합니다. JLS 17.4.2는 다음 5종을 synchronization actions(동기화 액션)로 명명합니다.
- volatile read, volatile write
- monitor lock(
synchronized진입), monitor unlock(synchronized종료) - 스레드의 첫 액션, 마지막 액션
- 다른 스레드를 시작시키는 액션(
Thread.start)과 다른 스레드의 종료를 감지하는 액션(Thread.join이 반환되거나isAlive()가 false 반환) - external 액션(예:
System.out.println처럼 JVM 외부와 상호작용하는 액션) 및 이로 인한 스레드 분기·종료
비-volatile 일반 필드의 read/write는 plain action이고 synchronization action이 아닙니다. plain action은 같은 스레드 내 program order만 따르며, 다른 스레드와의 가시성 관계는 synchronization action을 매개로만 형성됩니다.
이 분리가 JMM의 핵심입니다. plain action은 컴파일러·CPU가 자유롭게 재배열할 수 있지만, synchronization action들 사이에는 전체 실행을 가로지르는 synchronization order가 존재하고, 이 순서가 happens-before의 뼈대가 됩니다.
17.4.4 — Synchronizes-with
Synchronization action 사이에는 특정 쌍을 묶어 주는 synchronizes-with 관계가 있습니다. JLS 17.4.4가 정의하는 쌍은 다음과 같습니다.
- monitor m에 대한 unlock action은 m에 대한 모든 subsequent(synchronization order에서 뒤에 오는) lock action과 synchronizes-with 관계를 맺습니다.
- volatile 변수 v에 대한 write action은 v에 대한 모든 subsequent read action(다른 스레드에서의 읽기 포함)과 synchronizes-with 관계를 맺습니다.
- 스레드 시작 액션은 시작된 스레드의 첫 액션과 synchronizes-with 관계를 맺습니다(start → first action).
- 스레드의 마지막 액션은 다른 스레드가 그 종료를 감지하는 액션과 synchronizes-with 관계를 맺습니다(last → join).
- 기본 초기화 write(객체 할당 시 모든 필드를 0/null로 채우는 가상 write)는 해당 객체에 접근하는 모든 액션과 synchronizes-with 관계를 맺습니다.
flowchart LR
subgraph TA[Thread A]
a1["data = 42<br/>(plain write)"] --> a2["volatile ready = true"]
end
subgraph TB[Thread B]
b1["read volatile ready<br/>(sees true)"] --> b2["read data<br/>(sees 42)"]
end
a2 -.synchronizes-with.-> b1
classDef plain fill:#fff,stroke:#666,color:#000
classDef sync fill:#fff,stroke:#000,stroke-width:2px,color:#000
class a1,b2 plain
class a2,b1 sync
이 그림이 첫 절의 Naive 예제를 고치는 표준 방법입니다. ready를 volatile로 선언하면 A의 volatile write가 B의 volatile read와 synchronizes-with 되고, 그 결과 A의 data = 42(plain write)가 B의 return data(plain read)보다 happens-before에서 앞에 옵니다. B는 42를 본다는 사실이 명세로 보장됩니다.
17.4.5 — Happens-before
Happens-before는 program order와 synchronizes-with를 이행 폐쇄(transitive closure)한 부분 순서입니다. JLS 17.4.5가 열거하는 핵심 규칙은 다음과 같습니다.
- Program order: 같은 스레드의 액션 x가 program order에서 액션 y 앞에 있으면, x happens-before y.
- Monitor: monitor m의 unlock은 m의 모든 후속 lock보다 happens-before.
- Volatile: 변수 v에 대한 write는 v에 대한 모든 후속 read보다 happens-before.
- Thread start:
t.start()호출은 t의 모든 액션보다 happens-before. - Thread join: 스레드 t의 모든 액션은 t.join()이 성공적으로 반환된 후의 액션보다 happens-before.
- Thread interruption:
Thread.interrupt()호출은 인터럽트된 스레드가 그 인터럽트를 감지하는 액션보다 happens-before. - Default values: 객체 할당 시의 기본값 write는 그 객체의 모든 다른 액션보다 happens-before.
- Transitivity: x happens-before y이고 y happens-before z이면, x happens-before z.
핵심 약속은 두 가지로 요약됩니다. 액션 A가 액션 B보다 happens-before이면, (1) A의 결과가 B에 가시화되고, (2) A와 B의 순서가 명세 차원에서 고정됩니다. 컴파일러도 하드웨어도 이 부분 순서를 위반하는 reordering은 금지됩니다.
flowchart TB
po["program order<br/>(within a thread)"] --> hb["happens-before<br/>(partial order)"]
sw["synchronizes-with<br/>(cross-thread sync)"] --> hb
hb --> vis["visibility:<br/>x's writes seen by y"]
hb --> ord["ordering:<br/>x ordered before y"]
classDef k fill:#fff,stroke:#000,stroke-width:2px,color:#000
classDef r fill:#fff,stroke:#666,color:#000
class hb k
class po,sw,vis,ord r
부분 순서라는 점이 중요합니다. happens-before로 묶이지 않은 두 액션은 순서가 정의되지 않으며, 이런 액션 쌍이 같은 변수를 건드린다면 data race가 발생합니다. JMM은 data race가 있는 프로그램에 대해서는 매우 약한 보장만 합니다(out-of-thin-air 금지와 word tearing 금지 정도).
17.4.9 — Happens-before Consistency와 Causality
JMM은 happens-before만으로 끝나지 않습니다. happens-before consistent execution이라도 직관과 어긋나는 "값이 갑자기 생겨나는" 결과를 허용할 수 있기 때문입니다. JLS 17.4.8과 17.4.9는 causality requirements라는 추가 조건을 부과해 그런 실행을 금지합니다.
핵심은 모든 read가 자신을 happens-before 하는 어떤 write의 값을 보든지, 또는 자신과 race 관계에 있는 write의 값을 봐야 한다는 것입니다. "어디서도 쓰지 않은 값"이 읽혀서는 안 된다는 직관이 이 절에서 형식화됩니다.
// out-of-thin-air 시나리오 (JMM이 금지)
int x = 0, y = 0;
// Thread 1
r1 = x; y = r1;
// Thread 2
r2 = y; x = r2;
// r1 == r2 == 42 가 나올 수 있을까? -> 금지
위 시나리오에서 42는 프로그램 어디에서도 쓰지 않은 값입니다. 두 스레드가 서로의 read 결과를 self-justifying 하면서 42를 만들어 내는 실행은 happens-before consistency만으로는 막을 수 없지만, causality 조건이 막아 줍니다.
17.5 — final 필드의 특별한 의미
final 필드는 happens-before와 별개의 추가 보장을 가집니다. JLS 17.5의 약속은 이렇습니다.
- 객체 X의 생성자가 final 필드 f를 초기화하고 정상 종료했고,
- 생성자 종료 전에
this가 escape 하지 않았다면, - 이후 X에 대한 참조를 얻은 모든 스레드는 동기화 없이 f의 정확한 값을 봅니다.
이 보장은 컴파일러가 생성자 종료 직전에 freeze action을 삽입한다고 모델합니다. freeze 이후 발행된 참조를 받은 모든 스레드는 final 필드에 대해 freeze 이전의 store 결과를 봅니다.
class Holder {
private final int[] data; // final
private int meta; // non-final
Holder() {
data = new int[]{1, 2, 3};
meta = 42;
// here: implicit "freeze" of final fields
}
}
// thread A
Holder h = new Holder();
sharedRef = h; // unsafe publication
// thread B
Holder h2 = sharedRef;
// h2.data 의 길이/원소는 보장됨 (final freeze)
// h2.meta 는 0을 볼 수도 있음 (non-final, no sync)
여기서 핵심은 escape 조건입니다. 생성자 안에서 this를 어딘가에 등록(예: 리스너 등록, static 필드에 대입, 스레드 시작 등)하면 final 필드 보장이 깨집니다. 그래서 생성자 안에서는 this를 외부로 흘리지 않는 것이 중요합니다.
JSR-133이 final 보장을 강화한 가장 큰 수혜자는 String입니다. String 객체는 hash 캐시를 제외하면 사실상 final 필드(char[] value, int hash)로만 구성되며, JSR-133 덕분에 immutable 객체로서의 안전 발행이 명세상 보장됩니다.
17.6, 17.7 — Word tearing과 long/double의 원자성
JLS 17.6은 word tearing 금지를 명세합니다. 한 스레드가 byte[]의 i번째 요소를 쓰면 그 결과는 i번째 바이트에만 반영되고, i+1번째 요소를 다른 스레드가 동시에 쓰더라도 두 write가 서로의 결과를 부분적으로 덮지 않습니다. 8비트씩 갱신할 수 있는 명령어가 없는 일부 RISC 아키텍처에서는 컴파일러가 read-modify-write 시퀀스를 끼워 넣어 이 보장을 유지해야 합니다.
JLS 17.7은 더 유명한 함정을 다룹니다. non-volatile long과 double의 read/write는 두 개의 32비트 액션으로 쪼개질 수 있습니다. 즉, 한 스레드가 0xFFFF_FFFF_FFFF_FFFF를 쓰는 도중에 다른 스레드가 읽으면, 상위 32비트는 새 값이고 하위 32비트는 옛 값(또는 그 반대)인 합성된 가짜 값을 볼 수 있습니다. 이 가짜 값은 Long.toString(value)이 출력하는 값이 될 수도 있습니다.
이 함정은 volatile long/volatile double로 선언하면 사라집니다. 명세는 volatile 64비트 값의 read/write 원자성을 보장합니다. 64비트 OS에서도 마찬가지로, plain long의 원자성은 JVM 구현이 선택할 수 있는 사항일 뿐 명세상 보장이 아닙니다. 멀티스레드에서 long을 공유한다면 volatile 또는 AtomicLong이 정답입니다.
참조 타입(object reference)은 길이와 무관하게 항상 원자적으로 읽고 씌어집니다. 32비트 JVM에서도 압축 oop가 32비트이고, 64비트 JVM에서는 압축 oop가 활성화되면 32비트, 아니면 64비트이며, 어느 경우든 한 개 단위로 다뤄집니다.
동기화 키워드의 매핑
이제 happens-before와 synchronizes-with의 어휘로 자바의 세 동기화 키워드를 다시 정의할 수 있습니다.
flowchart TB
subgraph V[volatile]
v1["write v"] --> v2["happens-before<br/>across threads"]
v2 --> v3["subsequent read v"]
end
subgraph S[synchronized]
s1["unlock m"] --> s2["happens-before<br/>across threads"]
s2 --> s3["subsequent lock m"]
end
subgraph F[final]
f1["constructor end<br/>(no this escape)"] --> f2["freeze of final fields"]
f2 --> f3["any later read of final<br/>via publication"]
end
classDef sec fill:#fff,stroke:#000,color:#000
class V,S,F sec
세 키워드 모두 단일 변수의 가시성만 책임지지 않습니다. volatile write 또는 monitor unlock은 그 시점까지 작성 스레드가 만든 모든 plain write를 함께 발행하는 동기화 포인트입니다. JMM 어휘로 말하면, 그 동기화 액션이 다른 스레드의 대응 동기화 액션과 synchronizes-with 되는 순간, program order로 그 앞에 있던 모든 plain write가 happens-before 관계로 끌려갑니다.
이 "전체 스냅샷" 의미가 입문서의 "캐시를 비운다"라는 비유의 근거입니다. JMM은 캐시를 직접 언급하지 않습니다. 하드웨어 차원에서는 store buffer 비우기와 캐시 invalidation으로 구현되지만, 명세 차원에서는 부분 순서의 확장으로만 정의됩니다.
JSR-133 Cookbook과 메모리 배리어
JMM 명세는 추상 기계 위에서 정의되지만, JIT 컴파일러는 이를 구체적인 CPU 명령어로 번역해야 합니다. Doug Lea의 JSR-133 Cookbook은 이 번역의 표준 레시피로 자리잡았습니다.
Cookbook은 reordering을 4종의 양방향 배리어로 추상화합니다.
- LoadLoad: 앞의 load가 끝나기 전에 뒤의 load가 시작되지 않음.
- LoadStore: 앞의 load가 끝나기 전에 뒤의 store가 발행되지 않음. 실제 프로세서에서 필요한 경우는 거의 없습니다.
- StoreStore: 앞의 store가 메모리에 도달하기 전에 뒤의 store가 발행되지 않음.
- StoreLoad: 앞의 store가 메모리에 도달하기 전에 뒤의 load가 수행되지 않음. 가장 비싸고 가장 중요한 배리어입니다.
flowchart TB
vw["volatile write v"]
preStore["all prior stores"]
postLoad["any later load"]
preStore --"StoreStore"--> vw
vw --"StoreLoad"--> postLoad
vr["volatile read v"]
postLoadOp["all later loads"]
postStoreOp["all later stores"]
vr --"LoadLoad"--> postLoadOp
vr --"LoadStore"--> postStoreOp
classDef op fill:#fff,stroke:#000,color:#000
class vw,vr op
x86 TSO에서는 store-store, load-load, load-store가 자연스럽게 보존되므로 LoadLoad/StoreStore/LoadStore는 nop이 되고, StoreLoad만 mfence(또는 락 prefix가 붙은 명령)로 명시해야 합니다. 그래서 volatile write가 x86에서 가장 비싼 작업이고, volatile read는 거의 무료에 가깝습니다.
ARM과 POWER, RISC-V는 더 약한 메모리 모델이라 더 많은 배리어가 필요합니다. ARMv8은 acquire/release 시맨틱을 가진 load/store 명령(ldar, stlr)으로 LoadLoad+LoadStore와 StoreStore+LoadStore를 한 번에 처리합니다. POWER는 lwsync와 sync로 더 거친 단위의 배리어를 씁니다.
synchronized 블록의 경계에서도 같은 배리어가 끼어듭니다. unlock은 release-store 시맨틱으로 모델되고 lock은 acquire-load 시맨틱으로 모델됩니다. 결과적으로 monitor 진입/탈출은 volatile write/read와 같은 종류의 배리어 효과를 가지면서, 추가로 mutual exclusion을 보장합니다.
실전 함정 — 깨진 double-checked locking
JSR-133 이전의 가장 유명한 함정은 double-checked locking입니다.
class Lazy {
private Holder instance; // 잘못된 버전: volatile 없음
Holder get() {
if (instance == null) {
synchronized (this) {
if (instance == null) {
instance = new Holder(); // (*)
}
}
}
return instance;
}
}
직관적으로는 락 안에서 한 번 검사하니 안전해 보이지만, JMM은 이 코드를 안전하다고 보장하지 않습니다. 문제는 (*)의 new Holder()가 추상적으로 세 단계(할당, 생성자 실행, 참조 대입)로 풀리고, JMM 입장에서 생성자 실행 단계의 plain write가 참조 대입의 store와 reorder 될 수 있다는 점입니다. 다른 스레드가 락 밖의 if (instance == null)에서 비-null 참조를 본 직후 그 객체의 plain 필드를 읽으면, 생성자 안의 write가 아직 발행되지 않은 상태에서 부분 초기화된 객체를 보게 됩니다.
해결책은 instance 필드를 volatile로 선언하는 것입니다. volatile write는 그 시점까지의 모든 plain write를 함께 발행하므로, volatile read로 비-null을 본 스레드는 생성자 안의 모든 write를 본 것이 보장됩니다.
class Lazy {
private volatile Holder instance; // 올바른 버전
Holder get() {
Holder local = instance; // 첫 번째 read
if (local == null) {
synchronized (this) {
local = instance;
if (local == null) {
local = new Holder();
instance = local; // volatile write
}
}
}
return local;
}
}
지역 변수 local로 캐싱하는 패턴은 volatile read 횟수를 줄여 성능을 끌어올리는 표준 트릭입니다(volatile read는 LoadLoad+LoadStore 배리어와 함께 동반되므로 한 번의 read가 두 번보다 명백히 쌉니다).
Java 5 이후로 enum singleton 또는 holder idiom(static inner class)이 double-checked locking보다 명확하고 더 적은 함정을 가진 대안으로 권장됩니다.
VarHandle — 더 약한 메모리 순서 모드
Java 9는 JEP 193으로 java.lang.invoke.VarHandle을 도입했습니다. VarHandle은 필드/배열 원소/off-heap 메모리에 대한 접근을 추상화하면서, 네 단계의 메모리 순서 모드를 제공합니다. 약한 순서에서 강한 순서로 가면 다음과 같습니다.
- Plain: 일반 필드 read/write와 동일. 가시성·순서 보장이 같은 스레드 program order로만 한정됨.
- Opaque: 같은 변수에 대한 액세스 사이의 비트 단위 원자성과 coherent ordering. 다른 변수와의 ordering은 보장하지 않음.
- Acquire/Release: release write는 그 시점까지의 모든 write와 함께 발행, 대응하는 acquire read는 그 모두를 봄. happens-before 관계를 형성하지만 monitor/volatile보다 더 좁은 fence.
- Volatile: JMM의 volatile과 동일. 전체 순서(synchronization order)에 참여.
JDK 9 이후의 java.util.concurrent.atomic 클래스들은 이 모드를 노출합니다. AtomicLong.setPlain, getAcquire, setRelease, compareAndExchangeAcquire 등이 그 예입니다.
class LongAdderLite {
private long sum;
private static final VarHandle SUM;
static {
try {
SUM = MethodHandles.lookup()
.findVarHandle(LongAdderLite.class, "sum", long.class);
} catch (Exception e) {
throw new Error(e);
}
}
void add(long delta) {
long current;
do {
current = (long) SUM.getAcquire(this);
} while (!SUM.compareAndSet(this, current, current + delta));
}
long get() {
return (long) SUM.getOpaque(this);
}
}
Acquire/Release는 C11 memory_order_acquire/memory_order_release와 직접 대응합니다. JEP 188이 C11/C++11 호환을 명시적 목표로 둔 이유 중 하나가 이 어휘의 통일입니다.
VarHandle의 약한 모드는 monitor·volatile의 StoreLoad를 피할 수 있어 lock-free 자료구조의 성능을 더 끌어올리는 도구입니다. 그러나 acquire/release pairing을 놓치면 정확성을 잃기 쉬워 일반 애플리케이션 코드에서는 권장되지 않고, j.u.c 라이브러리 같은 인프라 계층에서 주로 쓰입니다.
JEP 188 — 모델 갱신 작업
JSR-133이 도입된 지 20년이 지나면서 다음 영역에서 갱신이 필요해졌습니다.
- 일부 표현이 형식적으로 모호하거나, 기계 검증이 어려움.
- JVM 위에 올라간 Kotlin/Scala/Clojure 같은 다른 언어가 자바의 동시성 어휘를 빌려 쓰면서, 명세가 자바 언어 수준에 묶여 있는 문제가 드러남.
- C/C++11이 자체 메모리 모델을 갖춰 JNI 호출의 의미를 양쪽에서 같은 어휘로 정의할 필요가 생김.
JEP 188(Doug Lea 외, OpenJDK JMM Project)은 이를 다음 네 방향으로 갱신합니다.
- 형식화 강화: causality requirements를 더 기계 검증 가능한 형태로 재정의.
- JVM 수준 커버리지: 언어가 아니라 바이트코드와 JVM의 액션에 명세를 직접 거는 방향.
- C11/C++11 호환: acquire/release/seq_cst 어휘를 자바의 plain/opaque/acquire-release/volatile과 정확히 대응시킴.
- 구현 가이드·테스트: TCK와 stress test로 명세 위반을 잡아내는 도구 제공.
작업은 진행 중이고 공식 통합 시점은 아직 확정되지 않았습니다. 본 글 작성 시점(2026-05) 기준으로 자바 표준은 여전히 JSR-133 위의 JLS 17.4입니다.
정리
JMM은 멀티코어 자바 프로그램의 정확성을 떠받치는 추상 기계의 명세입니다. 핵심 어휘는 셋입니다.
- Action: 일반 read/write(plain action)와 동기화 액션(synchronization action).
- Synchronizes-with: 동기화 액션 쌍을 교차 스레드로 묶는 관계.
- Happens-before: program order와 synchronizes-with의 이행 폐쇄. 두 액션 사이에 이 관계가 있으면 가시성과 순서가 동시에 고정됩니다.
volatile, synchronized, final은 모두 이 어휘 위에서 정의됩니다. volatile write/read는 synchronizes-with 쌍을 만들고, monitor unlock/lock도 같은 종류의 쌍을 만들며, final 필드는 freeze action으로 생성자 종료 후의 안전한 발행을 별도 약속으로 보장합니다.
실무에서 기억할 점은 단순합니다. 두 스레드가 어떤 데이터로 통신한다면 그 사이에 동기화 액션이 있어야 하고, 그 동기화 액션이 happens-before 관계를 만들어 줍니다. 그 관계 없이 같은 변수를 건드리는 두 액션은 data race이고, JMM은 data race에 대해서는 매우 약한 보장만 합니다. AQS, ConcurrentHashMap, BlockingQueue 같은 j.u.c의 모든 도구는 이 부분 순서 위에 자기 자신의 happens-before 약속(예: "BlockingQueue에 넣기 전 모든 액션은 꺼낸 후의 모든 액션보다 happens-before")을 명시하는 형태로 설계되어 있습니다.
참고자료
- JLS Chapter 17 — Threads and Locks (Java SE 21)
- JLS Chapter 17 — Threads and Locks (Java SE 8, 한글 번역 다수 인용 출처)
- JSR-133: Java Memory Model and Thread Specification (2004)
- JSR-133 (Java Memory Model) FAQ — Jeremy Manson, Brian Goetz, William Pugh
- The JSR-133 Cookbook for Compiler Writers — Doug Lea
- JEP 188: Java Memory Model Update
- OpenJDK Memory Model Update Project
- Using JDK 9 Memory Order Modes — Doug Lea
- VarHandle (Java SE 21 API)
- JEP 193: Variable Handles
- Java Memory Model Pragmatics — Aleksey Shipilev
- Fixing the Java Memory Model, Part 2 — Brian Goetz (IBM developerWorks 아카이브)
- Synchronization and the Java Memory Model — Doug Lea

