왜 어려운가 JMM · happens-before volatile synchronized CAS · Atomic 동시성 위험 📝 문제풀이
◀ 심화 CH 01 📋 목차 심화 CH 03 ▶
🧵 심화 CHAPTER 02 · 5년차 트랙

스레드는 왜 서로의 쓰기를 못 볼까? — 메모리 모델의 규칙

멀티스레드 버그는 대부분 재현이 안 돼서 무섭습니다. 원인은 코드가 아니라 메모리가 언제·어떻게 보이는가에 있어요. 이번 장은 JMM(자바 메모리 모델)happens-before라는 규칙, 그리고 volatile · synchronized · CAS가 정확히 무엇을 보장하고 무엇을 보장하지 않는지를 파고듭니다.

🎯 이 장을 끝내면
🌀
왜 어려운가
동시성 버그의 세 얼굴
원자성 · 가시성 · 순서(재배치)

"공유 상태를 여러 스레드가 건드린다"는 한 문장 안에 세 가지 독립된 함정이 숨어 있어요. 이 셋을 분리해서 보는 순간, 대부분의 동시성 논의가 명료해집니다.

⚛️
원자성(Atomicity)
연산이 쪼개지지 않고 통째로
·
👁️
가시성(Visibility)
내 쓰기가 남에게 보이는가
·
🔀
순서(Ordering)
컴파일러·CPU의 재배치
🔎 세 축을 한 줄씩 ① 원자성count++는 사실 읽기 → +1 → 쓰기 세 단계예요. 중간에 다른 스레드가 끼어들면 갱신이 유실됩니다(lost update).
② 가시성 — 한 스레드가 쓴 값이 CPU 캐시·레지스터에 머물러 다른 스레드에게 영원히 안 보일 수 있어요. "왜 플래그를 바꿨는데 루프가 안 멈추지?"의 원인.
③ 순서 — 컴파일러와 CPU는 성능을 위해 명령을 재배치(reordering)해요. 단일 스레드 결과는 보존하지만, 다른 스레드가 보는 순서는 뒤집힐 수 있습니다. 세 축은 서로 독립적입니다. 그래서 volatile은 ②③은 다루지만 ①은 못 다루고, synchronized는 셋 다 다뤄요 — 이 차이가 이 장의 핵심입니다.
🖼️ 그림으로 보기 — count++ 의 원자성 문제 (갱신 유실)
count++ 는 read → modify → write 3단계 (원자적이지 않음) 스레드 A ① read → 0 ② +1 → 1 ③ write → 1 스레드 B ① read → 0 ② +1 → 1 ③ write → 1 count = 1 기대 2 · 갱신 유실 ❌ A와 B가 같은 값 0을 읽어, 증가가 한 번만 반영됨 (lost update)
⚠️
"내 PC에선 잘 되던데"의 함정. 가시성·재배치 문제는 x86처럼 강한 메모리 모델을 가진 CPU에서는 잘 안 터지고, ARM처럼 약한 모델이나 부하·JIT 최적화가 겹칠 때 터져요. 그래서 로컬에서 멀쩡하던 코드가 운영 서버(다른 아키텍처·높은 동시성)에서 간헐적으로 깨집니다. 테스트로 없음을 증명할 수 없는 부류의 버그예요.
📜
JMM · happens-before
"보인다"를 규정하는 규칙
자바 메모리 모델(JMM)과 happens-before 관계

JMM(Java Memory Model)은 "어떤 스레드의 쓰기가 어떤 스레드의 읽기에 반드시 보여야 하는가"를 규정한 명세예요(JSR-133, Java 5부터 정립). 핵심 도구가 happens-before 관계입니다.

