멀티스레드 버그는 대부분 재현이 안 돼서 무섭습니다. 원인은 코드가 아니라 메모리가 언제·어떻게 보이는가에 있어요. 이번 장은 JMM(자바 메모리 모델)과 happens-before라는 규칙, 그리고 volatile · synchronized · CAS가 정확히 무엇을 보장하고 무엇을 보장하지 않는지를 파고듭니다.
"공유 상태를 여러 스레드가 건드린다"는 한 문장 안에 세 가지 독립된 함정이 숨어 있어요. 이 셋을 분리해서 보는 순간, 대부분의 동시성 논의가 명료해집니다.
count++는 사실 읽기 → +1 → 쓰기 세 단계예요. 중간에 다른 스레드가 끼어들면 갱신이 유실됩니다(lost update).JMM(Java Memory Model)은 "어떤 스레드의 쓰기가 어떤 스레드의 읽기에 반드시 보여야 하는가"를 규정한 명세예요(JSR-133, Java 5부터 정립). 핵심 도구가 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 필드는 별도 동기화 없이도 안전하게 보임 |
data를 쓰고 volatile ready=true를 씀 → 스레드 B가 ready==true를 읽으면, volatile 규칙 + 프로그램 순서 + 전이성에 의해 data까지 안전하게 보여요. volatile 하나가 그 앞의 일반 쓰기까지 "발행(publish)"해주는 거죠 — 이게 안전 발행(safe publication)의 원리입니다.volatile은 두 가지를 보장해요. ① 가시성 — 쓰기는 즉시 메인 메모리에 반영되고 읽기는 항상 최신값을 가져와요. ② 재배치 방지 — volatile write/read 주변으로 메모리 연산이 넘나드는 재배치를 막아요(메모리 배리어).
volatile int count; count++;는 여전히 읽기-수정-쓰기 세 단계라 두 스레드가 동시에 증가시키면 갱신이 유실됩니다. volatile은 "각 읽기/쓰기 하나"의 가시성만 보장할 뿐, 복합 연산(read-modify-write)의 원자성은 못 줘요. 카운터엔 AtomicInteger나 synchronized를 쓰세요.// ✅ 적합: 단순 플래그 (한 스레드는 쓰기만, 다른 스레드는 읽기만)
volatile boolean running = true;
public void stop() { running = false; }
public void run() { while (running) { /* ... */ } } // 즉시 중단됨
// ❌ 부적합: 복합 연산엔 원자성이 없다
volatile int count = 0;
count++; // read → +1 → write 3단계, 경합 시 갱신 유실
boolean 신호.synchronized는 객체마다 딸린 모니터 락을 사용해요. 같은 락을 잡아야만 임계 구역에 들어갈 수 있어 상호배제(mutual exclusion)가 되고, 동시에 진입/해제 시점에 happens-before가 성립해 가시성까지 보장됩니다. 즉 원자성·가시성·순서를 한 번에 다뤄요.
| 성질 | synchronized가 주는 것 |
|---|---|
| 상호배제 | 같은 락에 대해 한 번에 한 스레드만 임계 구역 진입 → 복합 연산도 원자적 |
| 가시성 | 락 해제(unlock)가 이후 획득(lock)에 happens-before → 이전 쓰기가 보임 |
| 재진입(reentrant) | 이미 락을 가진 스레드는 같은 락을 다시 획득 가능(카운트 증가) → 자기 자신과 데드락 안 남 |
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++; } // 임계 구역만 감싼다
}
}
synchronized(this)나 문자열 상수 락은 외부에서 같은 모니터를 잡을 위험이 있어, private final 락 객체를 두는 편이 안전합니다.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++ 와 달리 경합에서도 갱신 유실이 없다
Atomic*이 정석이에요. 여러 변수의 불변식을 함께 지켜야 하면 락이 낫습니다.
AtomicStampedReference(또는 마킹용 AtomicMarkableReference)예요.AtomicLong에 수많은 스레드가 몰리면 CAS 실패·재시도가 폭증해요. LongAdder는 내부적으로 여러 셀(cell)에 분산해 더하고 읽을 때 합산해 경합을 흩뜨립니다. 통계 카운터처럼 쓰기 많고 읽기 드문 경우에 특히 유리해요.| 현상 | 정의 | 대표 처방 |
|---|---|---|
| 데드락 | 서로 상대의 락을 기다리며 영영 멈춤 | 락 순서 통일, tryLock(타임아웃) |
| 라이브락 | 멈추진 않는데 서로 양보만 반복해 진전이 없음 | 재시도에 랜덤 백오프 |
| 기아(starvation) | 특정 스레드가 자원을 계속 못 얻음 | 공정성(fair) 락, 우선순위 조정 |
| false sharing | 서로 다른 변수가 같은 캐시라인에 있어 무효화가 전파돼 성능 저하 | 패딩, @Contended |
@jdk.internal.vm.annotation.Contended(또는 수동 패딩)로 변수를 다른 라인으로 분리해요.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;
}
}
enum 싱글턴은 JVM의 클래스 초기화 스레드 안전성에 얹혀 가므로 volatile·락 코드가 아예 필요 없어요. DCL은 "이 관용구를 왜 쓰는지" 이해하는 용도로 알아두되, 실전에선 더 단순한 대안을 먼저 고려하세요.count++ 유실). 플래그·안전 발행·DCL 필드에 적합.AtomicStampedReference로. 극심한 경합엔 LongAdder.