Compare And Swap (비교 후 교환)
멀티스레드 환경에서 락(Lock) 없이 데이터를 안전하게 수정하는 원자적(Atomic) 연산입니다.
CAS(메모리 주소, 예상값, 새로운 값)
1. 메모리의 현재 값을 읽는다
2. 현재 값이 예상값과 같은가?
- 같으면: 새로운 값으로 변경 → 성공
- 다르면: 변경 안 함 → 실패
// 멀티스레드 환경의 문제 (Race Condition)
int count = 0;
// Thread 1: count++ → 예상: count = 2
// Thread 2: count++ → 실제: count = 1 (덮어씀!)
Thread 1 Thread 2 count
-------------------------------------------
read (0) 0
read (0) 0
write (1) 1
write (1) 1 ← 덮어씀!
초기값: count = 5
Thread 1: Thread 2:
-------------------------------------------------
read → expected = 5
read → expected = 5
CAS(count, 5, 6) ✅
→ count = 6
CAS(count, 5, 6) ❌
→ 실패 (현재 6임)
read → expected = 6
CAS(count, 6, 7) ✅
→ count = 7
변수 가시성을 보장하는 키워드
private boolean flag = false; // CPU 캐시 사용 → 가시성 문제
private volatile boolean flag = false; // 메인 메모리 직접 접근 → 모든 스레드가 최신값 보장
// volatile 없음
public class VisibilityProblem {
private boolean flag = false;
public void writer() { flag = true; } // Thread 1
public void reader() { // Thread 2
while (!flag) { } // 무한 루프! (캐시에서 계속 false 읽음)
}
}
// volatile 있음
public class Solution {
private volatile boolean flag = false;
public void writer() { flag = true; } // 메인 메모리에 즉시 반영
public void reader() {
while (!flag) { } // 정상 종료 (메인 메모리에서 최신값 읽음)
}
}
// ✅ 플래그, 상태 변수
public class Worker {
private volatile boolean running = true;
public void stop() { running = false; }
}
// ✅ Double-Checked Locking
private static volatile Singleton instance;
// ❌ 복합 연산 (count++는 원자적이지 않음)
private volatile int count = 0;
public void increment() { count++; } // Race Condition 발생!
핵심: volatile은 가시성만 보장, 원자성은 보장 안 됨 → 복합 연산에는 AtomicInteger 사용
AtomicInteger count = new AtomicInteger(0);
// 증가/감소
count.incrementAndGet(); // ++count
count.getAndIncrement(); // count++
count.decrementAndGet(); // --count
// 덧셈
count.addAndGet(5); // count += 5
count.getAndAdd(5); // 현재 값 반환 후 += 5
// CAS
boolean success = count.compareAndSet(0, 1);
public class CASExample {
private AtomicInteger count = new AtomicInteger(0);
public void increment() {
int expected, newValue;
do {
expected = count.get();
newValue = expected + 1;
} while (!count.compareAndSet(expected, newValue));
}
}
핵심 차이
| 메서드 | 반환값 | 실패 시 |
|---|---|---|
| compareAndSet | boolean | 성공/실패만 |
| compareAndExchange | 실제 값 | 현재 값 반환 |
compareAndSet: 성공 여부만 필요할 때
// 반환: boolean
boolean success = count.compareAndSet(5, 10);
if (success) { /* ... */ }
// 재시도 패턴
do {
expected = count.get(); // 매번 get() 호출!
newValue = expected + 1;
} while (!count.compareAndSet(expected, newValue));
compareAndExchange: 재시도 로직에 효율적 (Java 9+)
// 반환: 실제 값
int actual = count.compareAndExchange(5, 10);
if (actual == 5) { /* 성공 */ }
// 재시도 패턴 (더 빠름!)
int expected = count.get();
while (true) {
int actual = count.compareAndExchange(expected, expected + 1);
if (actual == expected) break;
expected = actual; // get() 불필요! 30% 빠름
}
선택 기준:
단순 체크? → compareAndSet
재시도 루프? → compareAndExchange (더 빠름!)
Java 8? → compareAndSet만 가능
| 기준 | Lock | CAS |
|---|---|---|
| 구현 | 간단 | 복잡 |
| 성능 (경쟁 적음) | 느림 | 빠름 ⚡ |
| 성능 (경쟁 많음) | 안정적 | 느려질 수 있음 |
| Deadlock | 가능 | 불가능 |
| 블로킹 | O | X |
| 적용 범위 | 여러 변수 | 단일 변수 |
public class LockCounter {
private int count = 0;
public synchronized void increment() {
count++;
}
}
// 특징: 블로킹, 컨텍스트 스위칭 오버헤드
public class CASCounter {
private AtomicInteger count = new AtomicInteger(0);
public void increment() {
count.incrementAndGet();
}
}
// 특징: 락 없음, 빠름 (경쟁 적을 때)
벤치마크 (1000 스레드, 각 1000번)
Lock: 500ms
CAS: 150ms ⚡ (3배 빠름)
1. Lock-Free
- 스레드 블로킹 없음
- Deadlock 불가능
2. 빠름 (경쟁 적을 때)
- 단일 CPU 명령어 수준
1. 재시도 오버헤드 (경쟁 많을 때)
- 100개 스레드 → 99개 실패 → 재시도 반복
2. ABA 문제
- A → B → A 변경 시 감지 못함
3. 단일 변수만 가능
- 여러 변수 동시 변경 불가
// 문제 상황
초기: count = 5
Thread 1:
1. read → expected = 5
2. [대기]
Thread 2:
3. count: 5 → 3
4. count: 3 → 5 (원래대로)
Thread 1:
5. CAS(5, 6) → 성공!
하지만 "다른 5"인데 성공함
해결: AtomicStampedReference (버전 추가)
AtomicStampedReference<Node> top =
new AtomicStampedReference<>(null, 0);
// 값과 버전 함께 비교
if (top.compareAndSet(oldNode, newNode, oldStamp, oldStamp + 1)) {
// 버전까지 일치해야 성공
}
AtomicInteger count = new AtomicInteger(0);
AtomicLong longCount = new AtomicLong(0L);
AtomicBoolean flag = new AtomicBoolean(false);
// 단일 참조
AtomicReference<User> userRef = new AtomicReference<>();
// 버전 포함 (ABA 해결)
AtomicStampedReference<User> stampedRef =
new AtomicStampedReference<>(user, 0);
AtomicIntegerArray array = new AtomicIntegerArray(10);
array.incrementAndGet(0); // array[0]++
AtomicReferenceArray<User> userArray =
new AtomicReferenceArray<>(10);
AtomicInteger/AtomicLong: 단일 변수에 CAS 적용
AtomicLong counter = new AtomicLong(0);
// 모든 스레드가 하나의 변수를 두고 경쟁
Thread 1: CAS(0, 1) → 성공
Thread 2: CAS(0, 1) → 실패 (이미 1) → 재시도
Thread 3: CAS(0, 1) → 실패 (이미 1) → 재시도
// 경쟁 많을수록 재시도 증가 → 느려짐
LongAdder: 여러 셀로 분산하여 경쟁 감소 (Java 8+)
LongAdder adder = new LongAdder();
// 동작 방식 (Adaptive)
1. 경쟁 없음: base 변수만 사용 (셀 0개)
2. 경쟁 발생: Cell[2] 생성 (2개)
3. 경쟁 심화: Cell[4] 확장 (4개)
4. 더 심화: Cell[8] 확장 (8개)
...
최대: CPU 코어 수까지 확장
// 왜 CPU 코어 수까지만?
CPU 4코어인 경우:
- 동시 실행 가능한 Thread = 최대 4개
- 셀 4개면 충분 (각 Thread가 다른 셀 사용)
- 셀 8개로 늘려도 의미 없음 (Thread는 4개만 동시 실행)
// Thread는 해시로 셀 선택
Thread 1 → hash(ThreadID) % 셀개수 → Cell[0]
Thread 2 → hash(ThreadID) % 셀개수 → Cell[1]
Thread 3 → hash(ThreadID) % 셀개수 → Cell[2]
// 예시: CPU 4코어, 셀 4개
base = 2
Cell[0] = 3 ← Thread 1, 5, 9, ...
Cell[1] = 5 ← Thread 2, 6, 10, ...
Cell[2] = 4 ← Thread 3, 7, 11, ...
Cell[3] = 1 ← Thread 4, 8, 12, ...
// 읽기: base + 모든 셀 합산
long sum = adder.sum(); // 2 + 3 + 5 + 4 + 1 = 15
핵심:
CPU 4코어:
동시 실행: [T1] [T2] [T3] [T4]
셀 4개: [C0] [C1] [C2] [C3] ← 딱 맞음!
셀 8개로 늘려도?
동시 실행: [T1] [T2] [T3] [T4] ← 여전히 4개만
셀 8개: [C0] [C1] [C2] [C3] [C4] [C5] [C6] [C7]
↑ 안 쓰임 (메모리 낭비)
비교
| 특징 | AtomicLong | LongAdder |
|---|---|---|
| 구조 | 단일 변수 | 여러 셀 배열 |
| 쓰기 (경쟁 적음) | 빠름 | 비슷 |
| 쓰기 (경쟁 많음) | 느림 | 빠름 ⚡ |
| 읽기 | 빠름 | 느림 (합산 필요) |
| 메모리 | 적음 | 많음 |
| 사용 | 자주 읽기 | 자주 쓰기 |
언제 사용?
// ✅ AtomicLong 사용
- 읽기가 많음 (빠른 get())
- 스레드 수 적음
- 메모리 제약
// ✅ LongAdder 사용
- 쓰기가 많음 (카운터, 통계)
- 스레드 수 많음 (높은 경쟁)
- 읽기는 가끔만 (배치로)
// 예시
public class Metrics {
private LongAdder requestCount = new LongAdder();
public void recordRequest() {
requestCount.increment(); // 빠름!
}
public long getTotal() {
return requestCount.sum(); // 가끔만 호출
}
}
Tip 1: LongAdder 사용
// 경쟁 많을 때
LongAdder counter = new LongAdder(); // 여러 셀로 분산
Tip 2: 읽기 먼저
// 빠른 읽기로 필터링
if (!flag.get() && flag.compareAndSet(false, true)) {
// ...
}
Tip 3: Backoff 전략
// 재시도 시 CPU 양보
if (retries > 0) {
Thread.yield();
}
✅ CAS 사용
- 단일 변수 (카운터, 플래그)
- 간단한 연산 (++, get/set)
- 경쟁 적음
- 높은 처리량 필요
✅ Lock 사용
- 여러 변수 동시 변경
- 복잡한 비즈니스 로직
- 경쟁 많음
- 순서 보장 필요
✅ 원리: 예상값 == 현재값 → 변경 (원자적)
✅ Java: AtomicInteger, AtomicLong, AtomicReference
✅ 장점: 빠름, Lock-Free, Deadlock 없음
❌ 단점: 재시도 오버헤드, ABA 문제, 단일 변수만
✅ 역할: 가시성 + 재배치 방지
❌ 한계: 원자성 보장 안 됨 (count++ 불가)
✅ 사용: 플래그, 상태 변수
✅ 결합: volatile + CAS = Atomic
✅ Set: boolean 반환, 단순 체크
✅ Exchange: 실제 값 반환, 재시도 30% 빠름 (Java 9+)
✅ 문제: A → B → A 변경 감지 못함
✅ 해결: AtomicStampedReference (버전 추가)
✅ CAS: 단일 변수, 경쟁 적음 → 빠름
✅ Lock: 여러 변수, 경쟁 많음 → 안정적
✅ 카운터: AtomicInteger, LongAdder
✅ 플래그: AtomicBoolean
✅ 참조: AtomicReference
✅ 고성능: LongAdder (경쟁 많을 때)