🔗 happens-before란 두 연산 A, B에 대해 A happens-before B가 성립하면, A의 결과(메모리 쓰기)가 B에게 반드시 보이며 A가 B보다 먼저 일어난 것으로 취급돼요. 이 관계가 없으면 JVM은 재배치·캐싱을 마음대로 할 수 있어 보인다는 보장이 사라집니다. 중요한 오해 하나: happens-before는 "시간상 먼저 실행"이 아니라 "메모리 가시성·순서 보장"이에요. 실제 실행 순서와 다를 수도 있고, 없어도 우연히 보일 수도 있지만 보장은 안 됩니다.
규칙내용
프로그램 순서 규칙한 스레드 안에서는 코드에 쓴 순서대로 happens-before가 성립
모니터 락 규칙한 락의 unlock이후 같은 락의 lock보다 happens-before
volatile 규칙volatile 필드의 write이후 그 필드의 read보다 happens-before
스레드 시작 규칙Thread.start() 호출 전의 모든 작업은 시작된 스레드에게 보임
스레드 종료 규칙스레드의 모든 작업은 다른 스레드의 join() 성공 반환 후에 보임
전이성(transitivity)A hb B, B hb C 이면 A hb C
final 필드 안전 발행생성자에서 올바르게 초기화된 final 필드는 별도 동기화 없이도 안전하게 보임
💡
전이성이 실전의 핵심입니다. 흔한 패턴: 스레드 A가 일반 필드 data를 쓰고 volatile ready=true를 씀 → 스레드 B가 ready==true를 읽으면, volatile 규칙 + 프로그램 순서 + 전이성에 의해 data까지 안전하게 보여요. volatile 하나가 그 앞의 일반 쓰기까지 "발행(publish)"해주는 거죠 — 이게 안전 발행(safe publication)의 원리입니다.
🖼️ 그림으로 보기 — happens-before 안전 발행
volatile 하나가 앞선 일반 쓰기까지 보이게 한다 스레드 A data = 42 (일반 쓰기) ready = true (volatile write) 스레드 B ready == true? (volatile read) data 읽음 → 42 보장 ✅ happens-before volatile 규칙 + 프로그램 순서 + 전이성 → data까지 안전하게 발행됨
👁️
volatile
가시성은 준다, 원자성은 안 준다
가장 많이 오해받는 키워드

volatile은 두 가지를 보장해요. ① 가시성 — 쓰기는 즉시 메인 메모리에 반영되고 읽기는 항상 최신값을 가져와요. ② 재배치 방지 — volatile write/read 주변으로 메모리 연산이 넘나드는 재배치를 막아요(메모리 배리어).

🖼️ 그림으로 보기 — 가시성: 로컬 캐시 vs 메인 메모리
스레드 A · 로컬 캐시 running = false 로 씀 쓴 값이 캐시에 머무를 수 있음 스레드 B · 로컬 캐시 running 을 계속 true 로 읽음 while 루프가 안 멈춤 😵 메인 메모리 running 쓰기 반영 지연 ❌ 옛 값을 읽음 volatile이면 쓰기가 즉시 메인 메모리에 반영되고 읽기는 항상 최신값 → 문제 해결
⚠️
volatile은 원자성을 보장하지 않아요. volatile int count; count++;는 여전히 읽기-수정-쓰기 세 단계라 두 스레드가 동시에 증가시키면 갱신이 유실됩니다. volatile은 "각 읽기/쓰기 하나"의 가시성만 보장할 뿐, 복합 연산(read-modify-write)의 원자성은 못 줘요. 카운터엔 AtomicIntegersynchronized를 쓰세요.
// ✅ 적합: 단순 플래그 (한 스레드는 쓰기만, 다른 스레드는 읽기만)
volatile boolean running = true;
public void stop() { running = false; }
public void run() { while (running) { /* ... */ } }  // 즉시 중단됨

// ❌ 부적합: 복합 연산엔 원자성이 없다
volatile int count = 0;
count++;   // read → +1 → write 3단계, 경합 시 갱신 유실
🎯 volatile이 딱 맞는 곳 ① 상태 플래그 — 한쪽은 쓰기, 다른 쪽은 읽기만 하는 boolean 신호.
② 안전 발행 — 앞서 만든 객체·데이터를 volatile 참조에 담아 다른 스레드에 공개.
③ DCL의 인스턴스 필드 — double-checked locking에서 반드시 volatile이어야 함(뒤에서). "쓰는 값이 이전 값에 의존하지 않을 때(단순 대입)"가 volatile의 영역이에요. 이전 값에 의존하는 순간(증가·누적) 원자성이 필요해집니다.
🔒
synchronized
상호배제 + 가시성을 한 번에
모니터 락 · 재진입 · 락 범위 최소화

