i++가 왜 Thread-safe하지 않은지, volatile과 synchronized의
차이가 무엇인지 정확히 설명할 수 있어야 합니다.
이 챕터는 CPU 캐시 수준의 원인부터 Java가 제공하는 세 가지 해결책
(volatile / synchronized / Atomic)까지 계층적으로 설명합니다.
현대 CPU는 Main Memory(RAM)에서 데이터를 직접 읽지 않습니다. 속도 차이가 너무 크기 때문에(CPU: ns급 vs RAM: 60~100ns) 레지스터 → L1 → L2 캐시에 데이터를 복사해 두고 작업합니다. 이것이 동시성 문제의 근원입니다.
Thread-A가 x = 1을 쓰고 Main Memory에 반영했더라도,
Thread-B의 CPU 캐시에는 아직 x = 0이 남아 있을 수 있습니다.
이것이 가시성(Visibility) 문제입니다.
세 가지 동시성 문제가 있습니다:
| 문제 | 설명 | 예시 |
|---|---|---|
| 가시성 (Visibility) | 한 스레드가 쓴 값을 다른 스레드가 읽지 못함 | Thread-A가 flag=true 해도 Thread-B는 flag=false 읽음 |
| 원자성 (Atomicity) | 복합 연산이 중간에 끊길 수 있음 | i++ = read+increment+write의 3단계. 중간에 끊기면 값 소실 |
| 순서 (Ordering) | 컴파일러/JIT가 성능을 위해 명령 순서를 바꿀 수 있음 | a=1; b=2; 가 실행 시점에 b=2; a=1; 순서로 바뀔 수 있음 |
volatile 키워드가 붙은 변수는 항상 Main Memory에서 읽고 씁니다.
CPU 캐시를 거치지 않으므로 가시성 문제가 해결됩니다.
또한 메모리 배리어(Memory Barrier)를 삽입해서 명령어 재정렬도 방지합니다.
class StopTask {
// 캐시에서 읽을 수 있음
private boolean running = true;
public void stop() {
running = false; // Thread-A가 씀
}
public void run() {
// Thread-B가 캐시에서 읽으면
// running=true인 채로 무한루프!
while (running) {
doWork();
}
}
}
class StopTask {
// 항상 Main Memory에서 읽고 씀
private volatile boolean running = true;
public void stop() {
running = false; // Main Memory에 즉시 반영
}
public void run() {
// 항상 최신 값을 읽음
while (running) {
doWork();
}
}
}
volatile int count = 0;에서 count++을 두 스레드가 동시에 실행하면
여전히 값이 소실됩니다. count++는 (1)읽기 → (2)증가 → (3)쓰기의 3단계이고,
volatile은 각 단계의 가시성만 보장하지 3단계가 원자적으로 실행됨을 보장하지 않습니다.
이런 경우에는 synchronized 또는 AtomicInteger를 사용해야 합니다.
synchronized는 모니터 락(Monitor Lock)을 통해 한 번에 하나의 스레드만
임계 구역에 진입하도록 보장합니다. 락 해제 시 모든 변경사항이 Main Memory에 반영되므로
가시성도 함께 보장됩니다.
public class Counter {
private int count = 0;
// 메서드 전체에 this 객체 락
public synchronized void increment() {
count++;
}
public synchronized int get() {
return count;
}
}
// 단점: 메서드 전체가 임계 구역
// → 락 경합 범위가 넓어짐
public class OrderService {
private final Object lock = new Object();
private int pending = 0;
public void placeOrder(Order order) {
validateOrder(order); // 락 불필요
synchronized (lock) { // 임계 구역만 보호
pending++;
process(order);
pending--;
}
notify(order); // 락 불필요
}
}
// 락 보유 시간을 최소화 → 성능 향상
JMM(Java Memory Model)은 happens-before 관계를 정의합니다. A happens-before B라면, A의 모든 결과가 B에게 가시적으로 보장됩니다.
// 공유 변수 (volatile 없음)
int data = 0;
boolean ready = false;
// Thread-A
data = 42;
ready = true; // 재정렬 가능!
// Thread-B
if (ready) {
// data가 0일 수도 있음
// ready 쓰기가 data 쓰기보다
// 먼저 보일 수 있기 때문
System.out.println(data);
}
int data = 0;
volatile boolean ready = false;
// Thread-A
data = 42;
// volatile 쓰기 → 이전 작업 모두 완료 후
ready = true;
// Thread-B
if (ready) { // volatile 읽기
// ready=true라면 data=42가 반드시 보임
// volatile 읽기 HB 관계로 보장
System.out.println(data); // 반드시 42
}
synchronized는 락 경합(contention) 시 컨텍스트 스위칭이 발생해 성능이 저하됩니다.
Atomic 클래스는 CPU 수준의 CAS(Compare-And-Swap) 인스트럭션을 사용해
락 없이 원자적 연산을 구현합니다.
expected = value (현재 값 메모리에서 읽기)
value == expected 이면 새 값으로 원자적 교체 성공
value ≠ expected → 실패, 처음부터 재시도(spin)
public class SyncCounter {
private int count = 0;
public synchronized void increment() {
count++; // 락 획득 → 해제 반복
// 스레드 많을수록 컨텍스트 스위칭 폭발
}
public synchronized int get() {
return count;
}
}
public class AtomicCounter {
// CAS 기반, 락 없음
private AtomicInteger count
= new AtomicInteger(0);
public void increment() {
count.incrementAndGet(); // CAS 원자적 연산
}
public int get() {
return count.get();
}
}
// 고경합 환경에서 synchronized보다 빠름
// 설정 객체를 무중단 교체 시
AtomicReference<Config> config =
new AtomicReference<>(initialConfig);
// Thread-safe하게 새 설정으로 교체
Config old = config.get();
Config updated = buildNewConfig(old);
// CAS: old와 현재 값이 같을 때만 updated로 교체
boolean success = config.compareAndSet(
old, updated
);
// ABA 문제: A→B→A 변경 후 CAS가 성공해버림
// stamp(버전)을 함께 관리해서 방지
AtomicStampedReference<Node> ref =
new AtomicStampedReference<>(node, 0);
int[] stamp = new int[1];
Node current = ref.get(stamp); // stamp[0]=현재 버전
// 버전도 일치해야 교체 성공
ref.compareAndSet(
current, newNode,
stamp[0], stamp[0] + 1
);
| 상황 | 권장 도구 | 이유 |
|---|---|---|
| 단순 플래그 공유 (읽기 多) | volatile boolean |
가시성만 필요, 성능 오버헤드 없음 |
| 단순 정수 카운터 | AtomicInteger |
Lock-free CAS, synchronized보다 고경합 시 빠름 |
| 복합 조건 임계 구역 | synchronized |
원자성 + 가시성 + 순서 모두 보장, 블록 단위로 범위 최소화 |
| 읽기 多 / 쓰기 少 복합 연산 | ReentrantReadWriteLock |
여러 읽기 스레드 동시 진행, 쓰기만 단독 락 |
| 객체 참조 원자 교체 | AtomicReference |
설정 객체, 상태 머신 등의 무중단 교체 |
volatile은 가시성과 명령어 재정렬 방지만 보장합니다. 단일 변수 읽기/쓰기에 대해
항상 Main Memory를 참조하게 만들지만, 복합 연산(read-modify-write)의 원자성은 보장하지 않습니다.
synchronized는 원자성 + 가시성 + 순서를 모두 보장합니다. 모니터 락으로 임계 구역을 보호하고,
락 해제 시 모든 변경사항을 Main Memory에 flush합니다. 대신 락 경합 시 컨텍스트 스위칭 비용이 발생합니다.
volatile은 플래그 변수, synchronized는 복합 연산 보호에 사용합니다.
i++는 (1) 현재 값 읽기, (2) 1 증가, (3) 새 값 쓰기의 세 단계 연산입니다.
Thread-A와 Thread-B가 동시에 실행 시, 둘 다 (1)에서 같은 값을 읽을 수 있습니다.
예를 들어 i=5일 때 둘 다 5를 읽고 각자 6을 쓰면, 두 번 증가했지만 결과는 6이 됩니다.
이를 방지하려면 synchronized 블록 또는 AtomicInteger.incrementAndGet()을 사용해야 합니다.
경합(contention)이 낮을 때는 synchronized와 CAS 성능 차이가 거의 없습니다. 경합이 높아질수록(많은 스레드가 동시 접근) 차이가 납니다. synchronized는 락을 못 얻은 스레드가 블로킹되어 컨텍스트 스위칭이 발생합니다 (OS 레벨 비용). CAS는 실패 시 spin(재시도)하므로 컨텍스트 스위칭은 없지만 CPU를 계속 소비합니다. 따라서 짧은 임계 구역 + 높은 경합 = Atomic이 유리, 긴 임계 구역 = synchronized가 나을 수 있습니다.
동시성 문제는 CPU 캐시에서 시작된다 — volatile은 가시성만, synchronized는 원자성+가시성+순서 모두, Atomic은 락 없이 원자적 연산. 상황에 맞게 선택하면 된다.