ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Java volatile 키워드
    JAVA 2023. 1. 5. 09:40

    Java에서 volatile에 대해 알아보겠습니다.
    volatile을 번역하면 휘발성이라는 단어가 많이 나옵니다.
    Java동시성에 대한 이야기에 자주 따라붙곤 합니다.
    그러면, volatile이란 언제 사용하는 것이고, volatile 키워드를 사용하면 어떤일이 벌어질까요?
    synchronized 키워드는 변수와 클래스에 붙일 수 없고, 메서드와 블럭에만 사용할 수 있습니다.
    단순한 Counter 예제를 통해서 확인해 보겠습니다.

    예제 목록

    1. instance 변수
    2. volatile 변수
    3. AtomicInteger
    4. synchronized block

    main 메서드 클래스

    모든 코드는 다음코드로 실행해 보겠습니다.

    public class CounterApp {
    
        public static void main(String[] args) throws InterruptedException {
            final Counter counter = new Counter();
    
            Runnable r = () -> {
                int i = 0;
                while (i < 10_000) {
                    i++;
                    counter.incrementCount();
                }
                System.out.println(">>>>> 실행 완료된 Thread : " + Thread.currentThread());
            };
    
            for (int i = 0; i < 5; i++) {
                new Thread(r).start();
            }
    
            // t1, t2, t3 Thread가 모두 종료된 후에 count 값을 가져오기 잠시 Sleep
            Thread.sleep(5000);
            System.out.println();
            System.out.println(">>>>> count print Thread : " + Thread.currentThread());
            System.out.println(">>>>> counter.getCount() : " + counter.getCount());
        }
    }

    해당 코드는 단순히 Counter 클래스의 instance 변수인 count 변수를 1씩 증가하는 코드입니다.
    하나의 쓰레드는 1만번 count를 증가 시킵니다

    여기서는 5개의 Thread를 동작시키기 때문에 저희가 원하는 결과는 50,000 입니다.

    과연, 어떤 결과가 나오는지 확인해 볼까요?

    1. Instance 변수

    public class Counter {
        private int count;
    
        public void incrementCount() {
            count++;
        }
    
        public int getCount() {
            return count;
        }
    }

    결과

    >>>>> 실행 완료된 Thread : Thread[Thread-1,5,main]
    >>>>> 실행 완료된 Thread : Thread[Thread-0,5,main]
    >>>>> 실행 완료된 Thread : Thread[Thread-4,5,main]
    >>>>> 실행 완료된 Thread : Thread[Thread-3,5,main]
    >>>>> 실행 완료된 Thread : Thread[Thread-2,5,main]
    
    >>>>> count print Thread : Thread[main,5,main]
    >>>>> counter.getCount() : 15861

    2. volatile 변수

    public class Counter {
        private volatile int count;
    
        public void incrementCount() {
            count++;
        }
    
        public int getCount() {
            return count;
        }
    }

    결과

    >>>>> 실행 완료된 Thread : Thread[Thread-2,5,main]
    >>>>> 실행 완료된 Thread : Thread[Thread-3,5,main]
    >>>>> 실행 완료된 Thread : Thread[Thread-4,5,main]
    >>>>> 실행 완료된 Thread : Thread[Thread-0,5,main]
    >>>>> 실행 완료된 Thread : Thread[Thread-1,5,main]
    
    >>>>> count print Thread : Thread[main,5,main]
    >>>>> counter.getCount() : 19876

    3. AtomicInteger

    public class Counter {
        private final AtomicInteger count = new AtomicInteger();
    
        public void incrementCount() {
            count.incrementAndGet();
        }
    
        public int getCount() {
            return count.get();
        }
    }

    결과

    >>>>> 실행 완료된 Thread : Thread[Thread-0,5,main]
    >>>>> 실행 완료된 Thread : Thread[Thread-4,5,main]
    >>>>> 실행 완료된 Thread : Thread[Thread-1,5,main]
    >>>>> 실행 완료된 Thread : Thread[Thread-3,5,main]
    >>>>> 실행 완료된 Thread : Thread[Thread-2,5,main]
    
    >>>>> count print Thread : Thread[main,5,main]
    >>>>> counter.getCount() : 50000

    4. synchronized block

    public class Counter {
        private int count;
    
        public synchronized void incrementCount() {
            count++;
        }
    
        public int getCount() {
            return count;
        }
    }

    결과

    >>>>> 실행 완료된 Thread : Thread[Thread-3,5,main]
    >>>>> 실행 완료된 Thread : Thread[Thread-4,5,main]
    >>>>> 실행 완료된 Thread : Thread[Thread-0,5,main]
    >>>>> 실행 완료된 Thread : Thread[Thread-2,5,main]
    >>>>> 실행 완료된 Thread : Thread[Thread-1,5,main]
    
    >>>>> count print Thread : Thread[main,5,main]
    >>>>> counter.getCount() : 50000

    위 코드의 결과를 보면, 저희가 원했던 결과가 나오는 것은 원자성을 보장하는 AtomicInteger와 단 하나의 Thread만 실행될 수 있도록 사용한 synchronized block 뿐입니다.
    단순 인스턴스 변수와, volatile을 붙인 변수는 우리가 원하는 결과가 나오지 않았는데요. 이유가 무엇일까요?

    우선 가장 일반적으로 알고 있는 부분인 instance 변수 입니다.
    instance 변수는 모든 Thread가 공유하기 때문에 race condition이 발생할 수 있습니다.
    여러개의 Thread가 동시에 접근하는 것이 문제인 이유는 다음과 같습니다.

    예로 Thread2개를 가지고 설명해볼께요.
    다음의 순서로 동작이 가능합니다.

    1. count = 0;
    2. Thread 1 이 count 값을 읽습니다. 읽은 값은 0 입니다.
    3. Thread 2 가 count 값을 읽습니다. 읽은 값은 0 입니다.
    4. 이제서야 Thread1이 count 값을 증가 시킵니다. count 값은 1 입니다.
    5. Thread2 가 읽었던 count 값은 0 이기 때문에, Thread2는 Thread1이 증가시킨 count 값 1을 알지 못합니다.
    6. Thread2 가 count 값을 0 에서 1로 증가시킵니다.
    두개의 Thread가 동시에 접근했기 때문에 결과는 2가 나올 것이라 예상했지만, 두 쓰레드가 동시에 하나의 리소스에 접근했기 때문에
    race condition이 발생한 것입니다.

    race condition(경쟁 조건)
    여기서 말하는 race condition이란 하나의 쓰레드가 하나의 공유 변수에 접근하기 위해 경쟁하는 상태를 말합니다.

    instance 변수는 여러 Thread가 공유하기 때문에 위와 같은 문제가 발생합니다.
    그래서 저희가 원했던 50,000이 아닌 15,861 이라는 값이 나왔던 겁니다.

    그리고 여기서 하나 말씀 드리면, 메서드 내의 Local 변수는 Thread Safe하고, 멤버 변수는 Thread Safe 하지 않은데요.
    이건 JVM 메모리 구조를 알면 알 수 있습니다.
    JVM 메모리 구조는 크게 다음과 같은 구조로 되어 있는데요.

    여기서 우리가 사용한 인스턴스 변수는 Method Area에 저장 됩니다.
    Method Area는 모든 Thread가 공유하게 되어 있습니다.
    그리고, 메서드 내에서 사용하는 값들은 Stack Area에 저장되는데요. 이건 각 Thread별로 생성 됩니다.
    그렇기 때문에, 동시성 문제, race condition에 대해 고민하고 싶지 않다면,
    인스턴스변수나 클래스 변수로 선언하지 않고, 메서드 내의 로컬 별수를 사용하면 Thread Safe하게 코딩할 수 있습니다.

    다시 본론으로 돌아오겠습니다.
    모두가 잘 아는 synchronized block을 사용했을 때 우리가 원하는 결과를 얻을 수 있었던 것은
    해당 지역은 단 하나의 Thread만 실행되도록 blocking 되었기 때문에 우리가 원하는 결과를 얻을 수 있었습니다.

    예로 들면 다음과 같습니다.
    1. 1번 Thread가 incrementCount() 메서드를 실행합니다.
    2. 2번 Thread가 incrementCount() 메서드에 접근하려 하지만, 1번 Thread가 실행 중이기 때문에 대기 합니다.
    3. 1번 Thread가 count 값을 증가 시키고 incrementCount() 메서드에서 빠져 나옵니다.
    4. 2번 Thread가 incrementCount() 메서드를 실행합니다. 

    위와 같이 하나의 Thread만 접근할 수 있기 때문에, race condition이 발생하지 않습니다.
    그러면 왜 volatile 키워드는 문제가 될까요?

    여기서 volatile 키워드에 우선 설명하겠습니다.
    volatile 키워드를 사용하면 변수의 가시성을 확보할 수 있습니다.
    그렇다면 변수의 가시성이란 무엇을 말할까요?
    volatile 키워드를 사용하지 않았다면 일반적인 변수는 메인메모리가 아닌 CPU Cache 우선 저장됩니다.
    Cpu Cache에 저장된 값이 메인 메모리로 기록되는 시점은 보장할 수 없습니다.

    아래 이미지는 Thread 1 만 counter 변수를 7번 증가 시키고, Thread2가 counter 변수를 읽을 때
    변수의 변경된 최종값인 7을 읽어 오지 못하고, 0을 읽어오게 되서 가시성 문제가 발생된다는 내용의 이미지 입니다.
    이처럼, 변수의 최종값을 다른 스레드가 보지 못하는 것을 가시성 문제라고 합니다.
    하지만, volatile을 사용하면, Cpu Cache가 아닌 메인 메모리에서 값을 쓰고 읽게 됩니다.
    이로 인해, volatile 키워드를 사용하면, 변수의 최종값을 읽을 수 있게 되고 이를 가시성이 확보되었다고 말합니다.

    참고로, 저장 공간에는, CPU Cache, CPU Register, Memory 가 존재합니다.
    가장 먼저 우리가 선언한 변수는, 사이즈에 따라서, CPU Cache에 저장됩니다.
    그리고 CPU Cache는 L1, L2, L3로 구분 됩니다.
    여기서 CPU 란 CPU Core를 생각하시면 됩니다. 각각의 Core는 L1, L2, L3 캐시를 가지고 있고,
    이 캐시는 각각의 CPU Core가 공유하지 못합니다.
    모든 CPU Core가 공유할 수 있는 것은 Main Memory 입니다.
    멀티 프로세서 환경에서 멀티 쓰레드로 실행되는 경우, 각각의 쓰레드가 서로 다른 CPU Core의 캐시에 저장된 값을 읽어 오게 되면,
    서로 다른 값을 바라보게 될 수 있습니다.

    하지만, volatile 키워드로는 변수의 가시성은 확보할 수 있지만, 여러 쓰레드가 연산을 수행하는 경우 원자성을 보장할 수 없습니다.
    Thread가 volatile 변수의 값을 읽고 volatile 변수에 대한 새 값을 쓰게되면 volatile 변수는 올바른 가시성을 보장할 수 없습니다.

    volatile 키워드를 사용해서, 여러 쓰레드가 카운트를 증가 시키는 경우 다음과 같은 상황이 발생할 수 있습니다.
    volatile 키워드가 붙은 count 변수를 0으로 초기화한 상태에서 7번 count 를 증가 시킨다고 가정할 때
    그리고, 2개의 CPU Core에서 동작하는 경우
    1. 1번 쓰레드가 main memory에 있는 count 값 0을 읽습니다.
    2. 2번 쓰레드가 main memory에 있는 count 값 0을 읽습니다.
    3. 2번 쓰레드가 count 값을 1증가 시킵니다.
    4. 2번 쓰레드가 증가된 count 값 1을 main memory에 씁니다.
    5. 1번 쓰레드가 count 값을 1증가 시킵니다.
    6. 1번 쓰레드가 count 값 1을 main memory에 씁니다.

    이처럼 volatile이 변수의 가시성을 확보하지만, volatile이 연산에 대한 원자성을 보장하진 않습니다.
    그래서, 위의 코드에서 volatile 키워드를 사용했지만, 우리가 원하는 결과를 얻을 수 없었던 것 입니다.
    그래서 이럴 때는, 원자적 연산을 수행해주는 Atomic 을 사용해야 합니다.
    Atomic의 경우 CAS(Compare and Swap) 알고리즘을 사용해서 원자성을 보장해 줍니다.

    CAS(Compare and swap)
    비교후 교환
    멀티쓰레딩에서 사용되는 원자적 명령
    메모리 위치의 내용을 주어진 값과 비교하고 동일한 경우에만 해당 메모리 위치의 내용을 새로운 주어진 값으로 수정

    synchronized를 사용하는 경우는 하나의 Thread만 접근이 가능하도록 blocking되지만, Atomic은 Blocking되지 않습니다.

    결론

    volatile 키워드를 사용하면, 변수의 가시성을 확보할 수 있습니다.
    하지만, volatile이 연산의 일관성을 보장해 주지는 않습니다.

    변수를 blocking 하지 않고, 여러 쓰레드가 동시에 접근 가능하지만, 원자성을 보장하고 싶다면 Atomic을 사용하세요.
    그리고, Atomic이 원자성이 보장되는 것은 CAS(Compare and swap) 알고리즘으로 가능합니다.

    그리고 항상 고려해야 할 상황은 race condition이 발생되도록 코딩해야 하는 것이 정말 맞는가를 생각하는 것입니다.
    많은 사람들은 가능하다면 경쟁조건 및 동기화 이슈가 발생하지 않도록 개발하는 것이 가장 좋은 선택이라고 말합니다.

    댓글

Designed by Tistory.