[Java] volatile / synchronized / Atomic 을 이용한 동기화

박성우·2023년 7월 9일
0

Java

목록 보기
3/6

프로그래밍 동기화를 하는 방법에는 많은 방법이 존재하는데, 그 중 Java에서 제공하는 대표적인 세 가지가 volatile / synchronized / Atomic 이다.

volatile, synchronized는 예약어로 등록되어 있는 키워드이고, Atomic은 타입과 함께 클래스로 제공되고 있다. (ex. AtomicInteger, AtomicLong.. )

우선, 위 세 가지 방식에 한해서, 동시성 제어를 하기 위해 두 가지 조건이 만족되어야 한다.

가시성 (Visibility)

하나의 명령이 수행되었을 때, 바로 반영되어 알 수 있는 것
-> 어디서나 항상 최신값을 받아볼 수 있는 성질

원자성 (Atomicity)

하나의 명령을 수행할 때, 다른 곳에서 접근할 수 없는 것
-> 연산이 더 이상 쪼개질 수 없는 성질

애초에 이런 조건들을 왜 고려해야할까?

우리가 보고 생각하는 만큼 컴퓨터는 쉽고 간단하게 구성되어 동작하지 않는다.

그냥 누가 어떤 값을 읽고 바꾸고 또 누가 읽고 바꾸고 그러면 되는거 아닌가? 싶지만 컴퓨터에게는 이것이 전부 명령으로 구성되어야 하고 그 명령어 조차 우리의 생각처럼 한 번에 수행하는 것이 아니기 때문이다.

위 두 조건을 크게 두 가지 측면으로 볼 수 있다.

1. 하드웨어적 측면 (가시성 문제)

우리가 예를 들어 전역 변수를 선언한다면, 그 변수는 Main Memory에 저장될 것이고, 그 값을 읽고 쓸 것이다.

하지만, 멀티쓰레드 애플리케이션에서는 성능 향상을 위해 아래 사진과 같이 CPU Cache를 따로 두고 Cache를 거쳐서 읽고 쓰기때문에, Thread 1에서 값을 수정 해도 CPU 1 cache에 저장되어 있는 값을 수정한 다음 Main Memory에 값을 반영하는 과정을 거친다.

따라서, 값을 수정했다 한들 바로 반영되지 않으며 위 상황에선 Thread 2에서 값을 읽어왔을 때 아직 반영되어 있지 않은 값을 가져올 수도 있기 때문에 가시성이 보장되지 않는 상황인 것이다.

2. 소프트웨어적 측면 (원자성 문제)

i 라는 값에 1을 더하고 싶다면, 우리는 어떻게 생각할까?

그냥 i + 1 이라고 적을 것이고 실제로 코드도 i + 1 또는 i++ 이라고 적을 것이다. 컴퓨터도 그렇게 명령을 내릴까?

어떻게 보면 당연한거지만, 컴퓨터 입장에선 읽고 쓰는 행위가 필수적이며, 원자 단위로 연산을 수행하기 때문에

  1. i를 메모리로부터 읽는다.
  2. i에 1을 더한다.
  3. i를 메모리에 반영한다.

최소 3번의 명령이 필요하다. (Cache를 사용할 경우 늘어날 수도 있음)

문제는 만약 어떤 쓰레드가 2번 작업을 막 끝내고 3번을 수행하려고 하는데, 다른 쓰레드에서 1번 작업을 통해 메모리로부터 데이터를 읽어가면 결국 두 쓰레드에서 한번 씩 1을 더했는데 결과는 i에 1만 더해져있을 것이다.

이런 경우가 바로 원자성이 보장이 되지 않는 경우이다.

하지만, 만약 i에 어떤 값을 대입하려고만 한다면, 3번 명령에만 해당이 될 것이기 때문에 연산을 더 이상 쪼갤 수 없는 성질인 원자성이 보장이 될 것이다.


volatile (가시성 O, 원자성 X)

volatile 키워드는 가시성 문제를 해결하고자 나온 방법이다.

public class SharedObject {
    public volatile int counter = 0;
    ...
}

위와 같이 변수 앞에 volatile 키워드를 붙여주면 해당 변수는 더 이상 Cache를 거쳐서 읽어오는 것이 아니라, Main Memory로부터 직접 읽고 쓰게 된다.

결국, 쓰레드에서 수정한 값을 쓰면 다이렉트로 Main Memory의 값에 반영되기 때문에 가시성을 보장할 수 있다.

문제는, 가시성이 보장이 돼도 원자성이 보장이 되지 않는다.

Main Memory로부터 읽고 쓴다한들 Thread 1에서 수정된 값을 쓰기 전에 Thread 2가 값을 읽어가면 결국 Thread 1에 의해 수정된 값을 덮어쓰게 될 것이기 때문이다.

synchronized (가시성 O, 원자성 O)

synchronized 키워드는 크게 두 가지 방법으로 사용된다.

  1. synchronized 메소드
public synchronized void share() {
	...
}

해당 메소드에는 같은 시간에 한 개의 쓰레드만 접근 가능하다.

  1. synchronized 블록
synchronized (object) {
	...
}

해당 블록 내에는 같은 시간에 한 개의 쓰레드만 접근 가능하다. 필요한 일부 로직에만 동기화를 하기 때문에, 메소드에 synchronized를 사용하는 것보다 효율적이다.

