이 글은 원 저자 Jakob Jenkov의 허가로 포스팅된 번역물이다.

원문 URL : http://tutorials.jenkov.com/java-concurrency/volatile.html



 자바 volatile 키워드는 자바 코드의 변수를 '메인 메모리에 저장' 할 것을 명시하기 위해 쓰인다. 정확히 말해서, 모든 volatile 변수는 컴퓨터의 메인 메모리로부터 읽히고, volatile 변수에 대한 쓰기 작업은 메인 메모리로 직접 이루어진다. - CPU 캐시가 쓰이지 않는다.


실제로는 자바 5 부터 volatile 키워드는 메인 메모리로부터 읽고 쓰는 작업 이상의 것을 보장한다. 다음 섹션에서 이에 대해 설명한다.



volatile 의 가시성 보장


volatile 키워드는 쓰레드들에 대한 변수의 변경의 가시성을 보장한다. 이는 다소 추상적으로 들릴 수 있는데, 상세히 들어가보자.


멀티쓰레드 어플리케이션에서의 non-volatile 변수에 대한 작업은 성능상의 이유로 CPU 캐시를 이용한다. 둘 이상의 CPU가 탑제된 컴퓨터에서 어플리케이션을 실행한다면, 각 쓰레드는 변수를 각 CPU의 캐시로 복사하여 읽어들인다. 



non-volatile 변수에 대한 작업은 JVM 이 메인 메모리로부터 CPU 캐시로 변수를 읽어들이거나, CPU 캐시로부터 메인 메모리에 데이터를 쓰거나 할 때에 대한 어떠한 보장도 하지 않는다. 이는 몇가지 문제를 야기할 수 있는데, 다음 섹션을 보자.


둘 이상의 쓰레드가 다음과 같은 공유 객체로 접근하는 경우를 생각해보자.


public class SharedObject {

    public int counter = 0;

}


Thread1 은 counter 변수를 증가시키고, Thread1 과 Thread2 가 때에 따라서 counter 변수를 읽는다.


만일 counter 변수에 volatile 키워드가 없다면, counter 변수가 언제 CPU 캐시에서 메인 메모리로 쓰일지(written) 보장할 수 없다. CPU 캐시의 counter 변수와 메인 메모리의 counter 변수가 다른 값을 가질 수 있다는 것이다.



쓰레드가 변경한 값이 메인 메모리에 저장되지 않아서 다른 쓰레드가 이 값을 볼 수 없는 상황을 '가시성' 문제라 한다. 한 쓰레드의 변경(update)이 다른 쓰레드에게 보이지 않는다.


counter 변수에 volatile 키워드를 선언한다면 이 변수에 대한 쓰기 작업은 즉각 메인 메모리로 이루어질 것이고, 읽기 작업 또한 메인 메모리로부터 다이렉트로 이루어질 것이다.


public class SharedObject {

    public volatile int counter = 0;

}


volatile 선언은 다른 쓰레드의 쓰기 작업에 대한 가시성을 보장한다.



volatile 키워드의 Happens-Before 보장


자바 5 에서부터 volatile 키워드는 변수에 대한 읽기/쓰기 작업의 메인 메모리 사용을 보장하는 것 이상의 것을 보장한다고 했는데, 이는 다음과 같다.


  • It Thread A writes to a volatile variable and Thread B subsequently reads the same volatile variable, then all variables visible to Thread A before writing the volatile variable, will also be visible to Thread B after it has read the volatile variable.

  • The reading and writing instructions of volatile variables cannot be reordered by the JVM (the JVM may reorder instructions for performance reasons as long as the JVM detects no change in program behaviour from the reordering). Instructions before and after can be reordered, but the volatile read or write cannot be mixed with these instructions. Whatever instructions follow a read or write of a volatile variable are guaranteed to happen after the read or write.


이 문장에 대해서는 더 깊은 설명이 필요하다.


한 쓰레드가 volatile 변수를 수정할 때, 단지 이 volatile 변수만이 메인 메모리로 저장되는 것이 아니라, 이 쓰레드가 volatile 변수를 수정하기 전에 수정한 모든 변수들이 함께 메인 메모리에 저장(flushed)된다. 그리고 쓰레드가 volatile 변수를 메인 메모리에서 읽어들일 때, volatile 변수를 수정하면서 메인 메모리로 함께 저장된 다른 모든 변수들도 메인 메모리로부터 함께 읽어들여진다.


예제를 보자:


Thread A:
    sharedObject.nonVolatile = 123;
    sharedObject.counter     = sharedObject.counter + 1;

Thread B:
    int counter     = sharedObject.counter;
    int nonVolatile = sharedObject.nonVolatile;


