Chapter 4

Advanced Topics

  • 4.1 명시적 락
  • 4.2 커스텀 동기화
  • 4.3 원자 변수와 논블로킹 동기화
  • 4.4 자바 메모리 모델

동시성의 마지막 파트는 고급 주제야. synchronized 너머의 명시적 락, 조건 큐의 내부 동작, 락 없이 돌아가는 논블로킹 알고리즘, 그리고 이 모든 것의 이론적 기반인 자바 메모리 모델까지 다뤄.

내장 락(synchronized)은 간편하지만 한계가 있어. 락을 잡으려고 기다리는 중에 인터럽트할 수 없고, 타임아웃을 설정할 수 없고, 블록 구조가 아닌 방식으로 락을 잡을 수 없지. Lock 인터페이스와 ReentrantLock이 이 한계를 극복해.

Lock lock = new ReentrantLock();
lock.lock();
try {
    // 보호하려는 코드
} finally {
    lock.unlock(); // 반드시 finally에서 해제!
}

try-finally로 해제를 보장해야 해. synchronized는 블록을 벗어나면 자동 해제지만, Lock은 명시적으로 unlock()을 호출해야 하거든. 까먹으면 락이 영원히 잠긴 채로 남아. **tryLock()**은 락을 즉시 잡으려 시도하고, 실패하면 false를 반환해. 대기하지 않지.

if (lock.tryLock(1, TimeUnit.SECONDS)) {
    try { /* 작업 */ }
    finally { lock.unlock(); }
} else {
    // 1초 안에 락을 못 잡음 — 대안 로직 실행
}

이걸 활용하면 데드락을 회피할 수 있어. 두 개의 락을 모두 tryLock으로 잡으려 시도하고, 하나라도 실패하면 잡은 것도 풀고 다시 시도하는 거야. **lockInterruptibly()**는 락 대기 중에 인터럽트를 받으면 InterruptedException을 던져서 취소 가능한 작업에서 유용하지.

ReentrantLock은 **비공정(unfair)**과 공정(fair) 모드를 선택할 수 있어. 기본값은 비공정이야. 직관적으로 공정이 좋아 보이지만, 실제로는 비공정이 훨씬 빨라. 공정 락에서는 락을 해제하면 대기 큐의 첫 번째 스레드를 깨워야 하는데, 그 스레드가 실행 가능해질 때까지 시간이 걸리거든. 비공정 락에서는 방금 실행 중인 스레드가 바로 락을 잡으니까 그 지연이 없어. 대부분의 경우 비공정 락을 써.

그러면 언제 synchronized를 쓰고 언제 ReentrantLock을 쓸까? synchronized를 기본으로 써. 더 간결하고 익숙하고, finally에서 unlock 까먹을 위험이 없고, 스레드 덤프에서 모니터 정보가 명확하게 나오고, JVM이 최적화하기 쉽거든. ReentrantLock은 tryLock이 필요하거나, 인터럽트 가능한 락 대기가 필요하거나, 타임아웃이 필요하거나, 공정 락이 필요할 때만 쓰면 돼. 자바 6 이후로 synchronized 성능이 크게 개선돼서, 성능 차이를 이유로 ReentrantLock을 선택하는 건 더 이상 타당하지 않아.

ReadWriteLock은 읽기 락과 쓰기 락을 분리해. 여러 스레드가 동시에 읽을 수 있고, 쓰기만 독점적이지.

ReadWriteLock rwLock = new ReentrantReadWriteLock();
Lock readLock = rwLock.readLock();
Lock writeLock = rwLock.writeLock();

// 읽기 — 여러 스레드 동시 가능
readLock.lock();
try { return map.get(key); }
finally { readLock.unlock(); }

// 쓰기 — 독점적
writeLock.lock();
try { map.put(key, value); }
finally { writeLock.unlock(); }

읽기가 쓰기보다 훨씬 많고 읽기 작업이 어느 정도 시간이 걸릴 때 효과적이야. 읽기가 매우 짧으면 ReadWriteLock의 오버헤드 때문에 오히려 일반 락보다 느릴 수 있어.

