원자적 연산(Atomic Operation)
예제 분석
volatile int i = 0;
i = 1; // ✅ 원자적 연산
i = i + 1; // ❌ 원자적 연산이 아님
i의 값이 덮어써질 수 있습니다.예제 시나리오: 멀티스레드 문제
순차 실행 (문제 없음)
i = 0
Thread-1: i = i + 1 → i = 1
Thread-2: i = i + 1 → i = 2
i = 2동시 실행 (문제 발생)
i = 0
Thread-1: 읽음(i=0), +1, 대입(i=1)
Thread-2: 읽음(i=0), +1, 대입(i=1)
i=0을 읽고, 동시에 i=1로 덮어써서 증가가 무시됩니다.i++도 원자적이지 않음
i++는 실제로 i = i + 1과 동일합니다.핵심 요약
| 항목 | 원자적? | 멀티스레드 안전성 |
|---|---|---|
i = 1 | ✅ | 안전함 |
i = i + 1 / i++ | ❌ | 충돌 발생 가능 |
목표: 멀티스레드 환경에서 원자적이지 않은 연산이 어떻게 문제가 되는지 실습을 통해 확인합니다.
사용된 인터페이스
package thread.cas.increment;
public interface IncrementInteger {
void increment(); // 값을 1 증가
int get(); // 현재 값을 조회
}
구현 1: BasicInteger
package thread.cas.increment;
public class BasicInteger implements IncrementInteger {
private int value;
@Override
public void increment() {
value++; // ❌ 원자적이지 않음
}
@Override
public int get() {
return value;
}
}
value++는 여러 스레드가 동시에 접근하면 문제 발생합니다.테스트 코드
package thread.cas.increment;
import static util.ThreadUtils.sleep;
import java.util.ArrayList;
import java.util.List;
public class IncrementThreadMain {
public static final int THREAD_COUNT = 1000;
public static void main(String[] args) throws InterruptedException {
test(new BasicInteger());
}
private static void test(IncrementInteger incrementInteger) throws InterruptedException {
Runnable runnable = () -> {
ThreadUtils.sleep(10); // 스레드 동시 실행 유도
incrementInteger.increment();
};
List<Thread> threads = new ArrayList<>();
for (int i = 0; i < THREAD_COUNT; i++) {
Thread thread = new Thread(runnable);
threads.add(thread);
thread.start();
}
for (Thread thread : threads) {
thread.join(); // 모든 스레드 종료 대기
}
int result = incrementInteger.get();
System.out.println(incrementInteger.getClass().getSimpleName() + " result: " + result);
}
}
테스트 포인트
THREAD_COUNT = 1000value++는 1000번 실행되어야 하므로 결과는 1000이어야 정상참고로 스레드가 너무 빨리 실행되기 때문에, 여러 스레드가 동시에 실행되는 상황을 확인하기 어렵습니다. 그래서 run() 메서드에 sleep(10) 을 두어서, 최대한 많은 스레드가 동시에 increment() 를 호출하도록 합니다.
실행 결과 예시 (환경마다 다를 수 있음)
BasicInteger result: 950
value++을 동시에 수행하는데, 중간에 덮어쓰기 발생합니다.핵심 요약
| 항목 | 설명 |
|---|---|
value++의 문제 | 읽기 → 계산 → 쓰기 3단계로 분리됨. 중간에 다른 스레드가 개입할 수 있음 |
| 실험 목적 | 1000개의 스레드가 동시에 increment() 호출 시 충돌 발생 확인 |
| 결론 | 원자적 연산이 아니므로 멀티스레드 환경에서 데이터 유실 발생 가능 |
이 문제는 앞서 설명한 것 처럼 여러 스레드가 동시에 원자적이지 않은 value++ 을 호출했기 때문에 발생했습니다. 그럼 혹시 volatile 을 적용하면 될까요?
volatile, synchronized이 파트에서는 value++ 문제를 해결하려고
volatile을 사용하면 괜찮을까?synchronized는 어떤 차이가 있을까?를 실험적으로 확인합니다.
1. volatile 실험
클래스: VolatileInteger
package thread.cas.increment;
public class VolatileInteger implements IncrementInteger {
volatile private int value; // volatile 적용
@Override
public void increment() {
value++; // ❌ 여전히 원자적이지 않음
}
@Override
public int get() {
return value;
}
}
volatile은 메인 메모리와 CPU 캐시 간 일관성은 보장하지만, ++ 자체를 원자적으로 만들어주지는 않습니다.
결과 비교
BasicInteger result: 950
VolatileInteger result: 961
VolatileInteger도 1000 미만정리
| 키워드 | 보장 |
|---|---|
volatile | 읽기/쓰기의 메모리 일관성 O, 원자성 X |
value++ | 여전히 쪼개질 수 있음 |
2. synchronized 실험
클래스: SyncInteger
public class SyncInteger implements IncrementInteger {
private int value;
@Override
public synchronized void increment() {
value++;
}
@Override
public synchronized int get() {
return value;
}
}
increment() 메서드를 임계영역으로 보호합니다.실행 결과
SyncInteger result: 1000
핵심 요약
| 클래스 | 원자성 보장? | 설명 |
|---|---|---|
BasicInteger | ❌ | 단순 증가, 동기화 없음 |
VolatileInteger | ❌ | 메모리 일관성은 있으나 증가 연산은 쪼개짐 |
SyncInteger | ✅ | synchronized로 임계 영역 보호 |
보충 설명: volatile과 synchronized의 비교
| 항목 | volatile | synchronized |
|---|---|---|
| 용도 | 메모리 일관성 보장 | 임계 영역 보호 (원자성 보장) |
| 원자성 | ❌ | ✅ |
| 멀티스레드 안전 | ❌ | ✅ |
| 성능 | 가볍지만 불완전 | 무거우나 안전 |
AtomicInteger 목적
synchronized보다 더 빠르고 안전한 방식으로 동시성 문제를 해결합니다.클래스: MyAtomicInteger
public class MyAtomicInteger implements IncrementInteger {
private final AtomicInteger atomicInteger = new AtomicInteger(0);
@Override
public void increment() {
atomicInteger.incrementAndGet(); // 원자적 증가
}
@Override
public int get() {
return atomicInteger.get();
}
}
AtomicInteger는 내부적으로 CAS (Compare-And-Swap)를 사용해 락 없이 원자성을 보장합니다.incrementAndGet():테스트 추가
package thread.cas.increment;
import static util.ThreadUtils.sleep;
import java.util.ArrayList;
import java.util.List;
public class IncrementThreadMain {
public static final int THREAD_COUNT = 1000;
public static void main(String[] args) throws InterruptedException {
test(new BasicInteger());
test(new VolatileInteger());
test(new SyncInteger());
test(new MyAtomicInteger());
}
private static void test(IncrementInteger incrementInteger) throws InterruptedException {
Runnable runnable = new Runnable() {
@Override
public void run() {
sleep(10); // 너무 빨리 실행되기 때문에, 다른 스레드와 동시 실행을 위해 잠깐 쉬었다가 실행
incrementInteger.increment();
}
};
List<Thread> threads = new ArrayList<>();
for (int i = 0; i < THREAD_COUNT; i++) {
Thread thread = new Thread(runnable);
threads.add(thread);
thread.start();
}
for (Thread thread : threads) {
thread.join();
}
int result = incrementInteger.get();
System.out.println(incrementInteger.getClass().getSimpleName() + " result: " + result);
}
}
실행 결과
BasicInteger result: 950
VolatileInteger result: 961
SyncInteger result: 1000
MyAtomicInteger result: 1000
AtomicInteger는 SyncInteger처럼 정확한 결과를 보장합니다.AtomicInteger의 장점
| 항목 | 설명 |
|---|---|
| 락 사용 여부 | ❌ (Lock-Free) |
| 원자성 보장 | ✅ CAS 연산 기반 |
| 성능 | ✅ synchronized보다 빠름 |
| 실무 활용도 | ✅ 매우 높음 (ex: 카운터, 통계 등) |
핵심 요약
AtomicInteger는 멀티스레드 환경에서 성능과 안전성 모두 확보java.util.concurrent.atomic 패키지에는 다양한 클래스 존재:AtomicInteger, AtomicLong, AtomicBoolean 등성능 테스트 목적
value++ 연산을 다양한 방식(Basic, volatile, synchronized, AtomicInteger)으로 반복했을 때,테스트 코드: IncrementPerformanceMain
package thread.cas.increment;
public class IncrementPerformanceMain {
public static final long COUNT = 100_000_000; // 1 억 번 연산
public static void main(String[] args) {
test(new BasicInteger());
test(new VolatileInteger());
test(new SyncInteger());
test(new MyAtomicInteger());
}
private static void test(IncrementInteger incrementInteger) {
long startMs = System.currentTimeMillis();
for (long i = 0; i < COUNT; i++) {
incrementInteger.increment();
}
long endMs = System.currentTimeMillis();
System.out.println(incrementInteger.getClass().getSimpleName() + ": ms=" + (endMs - startMs));
}
}
value++을 수행하여 소요 시간을 측정합니다.실행 결과
BasicInteger: ms=39
VolatileInteger: ms=455
SyncInteger: ms=625
MyAtomicInteger: ms=367
※ 실행 환경에 따라 숫자는 다를 수 있으나, 패턴은 거의 동일합니다.
성능 비교 해석
| 클래스 | 시간(ms) | 특징 및 해석 |
|---|---|---|
BasicInteger | ✅ 39 | 가장 빠름 캐시 메모리 적극 활용, 동기화 없음 → 멀티스레드에서는 안전하지 않음 |
VolatileInteger | ❌ 455 | 메모리 일관성만 보장. 캐시 무시하고 항상 메인 메모리 사용 → 성능 저하, 원자성 미보장 |
SyncInteger | ❌❌ 625 | synchronized로 락을 획득/반납하면서 실행 → 성능 저하 발생 |
MyAtomicInteger | ✅✅ 367 | 락 없이 원자성 확보 성능도 괜찮음. 실무에서 자주 사용됨 |
핵심 요약
| 방식 | 멀티스레드 안전 | 성능 (단일 스레드) | 특징 |
|---|---|---|---|
BasicInteger | ❌ | ⭐ 매우 빠름 | 캐시 최적화만 활용 |
VolatileInteger | ❌ | ❌ 느림 | 메모리 일관성만 보장 |
SyncInteger | ✅ | ❌❌ 가장 느림 | 락 기반 |
AtomicInteger | ✅ | ✅ 빠름 | 락 없는 원자성 (CAS 기반) |
보충: AtomicInteger가 왜 빠른가?
value++는 원자적 연산이 아니므로 원래는 락이 필요합니다.SyncInteger는 synchronized를 통해 매번 락 획득과 반납합니다. → 무거움AtomicInteger는 CPU의 CAS 명령어를 활용하여 락 없이 원자성 확보합니다.결론
AtomicInteger가 가장 실용적입니다.value++처럼 간단한 연산에서는 락 없이 CAS 기반의 연산이 유리합니다.목표: AtomicInteger가 내부에서 사용하는 CAS(Compare-And-Swap) 연산이 정확히 어떤 방식으로 동작하는지를 실습을 통해 이해하는 것이 목표입니다.
핵심 개념 요약
CAS(Compare-And-Swap) 는 "현재 메모리에 있는 값이 내가 기대한 값이면, 새 값으로 바꿔!" 라는 논리입니다.
boolean compareAndSet(int expected, int update)
expected: 내가 기대하는 현재 값update: 바꾸고 싶은 새 값내부적으로는 CPU가 제공하는 명령어로 동작합니다. (→ 원자성 보장)
코드 실습: CasMainV1
package thread.cas;
import java.util.concurrent.atomic.AtomicInteger;
public class CasMainV1 {
public static void main(String[] args) {
AtomicInteger atomicInteger = new AtomicInteger(0);
System.out.println("start value = " + atomicInteger.get());
boolean result1 = atomicInteger.compareAndSet(0, 1);
System.out.println("result1 = " + result1 + ", value = " + atomicInteger.get()); // 1
boolean result2 = atomicInteger.compareAndSet(0, 1);
System.out.println("result2 = " + result2 + ", value = " + atomicInteger.get());
}
}
start value = 0
result1 = true, value = 1
result2 = false, value = 1
실행 흐름 분석
compareAndSet(0, 1) → 현재 값이 0이므로 1로 변경. true 반환합니다.compareAndSet(0, 1) → 현재 값은 1이라 기대한 값과 다릅니다. → false 반환, 값은 그대로!왜 이게 "원자적"인가?
비교(compare)와 교체(swap) 두 연산이 CPU 단에서 하나의 명령어로 실행됩니다.CMPXCHG, LOCK CMPXCHG 등의 CAS 명령어를 지원합니다.성능 측면의 장점
핵심 요약
| 항목 | 설명 |
|---|---|
| CAS | 기대값과 현재값이 같을 경우에만 새 값으로 교체 |
| compareAndSet | 자바에서 제공하는 CAS 연산 메서드 |
| 원자성 | CPU 차원에서 보장 |
| 실무 사용 | AtomicInteger, AtomicLong 등에서 사용됨 |
| 장점 | 락 없이 빠르고 안전한 동기화 |
| 한계 | 충돌 시 반복 재시도 필요 (→ 다음 파트에서 다룸) |
incrementAndGet() 직접 구현해보기목표: AtomicInteger.incrementAndGet() 메서드가 내부적으로 CAS를 활용해 어떻게 원자적 증가를 구현하는지 직접 따라 만들어 봅니다.
왜 i++는 원자적이지 않은가?
i++은 내부적으로 다음처럼 나뉩니다/
i = i + 1; // 이건 3단계로 나뉨
1. i 읽기
2. i + 1 계산
3. 결과를 다시 i에 저장
→ 중간에 다른 스레드가 i를 바꿔버릴 수 있습니다.
CAS 기반 incrementAndGet() 직접 구현
package thread.cas;
import java.util.concurrent.atomic.AtomicInteger;
import static util.MyLogger.log;
public class CasMainV2 {
public static void main(String[] args) {
AtomicInteger atomicInteger = new AtomicInteger(0);
System.out.println("start value = " + atomicInteger.get());
// incrementAndGet 구현
int resultValue1 = incrementAndGet(atomicInteger);
System.out.println("resultValue1 = " + resultValue1);
int resultValue2 = incrementAndGet(atomicInteger);
System.out.println("resultValue2 = " + resultValue2);
}
private static int incrementAndGet(AtomicInteger atomicInteger) {
int getValue;
boolean result;
do {
getValue = atomicInteger.get(); // 1. 현재 값 읽기
log("getValue: " + getValue);
result = atomicInteger.compareAndSet(getValue, getValue + 1); // 2. 기대값 → 새로운 값 CAS 시도
log("result: " + result);
} while (!result); // 3. 실패하면 반복 재시도
return getValue + 1;
}
}
compareAndSet() 실패 시 do-while 반복해서 재시도합니다.AtomicInteger.incrementAndGet()이 내부에서 이렇게 작동합니다.실행 예
AtomicInteger atomicInteger = new AtomicInteger(0);
System.out.println("start value = " + atomicInteger.get());
// incrementAndGet 구현
int resultValue1 = incrementAndGet(atomicInteger);
System.out.println("resultValue1 = " + resultValue1);
int resultValue2 = incrementAndGet(atomicInteger);
System.out.println("resultValue2 = " + resultValue2);
start value = 0
15:41:00.228 [ main] getValue: 0
15:41:00.229 [ main] result: true
resultValue1 = 1
15:41:00.229 [ main] getValue: 1
15:41:00.229 [ main] result: true
resultValue2 = 2
핵심 흐름 정리
| 단계 | 설명 |
|---|---|
| ① 읽기 | 현재 값 읽기 (get()) |
| ② 비교 후 교체 | compareAndSet(기대한값, 새로운값) |
| ③ 실패 시 재시도 | 다른 스레드가 먼저 바꿨다면 다시 처음부터 |
주의할 점
정리 요약
| 항목 | 내용 |
|---|---|
i++ 문제점 | 3단계 연산, 원자성 없음 |
| CAS 기반 증가 방식 | compareAndSet()으로 현재 값이 바뀌지 않았을 때만 교체 |
| 실패 시 처리 | 루프 재시도 |
실제 AtomicInteger | 내부적으로 이렇게 구현되어 있음 |
목표: 멀티스레드 환경에서 compareAndSet()이 실패할 수 있는 상황을 만들고, 재시도 루프가 어떻게 충돌을 해결하는지 확인하는 실습입니다.
실습 코드: CasMainV3
package thread.cas;
import static util.MyLogger.log;
import static util.ThreadUtils.sleep;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
public class CasMainV3 {
private static final int THREAD_COUNT = 2;
public static void main(String[] args) throws InterruptedException {
AtomicInteger atomicInteger = new AtomicInteger(0);
System.out.println("start value = " + atomicInteger.get());
Runnable runnable = new Runnable() {
@Override
public void run() {
incrementAndGet(atomicInteger);
}
};
List<Thread> threads = new ArrayList<>();
for (int i = 0; i < THREAD_COUNT; i++) {
Thread thread = new Thread(runnable);
threads.add(thread);
thread.start(); // 스레드 실행
}
for (Thread thread : threads) {
thread.join(); // 스레드 종료까지 대기
}
int result = atomicInteger.get();
System.out.println(atomicInteger.getClass().getSimpleName() + " resultValue: " + result);
}
private static int incrementAndGet(AtomicInteger atomicInteger) {
int getValue;
boolean result;
do {
getValue = atomicInteger.get(); // ① 현재 값 읽기
sleep(100); // 스레드 동시 실행을 위한 대기
log("getValue: " + getValue);
result = atomicInteger.compareAndSet(getValue, getValue + 1); // ③ CAS 시도
log("result: " + result);
} while (!result); // ④ 실패하면 재시도
return getValue + 1;
}
}
실행 시나리오: 2개의 스레드가 동시에 값 증가 시도
start value = 0
15:44:34.469 [ Thread-0] getValue: 0
15:44:34.469 [ Thread-1] getValue: 0
15:44:34.470 [ Thread-0] result: true
15:44:34.470 [ Thread-1] result: false
15:44:34.581 [ Thread-1] getValue: 1
15:44:34.581 [ Thread-1] result: true
AtomicInteger resultValue: 2
흐름 분석
Thread-1
get() → 값 0 읽음compareAndSet(0, 1) 성공 → 값 1로 변경true, 루프 탈출Thread-0
get() → 값 0 읽음compareAndSet(0, 1) → 실패 (expected와 다름)get() → 1 읽고 compareAndSet(1, 2) 시도 → 성공핵심 동작 요약
| 단계 | 설명 |
|---|---|
| 동시에 값을 읽음 | 두 스레드가 같은 값을 읽음 (0) |
| 한 쪽이 먼저 갱신 | Thread-1이 먼저 1로 바꿈 |
| 다른 스레드는 실패 후 재시도 | Thread-0은 실패하고 루프 다시 실행 |
| 최종적으로 둘 다 증가 성공 | 결과 값: 2 |
핵심 요약
| 항목 | 설명 |
|---|---|
| 충돌 처리 방식 | 실패 시 루프 재시도 (낙관적 락) |
| 데이터 손실 여부 없음 | CAS 실패 → 루프에서 다시 시도 |
| 멀티스레드 환경에서도 안전 | AtomicInteger는 충돌에도 올바르게 처리함 |
결론
AtomicInteger가 락 없이도 안전하게 작동하는 이유합니다.질문: for (Thread thread : threads) thread.join(); 는 왜 필요한가?
thread.join()은 해당 스레드가 끝날 때까지 현재 스레드를 멈추는 것입니다.main() 스레드가 각 작업 스레드가 종료될 때까지 기다리는 것이죠.코드 흐름을 다시 보면:
for (int i = 0; i < THREAD_COUNT; i++) {
Thread thread = new Thread(runnable);
threads.add(thread);
thread.start(); // 스레드 실행
}
for (Thread thread : threads) {
thread.join(); // 스레드 종료까지 대기
}
→ thread.start()로 실행된 스레드들은 비동기적으로 동작합니다. 즉, main() 메서드는 다른 스레드들이 끝나기도 전에 다음 줄로 넘어갈 수 있습니다.
그리고 다음 줄은 바로 이겁니다:
int result = atomicInteger.get();
System.out.println("resultValue: " + result);
만약 join()을 생략하면?
main() 스레드가 result = atomicInteger.get()을 호출할 때, 두 스레드 중 하나 혹은 둘 다 아직 증가 작업을 안 했을 수 있습니다.
→ 출력값이 2가 아니라 0이나 1일 수 있습니다. (논리적 오류 발생)
결론
| 목적 | 설명 |
|---|---|
join() 사용 이유 | 모든 스레드가 작업을 완료할 때까지 대기 |
| 안 쓰면 발생하는 문제 | 결과를 아직 다 더하지도 않았는데 main()이 먼저 값을 읽어버릴 수 있음 |
| 특히 중요할 때 | 테스트, 동시성 실험, 정확한 결과 측정 시 매우 중요 |
목표: synchronized 없이 직접 락을 만들어보되, CAS 없이 잘못 구현한 락의 문제점을 먼저 실습을 통해 경험합니다.
잘못된 락 구현: SpinLockBad
package thread.cas.spinlock;
import static util.MyLogger.log;
import static util.ThreadUtils.sleep;
public class SpinLockBad {
private volatile boolean lock = false;
public void lock() {
log("락 획득 시도");
while (true) {
if (!lock) { // 1. 락 사용 여부 확인
sleep(100); // 문제 상황 확인용, 스레드 대기
lock = true; // 2. 락의 값 변경
break;
} else {
// 락을 획득할 때 까지 스핀 대기(바쁜 대기) 한다.
log("락 획득 실패 - 스핀 대기");
}
}
log("락 획득 완료");
}
public void unlock() {
lock = false;
log("락 반납 완료");
}
}
문제점 핵심
if (!lock) {
lock = true;
}
lock = true를 실행하게 되면 두 스레드가 동시에 락을 획득해버립니다!참고로 락을 반납하는 다음 연산은 연산이 하나인 원자적인 연산입니다. 따라서 이 부분은 여러 스레드가 함께 실행해도 문제가 발생하지 않습니다.
public void unlock() {
lock = false; // 원자적인 연산
log("락 반납 완료");
}
실행 시나리오
package thread.cas.spinlock;
import static util.MyLogger.log;
import static util.ThreadUtils.sleep;
public class SpinLockMain {
public static void main(String[] args) {
SpinLockBad spinLock = new SpinLockBad();
// SpinLock spinLock = new SpinLock();
Runnable task = new Runnable() {
@Override
public void run() {
spinLock.lock();
try {
// critical section
log("비즈니스 로직 실행");
sleep(1); // 오래 걸리는 로직에서 스핀 락 사용X
} finally {
spinLock.unlock();
}
}
};
Thread t1 = new Thread(task, "Thread-1");
Thread t2 = new Thread(task, "Thread-2");
t1.start();
t2.start();
}
}
15:52:14.807 [ Thread-1] 락 획득 시도
15:52:14.807 [ Thread-2] 락 획득 시도
15:52:14.918 [ Thread-1] 락 획득 완료
15:52:14.918 [ Thread-2] 락 획득 완료
15:52:14.918 [ Thread-1] 비즈니스 로직 실행
15:52:14.918 [ Thread-2] 비즈니스 로직 실행
15:52:14.921 [ Thread-1] 락 반납 완료
15:52:14.921 [ Thread-2] 락 반납 완료
sleep(100)으로 타이밍을 일부러 벌려도 실행 타이밍은 겹칩니다.실행 결과를 보면 기대와는 다르게 Thread-1, Thread-2 둘다 동시에 락을 획득하고 비즈니스 로직을 동시에 수행해버립니다.
핵심 요약
| 항목 | 설명 |
|---|---|
| 문제 원인 | if (!lock) { lock = true; }는 원자적이지 않음 |
| 발생 가능한 문제 | 여러 스레드가 동시에 임계 영역을 실행 |
| volatile 사용 의미 | 캐시 일관성은 확보되지만 원자성 보장 못 함 |
| 해결 방향 (다음 파트 예고) | → CAS를 통해 "확인 + 변경"을 원자적으로 수행해야 함 |
compareAndSet()으로 안전한 락 구현목표: synchronized 없이, CAS를 활용하여 안전하고 원자적인 락(Spin Lock)을 직접 구현합니다.
코드: SpinLock
package thread.cas.spinlock;
import static util.MyLogger.log;
import java.util.concurrent.atomic.AtomicBoolean;
public class SpinLock {
private final AtomicBoolean lock = new AtomicBoolean(false);
public void lock() {
log("락 획득 시도");
while (!lock.compareAndSet(false, true)) {
// 락을 획득할 때 까지 스핀 대기(바쁜 대기) 한다.
log("락 획득 실패 - 스핀 대기");
}
log("락 획득 완료");
}
public void unlock() {
lock.set(false);
log("락 반납 완료");
}
}
핵심 원리: CAS를 통한 락 획득
while (!lock.compareAndSet(false, true))
false이면 → true로 바꾸고 락 획득true이면 → 다른 스레드가 이미 락을 점유 중 → 스핀 대기문제 해결됨
기존 (SpinLockBad) | 개선 (SpinLock) |
|---|---|
if (!lock) { lock = true; }는 원자성 없음 | compareAndSet(false, true)는 원자적 |
| 동시에 여러 스레드가 락 획득 가능 | 한 번에 하나만 가능 |
| 임계 영역 침범 가능성 존재 | 침범 불가, 충돌 시 재시도 |
실행 결과 예시
15:57:37.727 [ Thread-1] 락 획득 시도
15:57:37.727 [ Thread-2] 락 획득 시도
15:57:37.728 [ Thread-1] 락 획득 완료
15:57:37.728 [ Thread-2] 락 획득 실패 - 스핀 대기
15:57:37.728 [ Thread-1] 비즈니스 로직 실행
15:57:37.729 [ Thread-2] 락 획득 실패 - 스핀 대기
15:57:37.729 [ Thread-2] 락 획득 실패 - 스핀 대기
...
15:57:37.732 [ Thread-2] 락 획득 실패 - 스핀 대기
15:57:37.732 [ Thread-2] 락 획득 실패 - 스핀 대기
15:57:37.733 [ Thread-2] 락 획득 완료
15:57:37.733 [ Thread-1] 락 반납 완료
15:57:37.733 [ Thread-2] 비즈니스 로직 실행
15:57:37.735 [ Thread-2] 락 반납 완료
핵심 요약
| 항목 | 설명 |
|---|---|
| CAS 기반 락 핵심 | compareAndSet(false, true)로 원자적 확인+변경 |
| 스핀 락(Spin Lock) 정의 | 락을 획득할 때까지 반복적으로 시도하는 구조 |
| unlock 처리 | lock.set(false)는 단일 원자적 연산이므로 안전 |
| 효율성 | 아주 짧은 임계 영역에 적합, 긴 작업에는 비효율적 (다음에서 설명) |
실무에서 언제 쓸까?
CAS 기반 스핀 락은 다음과 같은 상황에서 적합합니다:
경고: CPU 자원 과다 사용 가능성
→ 이 단점은 곧바로 다음 섹션에서 실험으로 보여줍니다.
실험: 스핀 락의 단점 노출
public void run() {
spinLock.lock();
try {
log("비즈니스 로직 실행");
sleep(1); // ❗ 오래 걸리는 로직 (예: DB 처리)
} finally {
spinLock.unlock();
}
}
실행 결과
문제점: 바쁜 대기(busy-wait)
| 상황 | 설명 |
|---|---|
Thread-1이 락 보유 중 | Thread-2는 while 반복 수행 |
| 락이 해제될 때까지 | CPU를 계속 사용하며 대기 |
| 문제점 | CPU 리소스 낭비, 다른 작업 방해 가능 |
전통적인 동기화 락(synchronized, ReentrantLock)의 경우
락을 기다리는 스레드는 BLOCKED 또는 WAITING 상태로 진입합니다.
→ CPU는 거의 소모하지 않습니다.
→ 대신 락을 다시 획득할 때 컨텍스트 스위칭 비용이 발생합니다.
비교 정리: CAS vs 동기화 락
| 항목 | CAS (스핀 락) | 동기화 락 (synchronized, Lock) |
|---|---|---|
| 접근 방식 | 낙관적 (충돌 없을 거라고 가정) | 비관적 (충돌 날 거라고 가정) |
| 락 대기 방식 | 반복 루프 → CPU 사용함 | 대기 상태 → CPU 사용 안 함 |
| 스레드 상태 | RUNNABLE (계속 도는 중) | BLOCKED 또는 WAITING |
| 컨텍스트 스위칭 비용 | 없음 (루프 유지) | 있음 (상태 변경) |
| 충돌이 드물 경우 | ⭐ 매우 빠름 | 느림 |
| 충돌이 많을 경우 | ❗ 루프 많아지고 CPU 소모 커짐 | 안정적이나 느림 |
| 락의 정밀 제어 | X (간단한 값 변경에 적합) | O (복잡한 상황 처리 가능) |
언제 CAS가 적합할까?
| 상황 | 적합도 |
|---|---|
| 짧은 연산 (숫자 증가 등) | ✅ 매우 적합 |
| 충돌 가능성이 거의 없음 | ✅ 적합 |
| CPU 자원이 여유로움 | ✅ 적합 |
| 긴 로직, 외부 IO 대기 등 | ❌ 부적합 |
| 고빈도 충돌 예상 | ❌ 부적합 |
실무 핵심 요약
실무에서의 전략
실무 예시: 주문 수 카운트 증가
// 1초에 수백 건 들어오더라도, 대부분 충돌 없이 끝나기 때문에...
AtomicInteger orderCount = new AtomicInteger(0);
orderCount.incrementAndGet(); // 락 없이 빠르고 안전
→ 이런 경우 CAS가 매우 효율적입니다.
최종 요약
| 정리 항목 | 한 줄 요약 |
|---|---|
i++ 문제 | 원자성이 없고 충돌 가능 |
volatile | 캐시 일관성 보장, 원자성은 X |
synchronized | 안전하지만 느림 |
AtomicInteger | CAS 기반, 락 없이 원자성 보장 |
compareAndSet() | CAS 핵심 연산: 기대값 같을 때만 바꿈 |
| 스핀 락 | 락 획득할 때까지 루프 → CPU 소비 많음 |
| CAS vs 락 선택 기준 | 짧은 연산 + 낮은 충돌 시 CAS, 그 외에는 락 |