4. Java 동시성 기초

i++가 왜 Thread-safe하지 않은지, volatilesynchronized의 차이가 무엇인지 정확히 설명할 수 있어야 합니다. 이 챕터는 CPU 캐시 수준의 원인부터 Java가 제공하는 세 가지 해결책 (volatile / synchronized / Atomic)까지 계층적으로 설명합니다.

1. 근본 원인 — CPU 캐시와 메모리 가시성

현대 CPU는 Main Memory(RAM)에서 데이터를 직접 읽지 않습니다. 속도 차이가 너무 크기 때문에(CPU: ns급 vs RAM: 60~100ns) 레지스터 → L1 → L2 캐시에 데이터를 복사해 두고 작업합니다. 이것이 동시성 문제의 근원입니다.

CPU CORE 0 (Thread-A)
Register: x=1
L1 Cache: x=1
L2 Cache: x=1
메모리 버스
CPU CORE 1 (Thread-B)
Register: x=0 ← 오래된 값!
L1 Cache: x=0
L2 Cache: x=0
MAIN MEMORY (RAM)
x = 1 (Thread-A가 방금 씀)

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; 순서로 바뀔 수 있음

2. volatile — 가시성 보장 (단, 원자성은 아님)

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();
        }
    }
}
✅ volatile로 가시성 보장
class StopTask {
    // 항상 Main Memory에서 읽고 씀
    private volatile boolean running = true;

    public void stop() {
        running = false; // Main Memory에 즉시 반영
    }

    public void run() {
        // 항상 최신 값을 읽음
        while (running) {
            doWork();
        }
    }
}
volatile의 한계 — 원자성은 보장하지 않습니다

volatile int count = 0;에서 count++을 두 스레드가 동시에 실행하면 여전히 값이 소실됩니다. count++는 (1)읽기 → (2)증가 → (3)쓰기의 3단계이고, volatile은 각 단계의 가시성만 보장하지 3단계가 원자적으로 실행됨을 보장하지 않습니다. 이런 경우에는 synchronized 또는 AtomicInteger를 사용해야 합니다.

3. synchronized — 원자성 + 가시성 + 순서 모두 보장

synchronized모니터 락(Monitor Lock)을 통해 한 번에 하나의 스레드만 임계 구역에 진입하도록 보장합니다. 락 해제 시 모든 변경사항이 Main Memory에 반영되므로 가시성도 함께 보장됩니다.

메서드 synchronized (this 락)
public class Counter {
    private int count = 0;

    // 메서드 전체에 this 객체 락
    public synchronized void increment() {
        count++;
    }

    public synchronized int get() {
        return count;
    }
}
// 단점: 메서드 전체가 임계 구역
//        → 락 경합 범위가 넓어짐
블록 synchronized (범위 최소화)
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); // 락 불필요
    }
}
// 락 보유 시간을 최소화 → 성능 향상

4. happens-before — "이 작업은 저 작업 이전에 반드시 완료된다"

JMM(Java Memory Model)은 happens-before 관계를 정의합니다. A happens-before B라면, A의 모든 결과가 B에게 가시적으로 보장됩니다.

synchronized unlock
→ HB →
다음 synchronized lock
volatile 쓰기
→ HB →
이후 volatile 읽기
Thread.start()
→ HB →
새 스레드의 모든 작업
스레드의 모든 작업
→ HB →
Thread.join() 이후
❌ happens-before 없음 → 오동작
// 공유 변수 (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);
}
✅ volatile로 happens-before 보장
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
}

5. Atomic 클래스 — Lock-free 원자적 연산

synchronized는 락 경합(contention) 시 컨텍스트 스위칭이 발생해 성능이 저하됩니다. Atomic 클래스는 CPU 수준의 CAS(Compare-And-Swap) 인스트럭션을 사용해 락 없이 원자적 연산을 구현합니다.

1
현재 값 읽기

expected = value (현재 값 메모리에서 읽기)

2
CAS 시도 (성공)

value == expected 이면 새 값으로 원자적 교체 성공