이제 커스텀 동기화 얘기로 넘어갈게. 많은 동시성 연산은 **상태 의존적(state-dependent)**이야. "큐가 비어 있지 않을 때 꺼내라", "버퍼에 공간이 있을 때 넣어라" 같은 거지. 가장 단순한 접근은 **바쁜 대기(busy waiting)**인데, CPU를 낭비하거나 반응이 느리거나 해서 비효율적이야. 조건이 참이 되는 순간 바로 깨어나는 메커니즘이 필요한데, 그게 **조건 큐(condition queue)**야.

자바의 모든 객체는 조건 큐 역할을 할 수 있어. wait(), notify(), notifyAll()이 그 메커니즘이지.

// 제한된 버퍼 — 조건 큐 사용
public synchronized void put(V item) throws InterruptedException {
    while (isFull()) {      // 조건 확인 — 반드시 while로!
        wait();             // 조건이 거짓이면 대기
    }
    items[tail] = item;
    notifyAll();            // 상태 변경 후 대기 중인 스레드 깨우기
}

public synchronized V take() throws InterruptedException {
    while (isEmpty()) {
        wait();
    }
    V item = items[head];
    notifyAll();
    return item;
}

핵심 규칙이 있어. wait는 반드시 while 루프 안에서 호출해야 해. if가 아니라 while이야. 깨어났을 때 조건이 여전히 참이라는 보장이 없거든. 다른 스레드가 먼저 처리했을 수 있고, **허위 깨어남(spurious wakeup)**도 가능하니까. 그리고 상태를 변경한 후 반드시 notifyAll()을 호출해야 해. notify()는 스레드 하나만 깨우는데, 그 스레드가 대기 중인 조건이 내가 변경한 조건과 다를 수 있잖아.

내장 조건 큐는 객체당 하나뿐이라서 여러 조건을 구분할 수 없어. Condition 객체는 Lock마다 여러 개의 조건 큐를 만들 수 있게 해줘.

Lock lock = new ReentrantLock();
Condition notFull = lock.newCondition();
Condition notEmpty = lock.newCondition();

public void put(V item) throws InterruptedException {
    lock.lock();
    try {
        while (isFull()) notFull.await();    // "가득 차지 않음" 조건 대기
        items[tail] = item;
        notEmpty.signal();                     // "비어있지 않음" 조건 시그널
    } finally { lock.unlock(); }
}

Condition을 쓰면 signal()만으로 충분해. 각 조건에 해당하는 스레드만 대기하고 있으니까. **AQS(AbstractQueuedSynchronizer)**는 자바 동시성 라이브러리의 핵심 기반이야. ReentrantLock, Semaphore, CountDownLatch, ReentrantReadWriteLock, FutureTask가 모두 AQS 위에 구축되어 있지. int 상태 값과 FIFO 대기 큐를 관리하고, 하위 클래스는 tryAcquire/tryRelease(독점 모드) 또는 tryAcquireShared/tryReleaseShared(공유 모드)를 구현하면 돼.

public class OneShotLatch {
    private final Sync sync = new Sync();

    private class Sync extends AbstractQueuedSynchronizer {
        protected int tryAcquireShared(int ignored) {
            return (getState() == 1) ? 1 : -1; // 열렸으면 통과, 아니면 대기
        }
        protected boolean tryReleaseShared(int ignored) {
            setState(1); // 래치 열기
            return true;
        }
    }

    public void await() throws InterruptedException {
        sync.acquireSharedInterruptibly(0);
    }
    public void signal() {
        sync.releaseShared(0);
    }
}

이제 락 없이 동기화하는 방법이야. 락은 강력하지만 경합 시 컨텍스트 스위칭이 발생하고, 우선순위 역전도 생길 수 있어. **CAS(Compare-And-Swap)**가 그 빈자리를 채워. CAS는 현대 프로세서가 하드웨어 레벨에서 지원하는 원자적 명령인데, 메모리 위치의 현재 값이 기대값과 같으면 새 값으로 교체하고, 다르면 아무것도 안 하지. 이 전체가 원자적으로 실행돼. CAS는 낙관적(optimistic) 접근법이야. "충돌이 없을 거라고 가정하고 진행한다. 충돌이 발생하면 재시도한다." 경합이 낮으면 락보다 훨씬 빠르고 블로킹도 없어.

// CAS를 이용한 스레드 안전 카운터
public class CasCounter {
    private AtomicInteger value = new AtomicInteger(0);

    public int increment() {
        int oldValue;
        do {
            oldValue = value.get();
        } while (!value.compareAndSet(oldValue, oldValue + 1)); // CAS 재시도 루프
        return oldValue + 1;
    }
}