Thread A 가 non-volatile 변수인 sharedObject.nonVolatile 을, volatile 변수인 sharedObject.counter 를 읽기 전에 수정하기 때문에, 두 변수 sharedObject.nonVolatile 과 sharedObject.counter 는 Thread A 가 sharedObject.counter(volatile 변수) 를 수정할 때 메인 메모리로 함께 저장된다.


Thread B 는 sharedObject.counter 를 읽으면서 시작하는데, 이 때 sharedObject.nonVolatile 도 메인 메모리에서 CPU 캐시로 읽어온다. Thread B 가 sharedObject.nonVolatile 변수를 읽었을 때 이 변수의 값은 Thread A 에 의해 수정된 값이 읽혀진다.


개발자들은 쓰레드간의 변수의 가시성의 최적화를 보장하기 위해 이 확장된 가시성을 활용할 수 있다. 모든 변수에 volatile 을 선언하는 대신, 단 하나, 혹은 몇몇의 변수에만 volatile 을 선언하면 된다. 여기 Exchanger 클래스를 보자:


public class Exchanger {

    private Object   object       = null;
    private volatile boolean hasNewObject = false;

    public void put(Object newObject) {
        while(hasNewObject) {
            //wait - do not overwrite existing new object
        }
        object = newObject;
        hasNewObject = true; //volatile write
    }

    public Object take(){
        while(!hasNewObject){ //volatile read
            //wait - don't take old object (or null)
        }
        Object obj = object;
        hasNewObject = false; //volatile write
        return obj;
    }
}


Thread A 는 시간에 따라 put() 을 호출하여 객체를 대입하고, Thread B 는 take() 를 호출하여 객체를 읽어 반환한다. 두 쓰레드가 맡은대로 Thread A 는 put() 만을, Thread B 는 take() 만을 호출하는 한, 이 Exchanger 클래스는 synchronized 블록 없이도 volatile 변수를 이용해 문제 없이 작동할 수 있다.


하지만 이 코드에서 만일 JVM 이 코드 실행 동작을 바꾸지 않으면서 코드를 재정리할 수 있다면, JVM 은 성능 향상을 위해 코드를 재정리하려 할 것이다. JVM 이 put() 과 take() 의 읽기와 쓰기 작업의 순서를 바꾼다면 어떻게 될까? 


put() 의 실행 코드가 다음과 같이 바뀔 수(재정리) 있다고 생각해보자.


while(hasNewObject) {
    //wait - do not overwrite existing new object
}
hasNewObject = true; //volatile write
object = newObject;


volatile 변수 hasNewObject 로의 쓰기 작업이 object 가 세팅되기 전으로 바뀌었다. JVM 의 시각으로 이것은 완전히 유효한 코드이다. 두 쓰기 작업은 서로에게 의존하지 않는다.


그러나 이 재정리는 object 변수의 가시성에 손상을 줄 수 있다. 먼저, Thread B 는 Thread A 가 실제로 object 에 newObject 를 세팅하기도 전에 hasNewObject 값을 true 로 읽을 수가 있다. 둘째로, 새 객체가 세팅된 object 변수가 어느 시점에 메인 메모리로 저장될지에 대한 보장이 없다.


이러한 상황을 피하기 위해, volatile 키워드는 "happends before guarantee" 성질을 갖는데, 이것은 volatile 변수에 대한 읽기/쓰기 명령은 JVM 에 의해 재정리되지 않음을 보장한다는 의미이다. volatile 변수에 대한 읽기/쓰기 명령을 기준으로, 이 변수 전에 존재하는 다른 명령들은 자기들끼리 얼마든지 재정리 될 수 있다. 그리고 이 변수 뒤에 존재하는 다른 명령들 또한 자기들끼리 재정리 될 수 있다. 다만, volatile 변수에 대한 명령 이전/이후에 존재한다는 그 전제는 반드시 지켜진다.


혼동될 수 있으니 다음 예제와 설명을 자세히 보자:


sharedObject.nonVolatile1 = 123;
sharedObject.nonVolatile2 = 456;
sharedObject.nonVolatile3 = 789;

sharedObject.volatile     = true; //a volatile variable

int someValue1 = sharedObject.nonVolatile4;
int someValue2 = sharedObject.nonVolatile5;
int someValue3 = sharedObject.nonVolatile6;


JVM 은 -volatile 변수에 쓰기 명령이 실행되기 전에 실행되는- 처음 3개의 명령을 재정리할 수 있다. 다만 이 명령들은 반드시 volatile 변수에 쓰기 명령이 실행되기 전에 실행되어야 한다.


같은 맥락으로, JVM 은 volatile 변수에 쓰기 명령이 실행 먼저 실행되기만 한다면, 마지막 3개의 명령을 재정리할 수 있다. 다만 이 명령들은 반드시 volatile 변수에 쓰기 명령이 실행된 후에 실행되어야 한다.


