반응형
들어가며
이런 질문을 받았습니다.
"일반 int에 synchronized를 걸지 않고 100개 스레드가 각각 +1을 하면 결과가 100이 안 나올 수 있습니다. 하지만 AtomicInteger.addAndGet()을 사용하면 락 없이도 정확히 100이 보장됩니다. 어떻게 이게 가능한가요?
당시에는 제대로 답변하지 못했지만, 이후 학습을 통해 그 원리를 정리해보았습니다.
문제 상황: Race Condition
먼저 문제를 명확히 해봅시다.
// 문제가 되는 코드
private int counter = 0;
// 100개의 스레드가 동시에 실행
public void increment() {
counter++; // 이 한 줄이 사실은 3개의 작업!
}
counter++는 원자적(atomic) 연산이 아닙니다. 실제로는:
- READ: 메모리에서 counter 값을 읽음
- MODIFY: 값을 1 증가
- WRITE: 증가된 값을 메모리에 씀
두 스레드가 동시에 실행되면:
Thread A: READ(0) -> MODIFY(1) -> WRITE(1)
Thread B: READ(0) -> MODIFY(1) -> WRITE(1)
결과: 2가 아니라 1!
기존 해결책: synchronized와 Lock
synchronized 방식
private int counter = 0;
public synchronized void increment() {
counter++;
}
장점: 안전하고 간단합니다.
단점: 성능 오버헤드가 큽니다.
- 스레드가 락을 획득하지 못하면 블로킹됨
- 컨텍스트 스위칭 비용 발생
- 경쟁이 심할수록 성능 저하
AtomicInteger의 마법: CAS (Compare-And-Swap)
AtomicInteger는 락을 사용하지 않고도 동시성을 보장합니다. 그 비밀은 CAS 알고리즘입니다.
CAS란?
CAS는 하드웨어 레벨에서 지원하는 원자적 연산입니다.
CAS(메모리_주소, 예상값, 새값) {
if (메모리_주소의_값 == 예상값) {
메모리_주소의_값 = 새값;
return true;
} else {
return false;
}
}
이 비교와 교환이 하나의 CPU 명령어로 수행되므로 중간에 다른 스레드가 끼어들 수 없습니다.
AtomicInteger 내부 구조
public class AtomicInteger extends Number implements java.io.Serializable {
private static final Unsafe U = Unsafe.getUnsafe();
private static final long VALUE
= U.objectFieldOffset(AtomicInteger.class, "value");
private volatile int value;
public final int addAndGet(int delta) {
return U.getAndAddInt(this, VALUE, delta) + delta;
}
}
// Unsafe 클래스의 실제 구현
public final class Unsafe {
@IntrinsicCandidate
public final int getAndAddInt(Object o, long offset, int delta) {
int v;
do {
v = getIntVolatile(o, offset);
} while (!weakCompareAndSetInt(o, offset, v, v + delta));
return v;
}
}
주요 포인트:
- getIntVolatile(): 객체 o의 offset 위치에서 volatile 읽기 수행
- weakCompareAndSetInt(): CAS 연산 수행
- @IntrinsicCandidate: JVM이 이 메서드를 네이티브 CPU 명령어로 대체할 수 있음
- 메모리 offset을 직접 사용하여 성능 최적화
동작 과정 예시
100개 스레드가 동시에 addAndGet(1)을 호출할 때:
Thread A:
1. current = 0을 읽음
2. next = 1 계산
3. CAS(0, 1) 시도 -> 성공! (아직 0이므로)
4. 값이 1로 변경됨
Thread B:
1. current = 0을 읽음 (거의 동시에)
2. next = 1 계산
3. CAS(0, 1) 시도 -> 실패! (이미 1로 변경됨)
4. 루프 재시작
5. current = 1을 읽음
6. next = 2 계산
7. CAS(1, 2) 시도 -> 성공!
getIntVolatile()은 volatile 읽기를 수행하여
CPU 캐시 일관성 프로토콜을 통해 최신 값을 보장받고,
읽기/쓰기 재정렬을 방지합니다.
낙관적 락(Optimistic Lock)과의 유사성
낙관적 락의 특징
- 충돌이 드물 것이라 낙관적으로 가정
- 락을 미리 획득하지 않음
- 업데이트 시점에 충돌 검사
- 충돌 시 재시도
CAS도 동일한 철학
- 락 없이 작업 진행 (낙관적)
- 마지막에 값이 변경되었는지 확인
- 변경되었다면 재시도
- 충돌이 적을수록 효율적
직접 구현해보기
CAS를 직접 구현할 수는 없지만 (하드웨어 명령어 필요), 개념적으로 어떻게 100을 보장하는지 시뮬레이션해봅시다.
import java.util.concurrent.atomic.AtomicInteger;
public class CustomAtomicCounter {
private final AtomicInteger value = new AtomicInteger(0);
/**
* CAS 기반 증가 연산
*/
public int increment() {
while (true) {
int current = value.get();
int next = current + 1;
// CAS: 현재 값이 여전히 current라면 next로 변경
if (value.compareAndSet(current, next)) {
return next; // 성공
}
// 실패하면 루프 재시작 (다른 스레드가 값을 변경함)
}
}
public int get() {
return value.get();
}
}
테스트 코드
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class ConcurrencyTest {
public static void main(String[] args) throws InterruptedException {
CustomAtomicCounter counter = new CustomAtomicCounter();
int threadCount = 100;
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
CountDownLatch latch = new CountDownLatch(threadCount);
// 100개 스레드가 각각 1씩 증가
for (int i = 0; i < threadCount; i++) {
executor.submit(() -> {
counter.increment();
latch.countDown();
});
}
latch.await();
executor.shutdown();
System.out.println("최종 값: " + counter.get()); // 항상 100!
}
}
synchronized vs AtomicInteger 성능 비교
// 간단한 벤치마크
public class PerformanceTest {
private static final int ITERATIONS = 1_000_000;
// synchronized 버전
private int syncCounter = 0;
public synchronized void syncIncrement() {
syncCounter++;
}
// AtomicInteger 버전
private AtomicInteger atomicCounter = new AtomicInteger(0);
public void atomicIncrement() {
atomicCounter.incrementAndGet();
}
}
일반적인 결과:
- 경쟁이 적을 때: AtomicInteger가 2-5배 빠름
- 경쟁이 심할 때: 성능 차이가 줄어들지만 여전히 AtomicInteger가 유리
- synchronized는 블로킹되지만, CAS는 바쁘게 재시도 (스핀)
CAS의 장단점
장점
- 락 프리(Lock-Free): 데드락 불가능
- 높은 성능: 경쟁이 적을 때 매우 빠름
- 논블로킹: 스레드가 블로킹되지 않음
단점
- ABA 문제: A→B→A로 변경될 때 감지 못함 (AtomicStampedReference로 해결)
- CPU 사이클 낭비: 재시도 중 스핀으로 CPU 사용
- 경쟁이 심할 때: 재시도가 많아지면 오히려 비효율적
volatile의 역할
AtomicInteger 내부의 volatile int value에 주목해야 합니다.
private volatile int value;
volatile이 보장하는 것:
- 가시성(Visibility): 한 스레드의 변경이 즉시 다른 스레드에게 보임
- 재정렬 방지: 컴파일러/CPU의 최적화로 인한 명령어 순서 변경 금지
CAS만으로는 부족하고, volatile이 함께 있어야 올바른 동작이 보장됩니다.
실무에서 언제 사용할까?
AtomicInteger를 사용하면 좋은 경우
- 단순 카운터, ID 생성기
- 경쟁이 낮거나 중간 정도
- 락 오버헤드를 피하고 싶을 때
synchronized를 사용해야 하는 경우
- 복잡한 다단계 연산 (단일 원자성 보장 필요)
- 경쟁이 매우 심할 때
- 블로킹이 문제 없는 경우
// Atomic으로는 불가능한 예시
public synchronized void transferMoney(Account from, Account to, int amount) {
from.balance -= amount;
to.balance += amount;
// 두 연산이 함께 원자적이어야 함
}
정리
- AtomicInteger는 CAS 알고리즘을 사용합니다.
- CAS는 하드웨어 레벨의 원자적 연산으로, 비교와 교환을 한 번에 수행합니다.
- 실패 시 재시도하는 낙관적 접근으로 100을 보장합니다.
- volatile과 함께 사용되어 가시성도 보장합니다.
- 직접 구현한다면 AtomicInteger를 활용한 CAS 루프를 사용하면 됩니다.
참고 자료
- Java Concurrency in Practice
- Java Memory Model (JMM) Specification
- CPU의 Compare-and-Swap 명령어 (x86의 CMPXCHG)
반응형
'Back end > Java' 카테고리의 다른 글
| [Java] JDBC의 Statement와 PreparedStatement (2) | 2026.01.22 |
|---|---|
| [Java] static import 활용법 (0) | 2025.07.07 |
| [Java] 자바 데이터 타입 (기본형, 참조형) (0) | 2025.05.27 |
| [Java] 상수(Constant) (0) | 2025.05.26 |
| [Java] 변수(variable) (0) | 2025.05.26 |