동기화와 원자적 연산
원자적 연산
원자적 연산(Atomic Operation): 더 이상 나눌 수 없는 단위로 수행되는 연산. 중간에 끊기지 않아 멀티스레드 환경에서도 안전하다.
원자적 연산 예시
원자적
- 아래에 코드를 보면 오른쪽에 1을 i에 대입하는 연산이 끝이다.
원자적 연산 아님
- 아래에 코드를 보면 3가지 연산으로 분리되어 있다.
- 오른쪽에 i의 값을 읽는다.
- 읽은 i에 1을 더한다.
- 더한 i를 왼쪽에 i에게 대입한다.
AtomicInteger
- 락을 사용하지 않고도 정수 연산의 원자성과 메모리 가시성을 보장하는 클래스이다.
동작 원리
- 현재 값을 읽음
- 기대값과 실제 메모리 값을 비교
- 같으면 새 값으로 교체
- 다르면 다시 시도 (Spin)
장점
- synchronized 없이도 정확성 보장
- 경쟁이 적을수록 매우 빠름
- 코드가 간결함
- 데드락 위험 없음
한계
- 경쟁이 심할수록 성능 저하
- CAS 실패 → 반복 재시도
- 단일 변수 연산에만 적합
- 여러 변수를 함께 다뤄야 하면 부적합
예시 코드
public class Main {
private static final AtomicInteger atomicInteger = new AtomicInteger();
public static void main(String[] args) throws InterruptedException {
for (long i = 0; i < 100; i++) {
Thread thread = new Thread(new MyJob());
thread.start();
}
Thread.sleep(1_000);
System.out.println(atomicInteger.get());
}
private static class MyJob implements Runnable {
@Override
public void run() {
atomicInteger.incrementAndGet();
}
}
}
CAS (Compare-And-Swap / Compare-And-Set)
개요
- CAS는 동시성 제어를 위해 사용되는 락 없는(Lock-Free) 원자적 연산이다.
- CPU 하드웨어 차원에서 직접 지원되며, 현대적인 CPU는 CAS 전용 명령어를 제공한다.
- 자바에서는 Atomic* 계열 클래스가 내부적으로 CAS를 사용한다.
하드웨어 관점
- CAS 연산은 CPU 명령어 하나로 수행되는 원자 연산이다.
- 연산 중 중간 상태가 존재하지 않으며, 다른 스레드는 이를 관찰할 수 없다.
- 따라서 락을 사용하지 않고도 [경쟁 상태]를 방지할 수 있다.
CAS 연산의 핵심 개념
- CAS는 다음 3단계를 하나의 원자 연산으로 수행한다.
- 기대 값(Expected Value)과 메인 메모리에 실제 저장된 값(Current Value)을 비교한다.
- 두 값이 같으면 → 새로운 값(New Value)으로 교체
- 같지 않으면 → 아무 작업도 하지 않고 실패
compareAndSet() 메서드
- 자바의 CAS 구현 메서드
- 내부적으로 CPU의 CAS 명령어를 사용
- 반환값
- true : 값 변경 성공
- false : 다른 스레드가 먼저 값을 변경함
CAS 기반 증가 로직 예제
public int incrementAndGet(AtomicInteger atomicInteger) {
int getValue;
boolean result;
do {
// 1. 현재 값 조회
getValue = atomicInteger.get();
// 2-1. 기대 값(getValue)과 실제 메모리 값 비교
// 2-2. 같다면 atomicInteger 값 증가
result = atomicInteger.compareAndSet(getValue, getValue + 1);
// 3-1. 실패 시 다른 스레드가 값을 변경한 것이므로 재시도
} while(!result);
// 3-2. 성공 시 getValue <- 실제 연산을 한 후 리턴
return getValue + 1; // 결과 반환
}
SpinLock
- 락을 얻을 때까지 계속 확인하는 방식의 락
- 락 획득에 실패해도 스레드가
BLOCKED상태로 바뀌지 않고 CPU를 사용하면서 반복 대기(Busy Waiting) 한다. - 주로 아주 짧은 임계 구역에서만 사용된다.
동작 방식 (간단 설명)
- 락이 비어 있는지 확인
- 비어 있으면 즉시 락 획득
- 이미 사용 중이면
→ 잠들지 않고 계속 확인(Spin)
장점
- 컨텍스트 스위칭 비용이 없음
- 스레드를 재우거나 깨우지 않음
- 커널 스케줄러 개입이 없어 빠름
- 락 대기 시간이 매우 짧을 때 성능이 좋음
- 잠깐 기다릴 바에는 바로 확인하는 것이 효율적
단점
- CPU를 계속 사용함
- 락을 기다리는 동안에도 CPU 점유
- 경쟁이 심하면 CPU 낭비 발생
- 임계 구역이 길면 성능이 급격히 나빠짐
- 비즈니스 로직, I/O, sleep 포함 시 매우 부적합
- CPU 코어 수가 부족하면 문제 발생
- 다른 스레드가 실행 기회를 못 얻을 수 있음
예시 코드
public class Main {
public static void main(String[] args) {
SpinLock lock = new SpinLock();
Runnable task = new Runnable() {
@Override
public void run() {
lock.lock();
try {
log("비즈니스 로직 실행");
} finally {
lock.unlock();
}
}
};
for (int i = 1; i <= 2; i++) {
Thread thread = new Thread(task, "Thread" + i);
thread.start();
}
}
private static void log(Object obj) {
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("HH:mm:ss.SSS");
String time = LocalTime.now().format(formatter);
System.out.printf("%s [%9s] %s\n", time, Thread.currentThread().getName(), obj);
}
private static class SpinLock {
AtomicBoolean lock = new AtomicBoolean(); // default = false
public void lock() {
while (!lock.compareAndSet(false, true)) {
log("락 획득 실패");
}
log("락 획득 성공");
}
public void unlock() {
lock.set(false);
log("락 해제 성공");
}
}
}