synchronized는 객체마다 딸린 모니터 락을 사용해요. 같은 락을 잡아야만 임계 구역에 들어갈 수 있어 상호배제(mutual exclusion)가 되고, 동시에 진입/해제 시점에 happens-before가 성립해 가시성까지 보장됩니다. 즉 원자성·가시성·순서를 한 번에 다뤄요.

성질synchronized가 주는 것
상호배제같은 락에 대해 한 번에 한 스레드만 임계 구역 진입 → 복합 연산도 원자적
가시성해제(unlock)가 이후 획득(lock)에 happens-before → 이전 쓰기가 보임
재진입(reentrant)이미 락을 가진 스레드는 같은 락을 다시 획득 가능(카운트 증가) → 자기 자신과 데드락 안 남
🧭
재진입이 없다면? synchronized 메서드가 같은 객체의 다른 synchronized 메서드를 호출하는 순간 자기 자신을 기다리다 영영 멈추게 돼요. 자바 모니터 락과 ReentrantLock재진입 가능하도록 설계돼 이 문제를 피합니다(같은 스레드는 홀드 카운트만 올림).
class Counter {
    private int count = 0;
    // 메서드 전체 락 — this 모니터를 잡음
    public synchronized void inc() { count++; }   // 원자적 + 가시성 보장

    // 더 좁은 락: 전용 락 객체로 범위 최소화
    private final Object lock = new Object();
    public void incNarrow() {
        // ... 락 없이 할 수 있는 일 ...
        synchronized (lock) { count++; }   // 임계 구역만 감싼다
    }
}
💡
락 범위는 좁게, 락 객체는 전용으로. 임계 구역이 넓으면 병렬성이 죽고, 넓은 범위에서 I/O·외부 호출을 하면 락을 든 채 오래 멈춰 경합이 폭발해요. 또 synchronized(this)나 문자열 상수 락은 외부에서 같은 모니터를 잡을 위험이 있어, private final 락 객체를 두는 편이 안전합니다.
⚙️
CAS · Atomic
락 없이 원자성 만들기
compare-and-swap · AtomicInteger · ABA 문제

CAS(Compare-And-Swap)는 CPU가 제공하는 원자적 명령이에요. "메모리 위치의 값이 기대값과 같으면 새 값으로 바꾸고, 다르면 실패"를 한 번에 수행합니다. 실패하면 최신값을 다시 읽어 재시도(스핀)해요. 락을 걸지 않으므로 논블로킹(non-blocking)입니다.