java.util.concurrent.atomic 패키지가 CAS를 감싸서 편리한 API를 제공해. AtomicInteger, AtomicLong, AtomicReference 등이 있고, 원자 변수는 **"더 나은 volatile"**이야. volatile의 가시성 보장에 read-modify-write 원자성을 더한 거지. 경합이 낮은~중간 수준일 때 락보다 빨라.

CAS를 활용하면 논블로킹 자료구조를 만들 수 있어. Treiber 스택은 CAS로 헤드를 원자적으로 교체하는 논블로킹 스택이고, Michael-Scott 큐ConcurrentLinkedQueue의 기반이 되는 논블로킹 큐 알고리즘이야. 한 스레드가 중간에 멈춰도 다른 스레드가 "도와서" 작업을 완료할 수 있는 게 핵심이지. ABA 문제는 CAS의 함정인데, 값이 A→B→A로 바뀌면 CAS가 "변경되지 않았다"고 판단해. AtomicStampedReference로 값과 함께 버전 번호를 관리하면 해결돼.

마지막으로 **자바 메모리 모델(JMM)**이야. 이 책 전체에서 다뤄온 가시성, 순서 보장, 안전한 발행이 JMM이라는 하나의 이론적 틀로 통합돼. 프로그래머가 작성한 코드의 실행 순서는 실제 실행 순서와 다를 수 있어. 컴파일러가 명령어를 재배치하고, 프로세서가 순서를 바꾸고, 메모리 시스템이 쓰기 순서를 바꿀 수 있거든. 단일 스레드에서는 as-if-serial 덕분에 안 보이지만, 멀티스레드에서는 한 스레드의 재배치가 다른 스레드에 보일 수 있어.

JMM의 핵심 개념이 happens-before 관계야. A happens-before B이면, A의 결과가 B에게 보여. happens-before가 없으면 JVM은 어떤 재배치든 할 수 있고, 다른 스레드의 변경이 보일 수도 안 보일 수도 있지. 주요 규칙들을 보면 — 모니터 락 규칙(락 해제는 같은 락의 다음 획득에 대해 happens-before), volatile 변수 규칙(volatile 쓰기는 같은 변수의 다음 읽기에 대해 happens-before), 스레드 시작 규칙(Thread.start() 호출은 시작된 스레드의 모든 동작에 대해 happens-before), 그리고 전이성(A hb B이고 B hb C이면 A hb C)이야.

**피기백(piggybacking)**은 happens-before의 전이성을 활용하는 기법이야. volatile 쓰기 전에 일반 변수를 설정하면, volatile 읽기 후에 그 일반 변수도 볼 수 있지.

// 피기백 예시
// 스레드 A
x = 42;                    // 일반 쓰기
volatile_flag = true;       // volatile 쓰기

// 스레드 B
if (volatile_flag) {        // volatile 읽기
    // x == 42가 보장된다 (전이성)
}

final 필드는 JMM에서 특별한 보장을 받아. 생성자가 완료되면, final 필드의 값은 다른 스레드에서 항상 올바르게 보여 — 추가 동기화 없이도. 이걸 **초기화 안전성(initialization safety)**이라고 해. 조건은 필드가 final이어야 하고, 생성자에서 this가 탈출하지 않아야 하고, 생성자가 정상적으로 완료되어야 해. 그래서 불변 객체가 스레드 안전한 거고, 가능한 한 필드를 final로 선언하라는 거야.


정리

4장 읽고 기억할 거 세 가지:

  1. synchronized를 기본으로 쓰고, tryLock이나 인터럽트 가능한 대기가 필요할 때만 ReentrantLock을 써. wait()는 반드시 while 루프 안에서 호출하고, AQS가 자바 동시성 도구들의 내부 기반이라는 걸 알아두면 돼
  2. CAS는 낙관적 동기화야. 경합이 낮으면 락보다 훨씬 빠르고, AtomicInteger/AtomicReference는 "더 나은 volatile"이야. ABA 문제만 조심하고
  3. happens-before 관계가 가시성의 근간이야. 락 해제→획득, volatile 쓰기→읽기의 규칙이 스레드 간 데이터 전달을 보장하고, final 필드는 초기화 안전성을 추가 동기화 없이 보장해줘