Java 애플리케이션을 만들다보면, 멀티스레드 환경에서 공유 변수를 안전하게 다뤄야하는 순간이 있습니다. 가장 단순한 예를 들어보면 아래와 같습니다. 여러 스레드가 동시에 숫자를 증가시키는 코드인데요.
class Counter {
private int count = 0;
public void increment() {
count++; // 안전하지 않음!
}
public int getCount() {
return count;
}
}
겉보기에는 문제가 없어보이지만, 실제로 여러 스레드가 동시에 실행하면 기대한 값보다 훨씬 작은 수가. 출력됩니다. 그 이유는 count++라는 연산이 사실상 세 단계(읽기 → 더하기 → 쓰기)로 나뉘어져 있기 때문입니다. 이 세 단계 사이에 다른 스레드가 끼어들면 값이 꼬이는 것이죠.
이 문제를 해결하기 위해 쉽게 떠올릴 수 있는 방법은 Lock을 사용하는 것입니다. Java에서는 synchronized 키워드나 ReentrantLock 같은 라이브러리를 통해 쉽게 쓸 수 있습니다.
class Counter {
private int count = 0;
public synchronized void increment() {
count++;
}
}
이제 멀티 스레드 환경에서도 안전하지만 Lock 방식은 단점이 존재합니다. 여러 스레드가 경쟁하면 대기열에 줄을 서야 하고, 컨텍스트 스위칭 비용이 늘어나며, 그 결과 latency가 커집니다.
여기서 등장하는 개념이 바로 CAS 입니다. CAS는 CPU가 제공하는 원자적 명령으로 다음과 같은 성격을 가집니다.
<aside> 💡
It compares the contents of a memory location with a given (the previous) value and, only if they are the same, modifies the contents of that memory location to a new given value. [Wikipedia, Compare-and-swap]
</aside>
→ 메모리 위치의 값이 기대한 값과 같으면 새값으로 교체한다. 아니면 아무것도 하지 않는다.
Java에서는 이런 CAS를 직접 CPU 명령어로 다룰 필요 없이 Atomic 클래스를 통해 간단히 사용할 수 있습니다.
import java.util.concurrent.atomic.AtomicInteger;
class AtomicCounter {
private final AtomicInteger count = new AtomicInteger(0);
public void increment() {
count.incrementAndGet(); // 내부적으로 CAS
}
}
여기서 incrementAndGet() 메서드가 내부적으로 CAS를 사용합니다. 따라서 여러 스레드가 동시에 호출해도 안전하게 숫자를 증가시킬 수 있습니다. 또한 락처럼 스레드를 대기시키지 않기 때문에 특정 상황에서는 더 가볍게 동작합니다.