🥝

JAVA: volatile 

자바에서 volatile  키워드에 대해서 정리해보려고 한다. 아래 예제 코드를 먼저 살펴보자.

123456789101112131415161718192021222324252627282930313233
class Main {
public static void main(String[] args) throws Exception {
Main main = new Main();
main.doTest();
}
private boolean running = true;
private void doTest() {
Thread threadA = new Thread(() -> {
long count = 0;
while (running) {
count += 1;
System.out.println("Thread A - count: " + count + ", running: " + running);
}
System.out.println("Thread A - done, count: " + count);
});
Thread threadB = new Thread(() -> {
try {
Thread.sleep(1_000L, 0);
} catch (InterruptedException e) {
System.err.println(e);
}
running = false;
System.out.println("Thread B - toggle running, running: " + running);
});
threadA.start();
threadB.start();
}
}

이 예제 코드는 두 개의 쓰레드(Thread)인, threadA 와 threadB 를 만들고 이 두 쓰레드를 실행한다. threadA 에서는 running 의 값에 따라 반복해서 count 의 값을 1씩 증가하다가 running 의 값이 거짓이 될 때, 반복을 중지한다. threadB 에서는 시작하자마자 sleep 을 통해 잠시 멈추어 있다가 running 의 값을 참에서 거짓으로 바꾼다. 이 예제 코드는 간단하다. 실제로 실행해보면 threadA 에서 count 값이 증가하다가 running 의 값이 바뀔 때, 두 쓰레드가 중지하면서 프로그램이 끝난다. 여기서 상기하고 싶은 부분은 이 running 에 대한 변수다. 두 쓰레드가 같은 변수를 사용하고 있고 이 변수의 값에 따라서 주어진 로직을 처리한다. 누구나 그럴듯이 두 쓰레드가 threadB 가 running 이라는 변수를 바꾸는 순간 프로그램이 종료한다고 생각한다. 정말 그럴까.

위의 코드에서 while 안의 코드 중, 단 한줄을 주석 처리해보자.

1234
while (running) {
count += 1;
// System.out.println("Thread A - count: " + count + ", running: " + running);
}

그리고 해당 예제 코드를 다시 실행해보자. 그러면 threadB 가 잠시 멈춘 후, running 의 값을 바꾸고 종료한다. 그러나 threadA 는 종료되지 않은채 계속 count 를 증가시키고 있다. 왜 그럴까, System.out.println() 부분이 주석처리 되면서 running 이라는 변수의 값에 영향을 준 것일까, 실제로 동작하는 코드 자체를 보고 싶지만 아직 능력 밖의 일이라 생각해서 잠시 남겨 둔다. 

이 찝찝한 상황에서 volatile 키워드를 추가해보자.

1
private volatile boolean running = true;

다시 예제 코드를 실행해보면 아까와 다르게 threadA 가 계속 살아 있지 않고 죽는다. volatile 키워드를 통해 두 쓰레드가 running 이라는 값을 main memory에서 읽고 threadB 가 값을 변경하면 threadA 가 이 변경된 값을 읽어서 죽게 된다. 그렇다는 것은 앞에서 주석 처리한 코드에서는 threadA 와 threadB 가 같은 running 변수를 참조하는 것처럼 보이나 실제는 그렇지 않는 것을 추측할 수 있다. 그게 가능한 일일까.

CPU Cache와 Main Memory

사실 CPU는 자체적으로 cache를 가지고 있다. Main memory에서 매번 데이터를 읽고 쓰지 않고 캐시를 통해 데이터를 읽고 쓴다. 그래서 앞에서 추측해본 것처럼 threadA 가 main memory에 저장된 running 의 값이 아닌 CPU cache에 저장된 running 의 값을 읽는다면 캐시가 갱신되기 전까지는 running 의 값은 항상 참이기 때문에 죽지 않고 계속 실행하게 되는 것이다. 

자바의 volatile 키워드는 변수에 대해서 읽기/쓰기 동작을 CPU cache가 아닌 main memory에서 하도록 만들어 준다. 그래서 앞에서 volatile 키워드르 붙여줌으로써 threadA 가 main memory의 running  값을 읽고 종료한 것이다. 이처럼 다중 쓰레드에서 서로 같은 변수를 참조하는 상황이라면 volatile 키워드 사용을 고려해봐야 한다.

Volatile

자바의 volatile 키워드는 앞서 살펴본 것처럼 

  • 변수를 CPU cache가 아닌 main memory에서 읽기/쓰기 작업을 하도록 명시
  • 변수를 컴파일러로 인해 최적화 되지 않도록 방지

하는 역할을 한다. 여기서 최적화 작업을 방지한다는 것은 CPU cache 내에서 읽도록 하는 부분을 하지 않겠다는 것으로 이해가 된다. 핵심은 volatile 키워드가 있는 변수는 main memory에서 읽기/쓰기 작업이 이뤄난다는 것이다.

Volatile에 대한 착각

Main memory에서 변수에 대한 읽기/쓰기 작업을 명시한다고 해서 다중 쓰레드(Multi-thread)환경에서의 임계 영역(Race Condition)에 대한 문제를 해결해주지는 않는다. 변수가 임계 영역에 있다면 반드시 원자성을 보장하기 위해 synchronized 와 같은 키워드를 사용해야 한다. 그리고 volatile 키워드는 단순히 값에 대한 참조를 main memory에서 하는 것으로 thread block이 발생하지 않는 반면, synchronized 키워드는 원자성을 보장하기 위해 thread block이 발생한다. 즉 성능(performance) 저하가 발생한다. 

마치며

실제 현업에서 volatile 키워드를 사용해본 경험은 없다. 다중 쓰레드 환경에서 임계 영역을 만들지 않는 것을 지향하고 있어서 그런지는 몰라도 이 부분에 대해서 무지했다. 우연치 않게 transient 키워드를 찾아보다가 알게 된 내용인데 이 기회에 한번 정리해서 좋았다. 항상 깨닫는 것은 코드는 항상 읽는 대로, 생각한 대로 동작하지 않는 것이다. 간단한 부분이라도 의심해야 하고 끝까지 들여다 봐야한다. 스트레스를 받기도 하지만 이런 부분을 해결하는 쾌감도 얻는다.

References