[Java] Atomic type은 왜 쓰는지? CAS(Compared And Swap)알고리즘이란? AtomicInteger의 활용법

2tsumo·2022년 8월 23일
0

java

목록 보기
6/6

Atomic Type

atomic 변수는 멀티 스레드 환경에서 원자성을 보장하기 위해 나온 개념이다.
멀티쓰레드 환경에서 동기화 문제를 synchronized 키워드를 사용하여 , 락을걸곤하는데 이런 키워드 없이 동기화문제를 해결하기 위해 고안된 방법이다.

synchronized는 특정 Thead가 해당 블럭 전체를 lock을 하기떄문에 다른 Thread는 아무런 작업을 하지 못하고 기다리는 상황이 될수 있기때문에 , 낭비가 심하다. 그래서 NonBlocking하면서 동기화 문제를 해결하기 위한 방법이 Atomic이다.
Atomic의 동작 핵심원리는 바로 CAS알고리즘이다(Compared and Swap)

CAS( Compared And Swap)알고리즘

[그림1]

[그림2]

멀티 쓰레드 환경, 멀티 코어 환경에서 각 CPU는 메인 메모리에서 변수값을 참조하는게 아닌, 각 CPU의 캐시 영역에서 메모리를 값을 참조하게 된다. ([그림2] CPU 캐시 메모리 참고) 이때, 메인 메모리에 저장된 값과 CPU 캐시에 저장된 값이 다른 경우가 있다. (이를 가시성 문제라고 한다.) 그래서 사용되는 것이 CAS 알고리즘이다. 현재 쓰레드에 저장된 값과 메인 메모리에 저장된 값을 비교하여 일치하는 경우 새로운 값으로 교체하고, 일치 하지 않는 다면 실패하고 재시도를 한다. 이렇게 처리되면 CPU캐시에서 잘못된 값을 참조하는 가시성 문제가 해결되게 된다. 참고로 synchronized 블락의 경우 synchronized 블락 진입전 후에 메인 메모리와 CPU 캐시 메모리의 값을 동기화 하기 때문에 문제가 없도록 처리한다. 

AtomicInteger

atomic type인 AtomicInteger 클래스가 동기화 문제를 어떻게 해결하는지 살펴 보자.

public class AtomicIntegerTest {
    private static int cnt;
    public static void main(String[] args) throws InterruptedException {
        AtomicInteger atomicCount = new AtomicInteger();
        Thread thread1 = new Thread(() -> {
            IntStream.range(0,100000).forEach(j ->{
                cnt++;
                atomicCount.incrementAndGet();
                }
            );

        });

        Thread thread2 = new Thread(() -> {
            IntStream.range(0,100000).forEach(j ->{
                cnt++;
                atomicCount.incrementAndGet();
            });
        });

        thread1.start();
        thread2.start();

        Thread.sleep(5000);
        System.out.println("atomic 결과 : " + atomicCount.get());
        System.out.println("int 결과 : " + cnt);
    }
}

AtomicInteger와 int 타입인 count 변수를 생성한 다음, 두 개의 스레드에서 count++ 연산을 하는 예제이다. 결과는 다음과 같다.

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 키워드가 붙은 것이다. 해당 키워드는 원자성 문제는 해결해 주지 않고 가시성 문제만 해결해 주므로 해당 공유 변수에 동시 쓰기가 발생하면 동기화 문제가 발생할 수 있다. 그런데, 사실 위 코드만 보면 어차피 AtomicInteger 클래스는 가시성 문제와 원자성 문제를 해결하는 CAS 알고리즘을 사용하는 메소드만 있는 것처럼 보인다.

class AtomicInteger {

    private volatile int value;

    public AtomicInteger(int initialValue) {
        value = initialValue;
    }

    public AtomicInteger() {
    }

    public final int get() {
        return value;
    }

    public final void set(int newValue) {
        value = newValue;
    }

    ...
}

하지만 AtomicInteger를 보면 CAS 알고리즘을 사용하지 않고 공유 변수를 읽거나 쓰는 작업이 있다. 이때 get() 과 set() 은 그 자체로 atomic 연산이므로 원자성 문제가 발생하지 않으므로 volatile을 통해 가시성 문제만 해결해 주는 것이다. 단순 대입 연산은 원자 연산임을 기억하자.

참조
https://javaplant.tistory.com/23
https://steady-coding.tistory.com/568

profile
👨‍💻

0개의 댓글