compare and set의 줄임말로, 원자적 연산이 아닌 연산을 원자적 연산으로 만들어 준다.
정확히는 cpu에서 보낸 cas 명령어를 통해 compareAndSet 메서드의 연산이 원자적 연산이 된다.
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.ThreadLocalRandom;
public class CompareAndSetExample {
private static AtomicInteger status = new AtomicInteger(0); // 0: 초기 상태, 1: 처리 중, 2: 완료
public static void main(String[] args) throws InterruptedException {
System.out.println("초기 상태: " + status.get());
ExecutorService executor = Executors.newFixedThreadPool(5); // 5개의 스레드 생성
for (int i = 0; i < 5; i++) {
final int threadId = i;
executor.submit(() -> {
try {
// 스레드 시작 시점에 랜덤 대기
Thread.sleep(ThreadLocalRandom.current().nextInt(100, 500));
System.out.println("스레드 " + threadId + ": 현재 상태 " + status.get() + ", 0이면 1로 변경 시도.");
// 현재 상태가 0(초기 상태)일 때만 1(처리 중)로 변경 시도
boolean success = status.compareAndSet(0, 1);
if (success) {
System.out.println("스레드 " + threadId + ": ✅ 상태를 0에서 1로 성공적으로 변경했습니다!");
// 상태가 1로 변경되었으므로, 실제 처리 작업 수행 (예시에서는 sleep)
Thread.sleep(ThreadLocalRandom.current().nextInt(500, 1000));
// 처리 완료 후 2(완료) 상태로 변경 (이것도 CAS로 처리할 수 있지만, 예시 단순화를 위해 set 사용)
// 실제 애플리케이션에서는 다시 CAS를 사용하여 다른 스레드의 간섭을 막을 수 있음
status.set(2);
System.out.println("스레드 " + threadId + ": 상태를 2(완료)로 변경했습니다.");
} else {
System.out.println("스레드 " + threadId + ": ❌ 상태를 0에서 1로 변경 실패. 이미 다른 스레드가 변경했거나 다른 상태입니다. 현재 상태: " + status.get());
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
System.out.println("스레드 " + threadId + " 인터럽트됨.");
}
});
}
executor.shutdown();
executor.awaitTermination(1, TimeUnit.MINUTES);
System.out.println("\n최종 상태: " + status.get());
System.out.println("예상 결과: 0이었던 상태가 1로 한 번 변경되고, 이후 2로 변경되어 최종적으로 2가 됨.");
}
Atomic연산이름.compareAndSet(expectedValue, updateValue)으로 사용하며, 현재값이 expectedValue인지 확인한 뒤 맞으면 true을 반환하고 updateValue 값을 바꾼다. 만약에 맞지 않으면 false를 반환한다.
이를 통해 락을 사용하지 않고도 안전하게 멀티 스레드 환경에서 값을 바꿀 수 있다.
incrementAndGet()이라는 Atomic 연산 메서드가 이 원리로 만들어 졌다.
public static int customIncrementAndGet() {
int oldValue;
int newValue;
boolean success;
do {
oldValue = atomicValue.get(); // 1. 현재 값(oldValue)을 읽습니다.
newValue = oldValue + 1; // 2. 새로운 값(newValue)을 계산합니다.
// 3. compareAndSet 시도:
// 현재 atomicValue가 oldValue와 같다면, newValue로 업데이트합니다.
// 성공하면 true, 실패하면 (다른 스레드가 이미 oldValue를 변경했다면) false를 반환합니다.
success = atomicValue.compareAndSet(oldValue, newValue);
// 4. 성공 여부 확인:
// 성공하지 않았다면 (success == false), 다시 루프를 반복하여 최신 값을 읽고 재시도합니다.
} while (!success); // success가 true가 될 때까지 (즉, CAS가 성공할 때까지) 반복합니다.
return newValue; // CAS 성공 후의 새로운 값을 반환합니다.
}
이처럼 do-while문과 compareAndSet을 통해 incrementAndGet을 만들어 낼 수 있다.
다른 원자적 연산들도 이러한 원리로 구성되어 있다.
어떻게 락 없이도 멀티 스레드 환경에서 값을 안전하게 변경시킬 수 있냐면
1. 현재 값을 확인한다.
2. 맞으면 값을 변경하고 ture를 반환해 do-while문을 빠져나온다.
3. 맞지 않을 경우 false를 반환하여 다시 do-while문을 돌린다.
계속 돌리면 결국에는 작업을 수행하게 되고 기대한 결과가 나오게 된다.
이러한 단점이 있으므로 간단한 cpu 사이클링에서만 사용해야 한다.
cas를 통해 lock을 구현할 수 있다.
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;
public class CASSpinLockExample {
// AtomicBoolean을 사용하여 락의 상태를 나타냅니다.
// true: 락이 잠겨 있음 (locked)
// false: 락이 해제됨 (unlocked)
private final AtomicBoolean locked = new AtomicBoolean(false);
// 공유 자원에 대한 접근을 스핀 락으로 보호할 정수 카운터
private int counter = 0;
/**
* 락을 획득하는 메서드.
* do-while 루프와 compareAndSet을 사용하여 락이 해제될 때까지 스핀합니다.
*/
public void lock() {
// locked.compareAndSet(false, true)
// 1. 현재 locked 값이 false(락 해제 상태)인지 확인합니다.
// 2. 만약 false이면 true(락 잠김 상태)로 변경합니다.
// 3. 이 모든 작업이 원자적으로 수행됩니다.
// 4. 성공하면 true 반환, 실패하면 false 반환.
// !locked.compareAndSet(false, true)는 CAS 실패 시 true가 되어 루프를 계속 돌게 합니다.
int spinCount = 0;
while (!locked.compareAndSet(false, true)) {
// 락을 획득하지 못했으므로 계속 스핀(바쁘게 대기)합니다.
// 실제 시스템에서는 CPU 낭비를 줄이기 위해 짧은 yield()나 Thread.onSpinWait()를 사용할 수 있습니다.
// Thread.onSpinWait()는 Java 9에 추가되었으며, CPU에게 현재 스레드가 스핀 중임을 알려줍니다.
// Thread.onSpinWait(); // Java 9 이상에서 사용 가능
spinCount++;
}
// System.out.println(Thread.currentThread().getName() + " acquired lock after " + spinCount + " spins.");
}
/**
* 락을 해제하는 메서드.
* 락 상태를 false로 설정하여 다른 스레드가 획득할 수 있도록 합니다.
*/
public void unlock() {
// 락을 해제할 때는 단순히 locked 상태를 false로 변경합니다.
// 락을 획득한 스레드만 unlock을 호출해야 합니다 (이 구현에서는 강제하지 않음).
locked.set(false);
}
/**
* 스핀 락으로 보호되는 공유 자원 접근 메서드.
*/
public void increment() {
lock(); // 락 획득 시도 (스핀)
try {
// 락 획득 성공 후 임계 영역 (Critical Section)
// 여기서는 공유 자원인 counter를 증가시킵니다.
counter++;
// System.out.println(Thread.currentThread().getName() + " incremented counter to " + counter);
// 실제 작업 부하를 시뮬레이션하기 위한 짧은 대기
// Thread.sleep(ThreadLocalRandom.current().nextInt(1, 5));
} finally {
unlock(); // 락 해제 (매우 중요!)
}
}
public int getCounter() {
return counter;
}
public static void main(String[] args) throws InterruptedException {
CASSpinLockExample spinLock = new CASSpinLockExample();
final int NUM_THREADS = 10; // 스레드 개수
final int INCREMENTS_PER_THREAD = 10000; // 각 스레드가 증가시킬 횟수
ExecutorService executor = Executors.newFixedThreadPool(NUM_THREADS);
long startTime = System.nanoTime();
for (int i = 0; i < NUM_THREADS; i++) {
executor.submit(() -> {
for (int j = 0; j < INCREMENTS_PER_THREAD; j++) {
spinLock.increment();
}
});
}
executor.shutdown();
executor.awaitTermination(1, TimeUnit.MINUTES);
long endTime = System.nanoTime();
double durationMs = (endTime - startTime) / 1_000_000.0;
System.out.println("기대하는 최종 카운터 값: " + (NUM_THREADS * INCREMENTS_PER_THREAD));
System.out.println("스핀 락을 사용한 최종 카운터 값: " + spinLock.getCounter());
System.out.printf("총 실행 시간: %.2f ms\n", durationMs);
System.out.println("=========================================");
// 비교를 위한 AtomicInteger 사용 예제 (더 단순하고 일반적)
AtomicInteger atomicCounter = new AtomicInteger(0);
ExecutorService atomicExecutor = Executors.newFixedThreadPool(NUM_THREADS);
long atomicStartTime = System.nanoTime();
for (int i = 0; i < NUM_THREADS; i++) {
atomicExecutor.submit(() -> {
for (int j = 0; j < INCREMENTS_PER_THREAD; j++) {
atomicCounter.incrementAndGet();
}
});
}
atomicExecutor.shutdown();
atomicExecutor.awaitTermination(1, TimeUnit.MINUTES);
long atomicEndTime = System.nanoTime();
double atomicDurationMs = (atomicEndTime - atomicStartTime) / 1_000_000.0;
System.out.println("AtomicInteger를 사용한 최종 카운터 값: " + atomicCounter.get());
System.out.printf("AtomicInteger 총 실행 시간: %.2f ms\n", atomicDurationMs);
}
}
다만 이럴 경우에도 lock을 획득할 때까지 대기하지 않아 성능은 향상되겠지만, 얻을 때까지 cpu을 사용하기 때문에 오버헤드가 발생할 수 있다.
따라서 대부분 경우 락이나 동기화를 사용하고 간단한 연산을 할 때만 cas 연산을 사용하는 게 좋다.