왜 락-프리 CAS · ABA Treiber 스택 Michael-Scott 큐 자바 기성품 함정 · 주의 📝 문제풀이
◀ 심화 CH 24 📋 목차 심화 CH 26 ▶
🔗 심화 CHAPTER 25 · 딥다이브 · 5년차 트랙

락 없이 안전하게 — 락-프리 자료구조는 어떻게 동작할까?

synchronized·ReentrantLock은 이제 익숙하실 거예요. 이번 딥다이브는 락을 아예 걸지 않고 CAS만으로 스레드 안전을 만드는 세계를 파고듭니다. Treiber 스택·Michael-Scott 큐 같은 고전 알고리즘부터 ConcurrentLinkedQueue·LongAdder가 내부에서 하는 일, 그리고 ABA·재시도 폭증·false sharing 같은 함정까지 — "락-프리가 항상 정답은 아니다"의 감각을 잡습니다.

🎯 이 장을 끝내면
🚦
왜 락-프리
락이 만드는 문제들
블로킹·컨텍스트 스위치·우선순위 역전·데드락·호송

뮤텍스 락은 단순하고 강력하지만 본질적으로 블로킹이에요. 한 스레드가 락을 쥔 채 스케줄에서 밀리거나(preempt), 페이지 폴트가 나거나, 심지어 죽으면 그 락을 기다리는 모든 스레드가 함께 멈춰요. 락 하나의 장애가 시스템 전체 진행을 막는 셈이죠. 락-프리는 "어떤 스레드가 어느 순간 멈춰도, 시스템 전체는 계속 전진한다"를 목표로 합니다.

락의 문제무슨 일이 벌어지나
블로킹락을 못 얻은 스레드는 대기. 락 보유자가 느리면 대기자도 함께 느려짐
컨텍스트 스위치 비용park/unpark로 스레드가 재웠다 깨면 스케줄러·캐시 오염 비용 발생
우선순위 역전낮은 우선순위 스레드가 락을 쥐면 높은 우선순위 스레드가 대기
데드락여러 락을 서로 다른 순서로 잡으면 순환 대기로 영구 정지
호송 효과(convoy)느린 락 보유자 뒤로 줄이 길게 늘어서 처리량이 급락
🧗 논블로킹 진행 보장 3계층 — 정의를 정확히 ① Wait-free (가장 강함)모든 스레드유한한 단계 안에 자기 작업을 끝냄. 굶는(starvation) 스레드가 없음. 구현이 가장 어렵고 비쌈.
② Lock-free시스템 전체로는 항상 누군가는 전진함(적어도 한 스레드는 유한 단계에 성공). 개별 스레드는 재시도로 무한히 미끄러질 수 있음(굶을 수 있음).
③ Obstruction-free (가장 약함)다른 스레드가 방해를 멈추면(혼자 실행되면) 유한 단계에 끝남. 경합 중엔 서로 롤백해 아무도 못 끝낼 수도. 포함 관계는 wait-free ⊂ lock-free ⊂ obstruction-free예요(강한 보장이 약한 보장의 특수 케이스). 즉 wait-free면 자동으로 lock-free이고, lock-free면 자동으로 obstruction-free입니다.
💡
"락-프리"의 핵심은 이름과 달라요. synchronized 키워드를 안 쓴다는 뜻이 아니라, 어느 스레드가 임의 지점에서 멈춰도 다른 스레드의 진행이 막히지 않는다진행 보장(progress guarantee)이 핵심이에요. 그래서 스핀락은 락이란 이름이 없어도 락-프리가 아닙니다(보유자가 멈추면 대기자가 영원히 돎).
🖼️ 그림으로 보기 — 진행 보장의 포함 관계
Obstruction-free (가장 약한 보장) Lock-free (시스템 전체 진행 보장) Wait-free 모든 스레드가 유한 단계에 완료 (가장 강한 보장)
🔁
CAS · ABA
CAS 한 방과, 그 유명한 함정
compareAndSet · ABA · AtomicStampedReference

락-프리의 심장은 CAS(Compare-And-Swap)예요. "이 메모리가 내가 본 값(expected)이 맞으면 새 값으로 바꾸고, 아니면 실패를 알려라"를 원자적으로 수행하는 CPU 명령(CMPXCHG)이죠. 자바에선 AtomicInteger.compareAndSet(expect, update) 같은 형태로 노출돼요.