AtomicInteger n = new AtomicInteger(0);
int r = n.incrementAndGet();   // 내부적으로 CAS 재시도 루프, 원자적 +1 후 값 반환 → 1
n.compareAndSet(1, 100);       // 현재 값이 1이면 100으로 교체(성공 true)
// count++ 와 달리 경합에서도 갱신 유실이 없다
🆚 락 vs CAS 락(synchronized) — 경합 시 스레드를 대기(블로킹)시킴. 임계 구역이 길거나 경합이 심할 때 유리.
CAS(Atomic) — 실패하면 재시도(스핀). 경합이 낮고 연산이 짧을 때 매우 빠름. 단, 경합이 극심하면 재시도가 폭증해 오히려 느려질 수 있어요. "카운터·시퀀스·플래그"처럼 짧은 단일 변수 갱신은 Atomic*이 정석이에요. 여러 변수의 불변식을 함께 지켜야 하면 락이 낫습니다.
⚠️
ABA 문제. CAS는 "값이 같은가"만 봐요. 다른 스레드가 값을 A→B→A로 되돌려 놓으면, CAS는 "안 변했다"고 착각하고 성공합니다. 값은 같아도 그 사이 일어난 변화를 놓치는 거죠(특히 참조·스택/큐 노드 재활용에서 치명적). 해법은 값에 버전(스탬프)을 붙이는 AtomicStampedReference(또는 마킹용 AtomicMarkableReference)예요.
AtomicInteger AtomicLong AtomicReference AtomicStampedReference LongAdder compareAndSet
💡
극심한 경합엔 LongAdder. 한 개의 AtomicLong에 수많은 스레드가 몰리면 CAS 실패·재시도가 폭증해요. LongAdder는 내부적으로 여러 셀(cell)에 분산해 더하고 읽을 때 합산해 경합을 흩뜨립니다. 통계 카운터처럼 쓰기 많고 읽기 드문 경우에 특히 유리해요.
☠️
동시성 위험
현장에서 실제로 터지는 것들
데드락 · 라이브락 · 기아 · false sharing · DCL
🔐 데드락의 4가지 필요조건 (Coffman) ① 상호배제 — 자원을 한 번에 하나만 점유.
② 점유와 대기(hold & wait) — 자원을 쥔 채 다른 자원을 기다림.
③ 비선점(no preemption) — 남의 자원을 강제로 뺏을 수 없음.
④ 순환 대기(circular wait) — 대기 관계가 원을 이룸. 네 조건이 동시에 성립해야 데드락이에요. 그래서 하나만 깨면 됩니다 — 실무 처방은 대개 ④ 순환 대기 제거: 모든 스레드가 락을 같은 전역 순서로 획득하게 하는 것.
현상정의대표 처방
데드락서로 상대의 락을 기다리며 영영 멈춤락 순서 통일, tryLock(타임아웃)
라이브락멈추진 않는데 서로 양보만 반복해 진전이 없음재시도에 랜덤 백오프
기아(starvation)특정 스레드가 자원을 계속 못 얻음공정성(fair) 락, 우선순위 조정
false sharing서로 다른 변수가 같은 캐시라인에 있어 무효화가 전파돼 성능 저하패딩, @Contended
🧭
false sharing은 "정확성"이 아니라 "성능" 문제예요. CPU 캐시는 보통 64바이트 캐시라인 단위로 움직여요. 서로 다른 스레드가 자주 쓰는 두 변수가 우연히 같은 라인에 놓이면, 한쪽이 쓸 때마다 다른 쪽 캐시가 무효화돼 성능이 급락합니다. JDK 8+의 @jdk.internal.vm.annotation.Contended(또는 수동 패딩)로 변수를 다른 라인으로 분리해요.
⚠️
Double-Checked Locking(DCL)엔 volatile이 필수입니다. 지연 초기화 패턴에서 인스턴스 필드가 volatile이 아니면, new①메모리 할당 ②생성자 실행 ③참조 대입재배치되어, 다른 스레드가 생성자가 끝나기도 전의 "반쯤 만들어진" 객체를 볼 수 있어요. volatile이 이 재배치를 막아줍니다.
class Holder {
    // 반드시 volatile! 없으면 부분 초기화된 객체 노출 위험
    private volatile Singleton instance;
    Singleton get() {
        if (instance == null) {              // 1차 검사(락 없이)
            synchronized (this) {
                if (instance == null) {      // 2차 검사(락 안에서)
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}
💡
사실 싱글턴엔 DCL보다 더 쉬운 길이 있어요. 초기화-온-디맨드 홀더(static 중첩 클래스) 관용구나 enum 싱글턴은 JVM의 클래스 초기화 스레드 안전성에 얹혀 가므로 volatile·락 코드가 아예 필요 없어요. DCL은 "이 관용구를 왜 쓰는지" 이해하는 용도로 알아두되, 실전에선 더 단순한 대안을 먼저 고려하세요.
🧠 이 장 핵심 요약
📝
문제풀이 · 점검
시니어 관점으로 점검
면접·코드리뷰에서 실제로 갈리는 결의 문제예요. 틀려도 바로 해설이 나옵니다.
🧪
"안다"와 "정확히 설명한다"는 달라요. 네 유형(객관식 · O/X · 빈칸 · 코드결과)으로 점검해보세요.
심화 CH 03 🧰 동시성 도구와 가상 스레드 — Executor, CompletableFuture, java.util.concurrent, Virtual Thread (제작 예정)