이 성질을 Java volatile happends before guarantee 라 한다.



volatile 은 만병통치약이 아니다


volatile 선언이 변수의 읽기/쓰기 명령을 메인 메모리로부터 수행한다는 것을 보장한다고 할지라도, volatile 선언으로 해결할 수 없는 상황들은 여전히 남아있다.


위의 예제들 중, 공유 변수인 counter 가 있고 Thread1 만이 이 변수를 수정하고, Thread2 만이 이 변수를 읽는 이런 상황에서라면 volatile 선언이 변수의 가시성을 보장해준다.


멀티쓰레드 환경에서, volatile 공유 변수에 세팅된 새로운 값이 이 변수가 가지고 있던 이전의 값에 의존적이지 않는다면, 다수의 쓰레드들이 volatile 공유 변수를 수정하면서도 메인 메모리에 존재하는 정확한 값을 읽을 수 있다. 달리 말하자면, 만일 volatile 공유 변수를 수정하는 한 쓰레드가 이 변수의 다음 값을 알아내기 위해 이전의 값을 필요로 하지 않는다면 말이다.


쓰레드가 volatile 변수의 초기값을 필요로 할 때, 그리고 volatile 변수의 새 값이 이 초기값을 근거로 할 때, volatile 선언은 더이상 정확한 가시성을 보장하기 못한다. volatile 변수를 읽고, 새 값을 쓰는 사이의 짧은 갭은 경합 조건을 일으킨다 - 다수의 쓰레드가 volatile 변수의 값을 똑같이 읽고, 새 값을 생성하여 이를 메인 메모리로 저장하는 동안, 서로의 값을 덮어쓸 수 있다.


다수의 쓰레드가 같은 counter 값을 증가시키는 상황이 바로 volatile 변수가 불완전해지는 상황이다. 다음 섹션에서 이에 대해 자세히 설명한다.


상황을 가정해보자. Thread1 이 공유 변수인 counter 의 값 0 을 자신의 CPU 캐시에 읽어들이고, 이 값을 1 증가시키고, 아직 메인 메모리로 저장하지 않았다. 그리고 Thread2 는 같은 counter 변수를 메인 메모리에서 자신의 CPU 캐시로 읽어들였고, 이 때의 값은 여전히 0 이다. 그리고 Thread2 도 이 값을 1 증가시키고 메인 메모리로 저장하지는 않았다. 다음은 이 상황을 묘사한 다이어그램이다.



Thread1 과 Thread2 는 사실상 동기화에서 완전히 멀어진 상태이다. counter 변수의 실제 값은 2 가 되어야 하지만, 두 쓰레드는 각자의 값, 1 을 자신들의 캐시에 가지고 있다. 그리고 메인 메모리의 값은 아직 0 이다. 이 상황에서 쓰레드들이 캐시에 가진 변수의 값은 메인 메모리에 저장한다고 해도, counter 의 값은 1 이 된다. 잘못된 상황이다.



volatile 을 사용하기 적절한 때는?


앞서 설명했듯, 두 쓰레드가 공유 변수에 읽기/쓰기 를 실행할 때, volatile 선언은 충분치 않다. 이런 상황에서는 변수값의 읽기/쓰기 명령의 원자성을 보장하기 위해 synchronized 를 써야한다. 변수를 읽고 쓸 때 volatile 선언은 변수에 접근하는 쓰레드들을 블록시키지 않는다. 이런 임계 영역에는 synchronized 키워드가 필요하다.


synchronized 블록을 대체하는 다른 것을 찾는다면, java.util.concurrent 패키지의 많은 원자성 데이터 타입들을 사용할 수도 있다. 에를 들자면 AtomicLong 이나 AtomicReference 와 같은 것들이다.


한 변수를 두고 오직 한 쓰레드만 이 변수에 읽기/쓰기 작업을 하고, 다른 쓰레드들은 읽기 작업만 하는 상황에서라면 이 때는 volatile 선언이 유효하다. 읽기 작업을 수행하는 쓰레드들은 언제나 이 변수의 가장 최근 수정된 값을 봐야하고, volatile 은 이를 보장해준다.


그리고 volatile 은 32비트와 64비트 변수에서 효과를 볼 수 있다.



volatile 의 성능에 대한 고찰


volatile 변수의 읽기/쓰기는 메인 메모리를 이용한다. 메인 메모리로부터 데이터를 읽고 쓰는 작업은 CPU 캐시를 이용하는 것 보다 많은 비용이 요구된다. 또한 volatile 선언은 JVM 의 성능 향상을 위한 기술인, 코드 재정리를 막기도 한다. 그러므로 volatile 키워드는 변수의 가시성 보장이 반드시 필요한 경우에만 사용되어야 한다.


+ Recent posts