AtomicInteger v = new AtomicInteger(0);

// "지금 값이 0이면 1로 바꿔" — 성공하면 true
boolean ok = v.compareAndSet(0, 1);

// 락-프리 증가의 전형: 읽고 → 계산 → CAS, 실패하면 재시도
int cur, next;
do {
    cur  = v.get();
    next = cur + 1;
} while (!v.compareAndSet(cur, next)); // 경합에서 밀리면 다시 시도
🃏 ABA 문제 — CAS의 유명한 사각지대 CAS는 "값이 같은가"만 봐요. 그래서 값이 A → B → 다시 A로 돌아오면, 그 사이 무슨 일이 있었는지 모른 채 "여전히 A네" 하고 CAS가 성공해 버립니다. 값은 같아도 가리키던 노드가 이미 재활용/변형됐을 수 있는데 말이죠. 스택·큐처럼 포인터를 CAS하는 자료구조에서 특히 위험해요. pop 도중 다른 스레드가 그 노드를 빼고, 다른 값을 넣었다가, 같은 주소의 노드를 다시 head로 만들면, 원래 스레드의 CAS가 잘못 성공해 리스트가 깨질 수 있습니다.

해결의 핵심은 값에 버전(스탬프)을 붙여 "값+버전"을 함께 비교하는 거예요. 값이 A로 돌아와도 버전은 증가했으니 CAS가 옛 스냅샷을 거부합니다. 자바는 AtomicStampedReference<V>(int 스탬프)와 AtomicMarkableReference<V>(boolean 마크)를 제공해요.

// 참조 + int 스탬프를 한 번에 CAS → ABA 차단
AtomicStampedReference<Node> ref =
        new AtomicStampedReference<>(head, 0);

int[] stampHolder = new int[1];
Node cur = ref.get(stampHolder);      // 값과 스탬프를 함께 스냅샷
int  stamp = stampHolder[0];

// 값이 cur이고 && 스탬프도 그대로일 때만 성공. 성공 시 스탬프 +1
boolean ok = ref.compareAndSet(cur, next, stamp, stamp + 1);
🧭
자바가 C++보다 편한 지점 — 안전한 메모리 재확보(SMR). C/C++에서 락-프리 자료구조는 "지금 다른 스레드가 이 노드를 읽는 중일 수도 있는데 free()해도 되나?"라는 무서운 문제(use-after-free)를 hazard pointer·RCU·epoch 같은 기법으로 직접 풀어야 해요. 자바는 GC가 있어서 아직 참조가 남은 노드는 절대 회수되지 않아요 — 이 문제 상당 부분이 자동으로 사라집니다. 다만 ABA(논리적 재사용) 자체는 GC로도 안 사라져서 스탬프가 여전히 필요합니다.
📚
Treiber 스택
head 하나만 CAS하는 락-프리 스택
가장 단순한 락-프리 자료구조의 교과서

Treiber 스택(1986)은 락-프리 자료구조의 "Hello World"예요. 단일 연결 리스트의 head 포인터 하나만 CAS로 갈아끼우면 push/pop이 됩니다. push는 새 노드의 next를 현재 head로 걸어두고 head를 새 노드로 CAS, pop은 head를 head.next로 CAS하죠.

class TreiberStack<E> {
    static final class Node<E> {
        final E item; Node<E> next;
        Node(E item){ this.item = item; }
    }
    private final AtomicReference<Node<E>> head = new AtomicReference<>();

    void push(E item) {
        Node<E> n = new Node<>(item);
        Node<E> cur;
        do {
            cur = head.get();
            n.next = cur;                         // 새 노드를 현재 head 위에 얹고
        } while (!head.compareAndSet(cur, n));     // head를 n으로 교체 (실패 시 재시도)
    }

    E pop() {
        Node<E> cur, next;
        do {
            cur = head.get();
            if (cur == null) return null;          // 비어 있음
            next = cur.next;
        } while (!head.compareAndSet(cur, next));   // head를 next로 교체
        return cur.item;
    }
}
⚠️
여기가 바로 ABA의 무대예요. 스레드 T1이 pop에서 cur=A, next=A.next(=B)까지 읽고 잠깐 멈춘 사이, T2가 A와 B를 pop한 뒤 같은 객체 A를 다시 push하면 head는 또 A가 돼요. 깨어난 T1의 compareAndSet(A, B)는 "head가 A네" 하고 성공하지만, 이제 B는 이미 리스트 밖이라 스택이 깨집니다. 그래서 실전 락-프리 스택은 스탬프/버전을 함께 CAS해요. (자바 GC는 use-after-free는 막아주지만, 이 논리적 ABA는 못 막습니다.)
🚚
Michael-Scott 큐
head·tail을 CAS하는 락-프리 FIFO 큐
ConcurrentLinkedQueue의 이론적 뿌리