결국 synchronized 키워드는 연산에 대해서 쓰레드 하나씩 수행하게 하기 때문에 가시성과 원자성이 보장된다.

❗다만 이러한 Lock을 거는 방식 자체가 Blocking 방식이기 때문에 병목 현상을 일으키기 쉽고, 자칫하면 두 개의 Lock이 서로 간의 리소스를 대기하는 상태인 교착 상태(Deadlock)가 걸릴 수 있는 확률도 높기 때문에 조심해서 사용해야 한다.

Atomic (가시성 O, 원자성 O)

Atomic 변수를 이용하면 synchronized 키워드의 성능 저하를 보완할 수 있다.

Atomic 변수는 Atomic 이름이 붙은 AtomicInteger, AtomicLong과 같은 클래스를 통해 사용할 수 있는데 CAS (Compare And Swap) 알고리즘을 통해 동작한다.

CAS 알고리즘은 현재 쓰레드가 존재하는 CPU의 Cache Memory와 Main Memory에 저장된 값을 비교하여, 일치하는 경우 새로운 값으로 교체하고, 일치하지 않을 경우 기존 교체를 실패(false 반환)하고 이에 대해 계속 재시도하는 방식이다.
-> 원자성 보장

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 get() { return value; }
        public final void set(int newValue) { value = newValue; }

        public final int incrementAndGet() {
        	return U.getAndAddInt(this, VALUE, 1) + 1;
    	}
}

-----------------------------------------------------------------------------------------------

public final class Unsafe {
	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;
      	} 
}

AtomicInteger 클래스의 내부 코드를 보면 우선 volatile로 선언된 변수 value가 존재하고 get 메소드와 set 메소드가 존재한다.

get 메소드와 set 메소드는 단순히 값을 읽고 쓰기 때문에, 더 이상 연산이 쪼개질 수 없는 원자성이 보장되는 연산이며, volatile 선언을 통해 가시성만 보장해주면 되기 때문에 CAS 알고리즘을 통해 원자성을 보장해줄 필요가 없다.

그 외의 incrementAndGet 메소드의 경우는 getIntVolatile 메소드 호출을 통해 가시성이 보장된 상태로 값을 읽어오고 weakCompareAndSetInt 라는 CAS 알고리즘을 적용한 메소드 호출을 통해 값에 대한 연산을 수행하여 원자성 또한 보장해주고 있다.

이렇게 Atomic 변수를 이용하여 가시성과 원자성을 모두 보장하는 동기화를 할 수 있으며, CAS 알고리즘을 이용한 Non-Blocking 방식이기 때문에 Blocking 방식의 synchronized 키워드를 사용하는 것보다 효율적으로 동작한다.


적용 예시

Java가 제공하는 자료 구조 중 thread-safe한 자료 구조로써, Map 인터페이스의 구현체인 HashTable과 ConcurrentHashMap이 존재한다. (HashMap은 동기화 X)

동기화에만 초점을 두고 간단히 코드를 살펴보면

public class Hashtable<K,V>
    extends Dictionary<K,V>
    implements Map<K,V>, Cloneable, java.io.Serializable {
  
    ...
    public synchronized V put(K key, V value) {
        if (value == null) {
            throw new NullPointerException();
        }

        Entry<?,?> tab[] = table;
        int hash = key.hashCode();
        int index = (hash & 0x7FFFFFFF) % tab.length;
        @SuppressWarnings("unchecked")
        Entry<K,V> entry = (Entry<K,V>)tab[index];
        for(; entry != null ; entry = entry.next) {
            if ((entry.hash == hash) && entry.key.equals(key)) {
                V old = entry.value;
                entry.value = value;
                return old;
            }
        }

        addEntry(hash, key, value, index);
        return null;
    }
    ...  
}

HashTable 같은 경우 메소드 자체에 synchronized 키워드를 붙여서 쓰레드 간 Lock을 통해 동기화를 보장하는 반면에,

public class ConcurrentHashMap<K,V> extends AbstractMap<K,V>
    implements ConcurrentMap<K,V>, Serializable {
    ...
        public V put(K key, V value) {
        return putVal(key, value, false);
    }

    final V putVal(K key, V value, boolean onlyIfAbsent) {
        if (key == null || value == null) throw new NullPointerException();
        int hash = spread(key.hashCode());
        int binCount = 0;
        for (Node<K,V>[] tab = table;;) {
            Node<K,V> f; int n, i, fh; K fk; V fv;
            if (tab == null || (n = tab.length) == 0)
                tab = initTable();
            else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
                if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value)))
                    break;                   
            }
            ...
            else {
                V oldVal = null;
                synchronized (f) {
                    if (tabAt(tab, i) == f) {
                        ...
                        }
                        else if (f instanceof TreeBin) {
                            ...
                            }
                        }
                        else if (f instanceof ReservationNode)
                            throw new IllegalStateException("Recursive update");
                    }
                }
                ...

ConcurrentHashMap 같은 경우는 내부적으로 CAS 알고리즘과 synchronized 블럭을 채택하며 쓰레드 간 Lock이 아닌 특정 Entry에 대해서만 Lock을 걸기 때문에 HashTable에 비해 성능적으로 우수하다.


Reference

profile
Backend Developer

0개의 댓글