synchronized의 문제점
synchronized는 blocking을사용하여 멀티 스레드 환경에서 공유 객체를 동기화하는 키워드입니다.
그러나 blocking에는 여러 가지 단점이 존재하는데, 그 중에서 손 꼽는 문제는 성능 이슈입니다.
특정 스레드가 해당 블럭 전체에 lock을 걸면, 해당 lock에 접근하는 스레드들은 블로킹 상태에 들어가기 때문에 아무 작업도 하지 못한 채 자원을 낭비합니다.
또한 blocking 상태의 스레드를 준비 혹은 실행 상태로 변경하기 위해 시스템의 자원을 사용해야 합니다.
결국 이 문제는 성능 저하로 이어집니다.
예를 들어 자동차 운전을 한다고 가정해보겠습니다.
운전자가 방향 전환을 하려고 하는데, 마침 앞에 다른 자동차가 대기하고 있습니다.
운전자는 앞의 차가 먼저 지나가기를 기다리기 위해 정차를 하고, 앞의 차가 지나가면 다시 출발을 해야 합니다. 이처럼 자동차를 정차하고 다시 출발하고, 앞의 차를 기다리며 많은 에너지가 소비되는 것과 비슷한 이치입니다.
이러한 문제점 때문에 non-blocking을 하며 원자성을 보장하기 위한 방법이 atomic 변수입니다.
원자성과 Atomic Type
atomic 변수는 멀티 스레드 환경에서 원자성을 보장하기 위해 나온 개념입니다.
synchronized와는 다르게 blocking이 아닌 non-blocking하면서 원자성을 보장하여 동기화 문제를 해결합니다.
atomic의 핵심 동작 원리는 CAS(Compare And Swap) 알고리즘입니다.
CAS (Compare And Swap) 알고리즘
CAS 알고리즘의 동작 원리는 다음과 같습니다.
- 인자로 기존 값(Compared Value)과 변경할 값(Exchanged Value)을 전달한다.
- 기존 값(Compared Value)이 현재 메모리가 가지고 있는 값(Destination)과 같다면 변경할 값(Exchanged Value)을 반영하며 true를 반환한다.
- 반대로 기존 값(Compared Value)이 현재 메모리가 가지고 있는 값(Destination)과 다르다면 값을 반영하지 않고 false를 반환한다.
여기서 ‘기존 값으로 던진 값이 현재 메모리가 가지고 있는 값과 다른 경우가 뭐지?’라는 의문이 생길 수 있습니다.
이 말의 의미는 스레드 A가 공유 변수에 대해 계산을 하고, 메모리에 반영하기 직전에 다른 스레드 B가 공유 변수를 변경하여 메모리에 반영한 경우를 의미합니다. 이때 당연히 스레드 A의 변경할 값을 메모리에 반영하면 안 됩니다.
따라서 false를 반환하는 경우에는 무한 루프를 구성하여 변경된 값(다른 스레드에 의해 변경된 메모리 값)을 읽고 같은 시도를 반복하거나, 다른 더 중요한 작업이 있으면 다른 작업을 해도 됩니다.
정리하자면, atomic은 blocking 방식을 사용하는 synchronized에 비해 훨씬 효율적인 방법이라고 할 수 있습니다.
무한 루프를 돌면서 값을 반영할 수 있는지 물어보는 경우에도 스레드의 상태를 변경하는 작업이 발생하지 않으므로 성능이 더 우수합니다.
Java에서의 CAS 동작 예시
JVM 내의 스레드 스케줄러에 의해 각각의 core에 스레드-1과 스레드-2가 선점된 상태이고, 두 스레드는 각각 for문에서 공유 변수 count를 증가시킨다고 가정하겠습니다.
- 각 스레드는 힙 내에 있는 count 변수를 읽어 CPU 캐시 메모리에 저장합니다.
- 각 스레드는 번갈아가며 for문을 돌면서 count 값을 1씩 증가시킵니다.
- 스레드-1 또는 스레드-2는 변경한 count 값을 힙에 반영하기 위해 변경하기 전의 count 값과 힙에 저장된 count 값을 비교합니다. 여기서 이후 상황이 두 가지로 나뉩니다.
- 변경하기 전의 count 값과 힙에 저장된 count 값이 다를 경우 false를 반환하며, 힙에 저장된 값을 읽어 2번 과정으로 돌아갑니다.
- 변경하기 전의 count 값과 힙에 저장된 count 값이 같을 경우 true를 반환하며, 힙에 변경한 값을 저장합니다.
- 힙에 변경한 값을 저장한 스레드-1 또는 스레드-2는 1번 과정으로 돌아갑니다. (for문이 종료될 때까지)
정리
atomic 변수의 핵심 원리인 CAS 알고리즘은 원자성을 해결해 주는 것을 볼 수 있습니다. 그리고 non-blocking이 가능하므로 blocking 방식인 synchronized보다 성능 상 이점이 있다는 것도 알 수 있었습니다. 참고로 synchronized 키워드의 경우 synchronized 블록에 진입하기 전에 CPU 캐시 메모리와 메인 메모리 값을 동기화하여 가시성을 해결합니다.
blocking에 관한 오해
필자는 synchronized 방식이 무한 루프를 돌면서 true를 반환할 때까지 기다리는 atomic 방식보다 성능이 우수하지 않나 생각을 했었습니다.
왜냐하면 blocking된 스레드는 즉시 자신이 CPU 제어권을 다른 스레드에게 양보하는 반면, non-blocking된 스레드는 무한 루프를 돌면서 의미 없이 true를 반환받을 때까지 CPU를 붙잡고 있기 때문입니다.
하지만 싱글 코어 환경에서 공유 변수 count를 사용하는 스레드가 100개가 있다면 어떨까요?
synchronized 방식의 경우 blocking이므로 count를 사용하는 하나의 스레드 외에 나머지는 모두 스레드 상태가 blocked로 바뀌고 다른 일을 할 수 없습니다.
따라서 CPU 자원이 낭비가 되고, 공유 자원의 lock이 풀리더라도 특정 스레드의 상태를 blocked에서 running으로 바꾸어야 하고, 반대로 99개의 스레드를 running에서 blocked 상태로 바꾸어야 하므로 atomic에 비해 성능이 느릴 수 밖에 없을 것입니다.
반면 atomic 방식의 경우 non-blocking이므로 비록 무의미한 무한 루프를 돌 수도 있지만, true를 반환 받는 순간 스레드의 상태를 변경하는 일 없이 바로 이후 작업을 이어서 할 수 있습니다. 또한, non-blocking 방식은 무한 루프를 하면서 작업이 끝났는지 물어보지 않고 다른 작업을 해도 되므로 선택이 자유롭습니다.
AtomicInteger 살펴 보기
atomic type인 AtomicInteger 클래스가 동기화 문제를 어떻게 해결하는지 살펴 보겠습니다.
public class AtomicIntegerTest {
private static int count;
public static void main(String[] args) throws InterruptedException {
AtomicInteger atomicCount = new AtomicInteger(0);
Thread thread1 = new Thread(() -> {
for (int i = 0; i < 100000; i++) {
count++;
atomicCount.incrementAndGet();
}
});
Thread thread2 = new Thread(() -> {
for (int i = 0; i < 100000; i++) {
count++;
atomicCount.incrementAndGet();
}
});
thread1.start();
thread2.start();
Thread.sleep(5000);
System.out.println("atomic 결과 : " + atomicCount.get());
System.out.println("int 결과 : " + count);
}
}
AtomicInteger와 int 타입인 count 변수를 생성한 다음, 두 개의 스레드에서 count++ 연산을 하는 예제입니다.
결과는 다음과 같습니다.
//결과
atomic 결과 : 200000
int 결과 :152298
AtomicInteger 타입인 atomicCount는 의도 대로 200000이 출력되는 것을 볼 수 있고, int 타입인 count는 동기화가 지켜지지 않아 잘못된 값을 출력하는 것을 볼 수 있습니다.
동기화가 어떻게 지켜지는지 AtomicInteger 클래스의 incrementAndGet() 메소드를 살펴 보겠습니다.
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 incrementAndGet() {
return U.getAndAddInt(this, VALUE, 1) + 1;
}
}
public final class Unsafe {
@HotSpotIntrinsicCandidate
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;
}
}
incrementAndGet() 메소드 내부에서 CAS 알고리즘의 로직을 구현하고 있습니다.
getAndAddInt() 내부에서는 weakCompareAndSetInt() 메소드를 호출하여 메모리에 저장된 값과 현재 값을 비교하여 동일하다면, 메모리에 변경한 값을 저장하고 true를 반황하여 while문을 빠져 나옵니다.
추가로 눈 여겨 볼 점은 value 변수에 volatile 키워드가 붙은 것입니다.
따라서, Atomic 변수는 CAS(원자성) + volatile(가시성)을 보장하게 됩니다.
즉, 동시접근 문제와 가시성 문제 모두 해결할 수 있습니다.
참고자료
https://steady-coding.tistory.com/568
[Java] atomic과 CAS 알고리즘
java-study에서 스터디를 진행하고 있습니다. synchronized의 문제점 synchronized는 blocking을사용하여 멀티 스레드 환경에서 공유 객체를 동기화하는 키워드이다. 그러나 blocking에는 여러 가지 단점이 존
steady-coding.tistory.com
'Java' 카테고리의 다른 글
[Java] 동시성 문제 해결을 위한 synchronized 키워드 (0) | 2025.02.11 |
---|---|
[Java] 가시성 문제 해결을 위한 volatile 키워드 (0) | 2025.02.11 |
[Java] 멀티스레드 환경에서의 동시성 이슈 (0) | 2025.02.11 |
[Java] 제네릭(Generic) 이란? (0) | 2025.01.31 |
[Java] 스레드 생명주기와 스케줄링 (0) | 2025.01.28 |