큐는 스택보다 어려워요. 양 끝(head=dequeue, tail=enqueue)을 각각 갱신해야 하니까요. Michael & Scott(1996) 큐는 더미(sentinel) 노드를 두고, enqueue를 두 번의 CAS로 나눠 우아하게 해결합니다.

next 연결
마지막 노드의 next를 새 노드로 CAS
tail 전진
tail을 새 노드로 CAS (도우미도 대신 함)

핵심 통찰은 tail이 "뒤처질(lagging)" 수 있다는 걸 정상으로 허용하는 거예요. ①에서 next 연결에는 성공했지만 ②의 tail 전진 전에 스레드가 멈춰도 큐는 일관성 있는 중간 상태에 있어요. 다른 스레드가 tail.next != null인 걸 발견하면, 자기 일을 하기 전에 남의 tail을 대신 전진시켜(helping) 줍니다. 어느 한 스레드가 멈춰도 전체는 전진하는 락-프리의 전형이에요.

// Michael-Scott enqueue의 뼈대 (개념 코드)
void enqueue(E item) {
    Node<E> n = new Node<>(item);
    while (true) {
        Node<E> last = tail.get();
        Node<E> next = last.next.get();
        if (last == tail.get()) {                 // tail이 그대로인지 재확인
            if (next == null) {                    // 진짜 마지막이면
                if (last.next.compareAndSet(null, n)) {  // ① next 연결
                    tail.compareAndSet(last, n);          // ② tail 전진(실패해도 OK)
                    return;
                }
            } else {
                tail.compareAndSet(last, next);    // 남이 뒤처뜨린 tail을 도와서 전진
            }
        }
    }
}
💡
자바의 ConcurrentLinkedQueue가 바로 이 알고리즘의 후예예요. Doug Lea가 Michael-Scott 큐를 기반으로, tail을 매번 갱신하지 않고 여러 노드마다 한 번씩만 전진시키는(느슨한 tail) 최적화 등을 얹어 구현했어요. 그래서 ConcurrentLinkedQueue락-프리·무제한(unbounded) FIFO이고, size()가 O(n)에 근사값이라는 특성도 이 구조에서 나옵니다.
🖼️ 그림으로 보기 — enqueue의 2단계 CAS와 도우미 전진
더미(head) sentinel Node X Node Y (last) Node Z (new) enqueue 대상 ① next CAS tail (지금은 Y를 가리킴 — ② CAS로 Z까지 전진, 늦으면 남이 도와줌) ② tail 전진
🧰
자바 기성품
직접 짜지 말고 java.util.concurrent를 써라
이미 검증된 락-프리·저경합 구현들

여기까지 읽고 "멋지다, 직접 만들어봐야지"라는 마음이 들 수 있어요. 하지만 실무의 정답은 대개 "직접 만들지 마라"입니다. 락-프리 자료구조는 메모리 순서·ABA·재시도·false sharing이 얽혀 정확히 짜기가 극도로 어렵고 테스트도 힘들어요. Doug Lea가 수년간 다듬은 java.util.concurrent를 쓰세요.

