JAVA
멀티스레드 환경에서의 동시성 이슈 (CAS 알고리즘)
soyeonisgood
2025. 7. 2. 20:37
동시성
- 한 CPU에서 동시에 여러 작업을 하는 것처럼 보이는 것
- 예시
- 하나의 CPU에서 2개의 프로세스가 있다고 가정해보자.
- 두 개의 프로세스는 아주 짧은 시간에 컨텍스트 스위칭으로 번갈아 실행된다.
- 이 과정이 우리가 볼 때 동시에 동작하는 것처럼 보이는 것!
경쟁 상태 (Race Condition)
- 두 개 이상의 스레드가 공유 자원에 동시에 접근할 때, 스레드 간의 실행 순서에 따라 결과가 달라지는 현상
- 경쟁 상태가 발생했다면 원자성과 가시성 모두 보장하지 못했기 때문
- 동시성 문제의 발생을 파악하기 어렵고, 발생하더라도 결함이 아닌 일시적 버그로 여겨질 수 있음 (재현이 안되기 때문)
- Java에서는 synchronized, Atomic Type, Concurrent Collection 등으로 해결 가능
원자성 (Atomicity)
- 공유 자원에 대한 작업 단위가 더 이상 쪼갤 수 없는 성질
- 원자성을 갖는 작업은 실행 중에 멈추는 경우는 있을 수 없다.
가시성 (Visibility)
- 한 스레드에서 변경한 값이 다른 스레드에서 즉시 확인 가능한 성질
동시성이 보장되지 못하는 예제를 보자.
private static long count = 0;
public void calculator() throws Exception {
int maxCount = 10;
for (int i = 0; i < maxCount; i++) {
new Thread(() -> {
try {
Thread.sleep(1);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
this.count++; // 동시성 보장 X
}).start();
}
Thread.sleep(100);
System.out.println("Total Count: " + count);
}
예상되는 Total Count는 10이다.
그러나 실제 수행 결과는 5~9 사이의 값이 나온다. ( TotalCount != 10)
Sychronized 키워드
- 여러 스레드가 동시에 count++; 작업을 수행하면서 중간 단계에서 서로 덮어쓰는 현상이 발생한다 = 원자성이 깨짐
- 각 스레드가 읽은 데이터가 캐싱됨 = 가시성 보장 X
- count++; 로직을 메서드로 분리하고, 해당 메서드에 sychronized 키워드를 붙인다.
- 메서드 레벨 뿐만 아니라 메서드 내부 블럭으로도 활용할 수 있다.
- 원자성 보장 → 하나의 스레드만 임계 구역에 들어가므로 증가 연산이 끊기지 않음
- 가시성 보장 → 동일 객체의 잠금(monitor)을 얻고 반납하는 순간, count 변수의 변경사항이 즉시 메인 메모리에 flush-in/flush-out 되어 다른 스레드도 갱신된 값을 봄
- 다른 스레드들은 작업을 하지 않고 기다리고 있어서, 자원 낭비이 낭비된다.
- 상호 배제 (Mutual-Exclusion): 동시에 하나의 스레드에서만 코드를 실행하거나, 공유자원 값을 변경하는 작없 수행
- 키워드 하나만 붙이면 해결되는 간단한 방법이지만 성능 저하 및 자원 낭비의 단점이 있다.
public synchronized void plusCount() {
this.count++;
}
private static long count = 0;
public void calculator() throws Exception {
int maxCount = 10;
for (int i = 0; i < maxCount; i++) {
new Thread(() -> {
try {
Thread.sleep(1);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
// this.count++; // 동시성 보장 X
plusCount(); // synchronized 키워드로 동시성 보장
}).start();
}
Thread.sleep(100);
System.out.println("Total Count: " + count);
}
volatile
- JVM에서 스레드는 실행 중인 CPU 메모리 영역에 데이터를 캐싱한다.
- 멀티 코어 프로세스에서는 여러 개의 스래드가 동일한 변수 a를 공유하더라도, 캐싱된 시점에 따라 데이터가 달라질 수 있다.
- 서로 다른 코어의 스레드는 데이터가 불일치할 수도 있다.
- 임의로 갱신하지 않는 이상, 데이터가 일치하는 정확한 시점을 알 수 없다.
- volatile 키워드는 캐싱된 값이 아닌, 메인 메모리 영역의 값을 참조하는 키워드
- 동일한 시점에 모든 스레드가 동일한 데이터를 가지도록 동기화
- sleep 시간을 늘리면 원하는 결과 10이 나오지 않을 것이다.
- Thread A가 count=3 으로 읽음 → Context Switch → Thread B가 3으로 읽고, count=4로 쓰기 → Thread A가 3+1=4로 쓰기 → Thread B의 count=4 update가 덮어써짐
- Thread B가 4로 바꿨다는 것을 즉시 볼 순 있지만, 이미 읽어온 3을 바꿀 순 없다. (업데이트 손실)
- 가시성과 순서 보장만 제공하기 때문에, 동시성 이슈를 완벽히 해결하진 못한다.
- 원자성 보장 X
private static volatile long count = 0;
public void calculator() throws InterruptedException {
int maxCount = 10;
Thread[] workers = new Thread[maxCount];
for (int i = 0; i < maxCount; i++) {
workers[i] = new Thread(() -> {
try { Thread.sleep(1); } catch (InterruptedException e) {}
count++; // 여전히 레이스 컨디션
});
workers[i].start();
}
for (Thread t : workers) t.join();
System.out.println("Total Count: " + count); // 10이 아닐 확률 높음
}
Atomic Class
java.util.concurrent.atomic 패키지가 제공하는 클래스
- ex) AtomicInteger, AtomicLong)
- 단일 변수에 대한 read-modify-write 연산을 원자적으로 수행
- 원자성 확보
- AtomicLong 핵심 연산은 하나의 논리단위로 완전히 실행됨. 중간에 끼어들 틈이 없음.
- 핵심 연산 ex) incrementAndGet(), addAndGet(), compareAndSet()
- AtomicLong 핵심 연산은 하나의 논리단위로 완전히 실행됨. 중간에 끼어들 틈이 없음.
- 가시성 확보
- 내부 필드가 volatile 로 선언되어 있음
- 순서 보장
- JMM(Java Memory Model)은 volatile 쓰기와 읽기 사이에서 순서 보장 관계를 부여. 따라서 연산 순서가 재배열되지 않음
private static final AtomicLong count = new AtomicLong();
void calculator() throws InterruptedException {
int max = 10;
Thread[] ts = new Thread[max];
for (int i = 0; i < max; i++) {
ts[i] = new Thread(() -> {
try { Thread.sleep(1); } catch (InterruptedException ignored) {}
count.incrementAndGet(); // 원자 + 가시성 OK
});
ts[i].start();
}
for (Thread t : ts) t.join();
System.out.println("Total = " + count.get()); // 항상 10
}
AtomicLong.incrementAndGet CAS 알고리즘
- CAS (Compare-And-Swap)
- CPU가 제공하는 원자 명령어를 사용해 현재 메모리 값이 prev와 같으면 next로 교체하는 작업을 한 번에 수행한다.
- 성공하면 true, 실패하는 false. 실패하면 while 루프로 재시도
- 이 과정에서 락(Lock)이 필요 없으므로 DeadLock, 우선순위 역전, Context Switch 비용이 없다.
- 메모리 장벽
- JVM의 CAS 수행 시 하드웨어 메모리 장벽이 함께 발동되어 읽기/쓰기 reorder를 막고 가시성 확보
public final long incrementAndGet() {
long prev, next;
do {
prev = getVolatileValue(); // ① 현재 값 읽기
next = prev + 1; // ② 계산
} while (!compareAndSwap(prev, next)); // ③ CAS 실패 시 재시도
return next;
}