2'
CAS 실패 (다른 스레드가 먼저 변경)

value ≠ expected → 실패, 처음부터 재시도(spin)

❌ synchronized 카운터 (락 경합 발생)
public class SyncCounter {
    private int count = 0;

    public synchronized void increment() {
        count++; // 락 획득 → 해제 반복
        // 스레드 많을수록 컨텍스트 스위칭 폭발
    }

    public synchronized int get() {
        return count;
    }
}
✅ AtomicInteger (Lock-free)
public class AtomicCounter {
    // CAS 기반, 락 없음
    private AtomicInteger count
        = new AtomicInteger(0);

    public void increment() {
        count.incrementAndGet(); // CAS 원자적 연산
    }

    public int get() {
        return count.get();
    }
}
// 고경합 환경에서 synchronized보다 빠름
AtomicReference — 객체 원자 교체
// 설정 객체를 무중단 교체 시
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
);
AtomicStampedReference — ABA 문제 해결
// 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
);

6. 실무 동기화 도구 선택 가이드

상황 권장 도구 이유
단순 플래그 공유 (읽기 多) volatile boolean 가시성만 필요, 성능 오버헤드 없음
단순 정수 카운터 AtomicInteger Lock-free CAS, synchronized보다 고경합 시 빠름
복합 조건 임계 구역 synchronized 원자성 + 가시성 + 순서 모두 보장, 블록 단위로 범위 최소화
읽기 多 / 쓰기 少 복합 연산 ReentrantReadWriteLock 여러 읽기 스레드 동시 진행, 쓰기만 단독 락
객체 참조 원자 교체 AtomicReference 설정 객체, 상태 머신 등의 무중단 교체

7. 면접 대비 핵심 Q&A

Q1. "volatile과 synchronized의 차이를 설명해주세요."

volatile은 가시성과 명령어 재정렬 방지만 보장합니다. 단일 변수 읽기/쓰기에 대해 항상 Main Memory를 참조하게 만들지만, 복합 연산(read-modify-write)의 원자성은 보장하지 않습니다.
synchronized는 원자성 + 가시성 + 순서를 모두 보장합니다. 모니터 락으로 임계 구역을 보호하고, 락 해제 시 모든 변경사항을 Main Memory에 flush합니다. 대신 락 경합 시 컨텍스트 스위칭 비용이 발생합니다. volatile은 플래그 변수, synchronized는 복합 연산 보호에 사용합니다.

Q2. "i++가 Thread-safe하지 않은 이유는?"

i++는 (1) 현재 값 읽기, (2) 1 증가, (3) 새 값 쓰기의 세 단계 연산입니다. Thread-A와 Thread-B가 동시에 실행 시, 둘 다 (1)에서 같은 값을 읽을 수 있습니다. 예를 들어 i=5일 때 둘 다 5를 읽고 각자 6을 쓰면, 두 번 증가했지만 결과는 6이 됩니다. 이를 방지하려면 synchronized 블록 또는 AtomicInteger.incrementAndGet()을 사용해야 합니다.

Q3. "CAS와 synchronized의 성능 차이는 언제 나타나나요?"

경합(contention)이 낮을 때는 synchronized와 CAS 성능 차이가 거의 없습니다. 경합이 높아질수록(많은 스레드가 동시 접근) 차이가 납니다. synchronized는 락을 못 얻은 스레드가 블로킹되어 컨텍스트 스위칭이 발생합니다 (OS 레벨 비용). CAS는 실패 시 spin(재시도)하므로 컨텍스트 스위칭은 없지만 CPU를 계속 소비합니다. 따라서 짧은 임계 구역 + 높은 경합 = Atomic이 유리, 긴 임계 구역 = synchronized가 나을 수 있습니다.

이 챕터의 핵심 한 줄

동시성 문제는 CPU 캐시에서 시작된다 — volatile은 가시성만, synchronized는 원자성+가시성+순서 모두, Atomic은 락 없이 원자적 연산. 상황에 맞게 선택하면 된다.