클래스내부 성격언제 쓰나
ConcurrentLinkedQueue락-프리 FIFO(Michael-Scott 기반), 무제한고성능 논블로킹 큐. 생산/소비가 많고 대기가 필요 없을 때
ConcurrentLinkedDeque락-프리 양방향(deque)양끝 삽입/삭제가 필요한 논블로킹 워크로드
LongAdder / LongAccumulator스트라이핑(striped) — 셀을 여러 개로 쪼개 경합 분산고경합 카운터. 값을 자주 더하고 가끔 읽을 때(AtomicLong보다 훨씬 빠름)
ConcurrentHashMapCAS + 락 혼합 — 빈 버킷은 CAS, 충돌 시 버킷 단위 synchronized범용 동시성 맵의 기본값
ConcurrentSkipListMap/Set락-프리 skip list(정렬 유지)동시성 + 정렬이 필요할 때
🧮 LongAdder는 왜 AtomicLong보다 빠를까 AtomicLong단 하나의 변수를 모든 스레드가 CAS해요. 경합이 높으면 CAS가 계속 실패·재시도하며 하나의 캐시라인을 두고 핑퐁이 벌어집니다. LongAdder는 값을 여러 셀(Cell)로 분산해 스레드마다 다른 셀을 갱신하게 하고, 읽을 때 sum()으로 합쳐요. 그래서 쓰기가 압도적으로 많고 읽기는 가끔인 카운터·통계 지표에 이상적이에요. 대신 sum()은 순간적으로 정확한 스냅샷이 아닐 수 있고, 셀만큼 메모리를 더 씁니다.
💡
선택 가이드. 블로킹 대기(가득 차면 기다리기)가 필요하면 ConcurrentLinkedQueue가 아니라 ArrayBlockingQueue·LinkedBlockingQueue 같은 블로킹 큐를 쓰세요 — 락-프리 큐는 대기 기능이 없어요. 즉 "논블로킹 = 항상 좋다"가 아니라 필요한 시맨틱(대기·유계·공정성)에 맞춰 고르는 게 맞습니다.
🕳️
함정 · 주의
"락-프리가 항상 빠른 건 아니다"
재시도 폭증 · false sharing · 공정성
💥 고경합 시 CAS 재시도 폭증 락-프리 알고리즘은 CAS가 실패하면 다시 읽고 다시 시도해요. 경합이 심하면 실패 → 재시도가 폭증해 CPU를 태우면서도 실질 진행은 느려질 수 있어요(livelock에 가까운 낭비). 완화책은 지수 백오프(exponential backoff)로 재시도 간 짧은 지연을 주거나, 아예 경합 자체를 분산(예: LongAdder의 스트라이핑)하는 겁니다. 그래서 경합이 극심하면 잘 만든 락(park/unpark로 대기하며 CPU를 양보)이 바쁘게 도는 락-프리보다 총 처리량이 더 나을 수도 있어요.
⚠️
False sharing(거짓 공유). 서로 다른 스레드가 서로 다른 변수를 갱신하는데, 그 변수들이 같은 캐시라인(보통 64바이트)에 있으면, 한쪽 쓰기가 다른 쪽 캐시라인을 무효화해 불필요한 캐시 코히런시 트래픽이 생겨요. 락-프리 코드에서 성능이 안 나오는 흔한 원인이에요. JDK는 @Contended(내부용)나 패딩으로 셀을 캐시라인 단위로 떼어 이 문제를 피합니다(LongAdder의 Cell이 그렇게 패딩돼 있어요).
🧭
락-프리가 불리한 경우.저경합 — 어차피 경합이 거의 없으면 락도 거의 무료(biased/uncontended lock은 매우 쌈)이고, 락-프리 코드의 복잡성만 손해예요. ② 공정성(fairness)이 중요할 때 — 락-프리는 개별 스레드가 굶을(starve) 수 있어서, FIFO 공정 락(new ReentrantLock(true))이 필요한 요구사항엔 맞지 않아요. ③ 여러 변수를 원자적으로 함께 바꿔야 할 때 — 단일 CAS로는 표현이 어렵습니다(그래서 락이나 STM을 씀).
💡
결론적 감각. "락-프리 = 항상 빠르고 좋다"는 미신이에요. 진행 보장이 필요한 곳(스레드 멈춤이 시스템을 막으면 안 되는 곳)·고경합에서 짧은 임계 구역일 때 빛나고, 그 외엔 단순한 락이나 검증된 j.u.c 컬렉션이 더 낫습니다. 그리고 언제나 측정으로 판단하세요.
🧠 이 장 핵심 요약
📝
문제풀이 · 점검
시니어 관점으로 점검
면접·설계 리뷰에서 실제로 묻는 결의 문제예요. 틀려도 바로 해설이 나옵니다.
🧪
개념을 "안다"와 "설명할 수 있다"는 달라요. 네 유형(객관식 · O/X · 빈칸 · 코드)으로 점검해보세요.
심화 CH 26 🔮 미래의 자바 — Valhalla(값 타입)와 Panama(외부 함수·메모리) (제작 예정)