✏️개요
앞선 게시글에서 Spring과 관련하여 새롭게 알게 된 내용들을 살펴보았으며, 이번 게시글에서는 그 계기가 되었던 Java의 Volatile 키워드에 대해서 살펴보고자 한다. Volatile에 대해서 살펴보는 과정에서 Java의 원자성, 가시성에 대한 내용을 접하게 되었는데 개인적으로 어렵다고 느껴졌지만😢 최대한 이해한 대로 풀어보고자 한다.
❗ 본 게시글은 필자 개인적인 의견이므로 틀린 부분이 있을 수 있습니다. 댓글을 통해 지적해주시면 감사하겠습니다.
✏️Volatile이란?
Volatile 키워드는 일반적으로 Multi Thread 환경에서 여러 스레드들이 함께 사용하는 공유 변수에 적용할 수 있는 키워드라고 한다. 즉, 경쟁 상태에 있는 변수에 적용할 수 있다는 것이다.
기존의 스레드들은 변수에 대해 Read & Write의 작업을 수행할 때, 그 대상은 각 스레드가 할당받은 CPU의 Cache Memory라고 한다. (HW 성능을 위한 설계로 인한 것이라고 한다.) 그러나 Volatile 키워드가 붙은 변수를 대상으로 여러 스레드들이 Read & Write 작업을 수행할 때는 작업의 대상으로 Main Memory로 삼는다는 것이다.
즉, Volatile 키워드가 붙은 변수에 접근하는 스레드들은 작업의 결과를 Main Memory에 저장한다.
✏️Visibility, 가시성
필자는 Thread를 이용한 작업을 많이 해보지 않았기 때문에, 왜 굳이 Volatile 키워드를 이용해 Main Memory에 값을 Write 하고 Read 하는지 이해할 수 없었지만 가시성에 대한 내용을 보고 이해할 수 있었다.
앞서 언급하였듯이, 성능상의 이유로 스레드들은 각각 할당받은 CPU의 Cache Memory를 이용하게 되는데 이곳에 저장된 연산의 결과가 언제 Main Memory로 올라갈지 모른다고 한다. 이에 대한 예시를 들면 아래와 같다.
- counter 변수의 값을 증가시키는 연산을 Thread1과 Thread 2가 수행한다.
- Thread1의 연산으로 counter 변수의 값이 7이 되었다 가정했을 때, 그 결과는 CPU1의 Cache Memory에 적재되어 있다.
- Thread 2의 연산 차례가 와서 Main Memory에 있는 counter 값을 CPU2의 Cache Memory로 가져와 연산을 수행한다. 이때의 counter 값은 Thread1의 결과가 아직 Main Memory에 올라가지 않았기에 0이며, Thread1의 연산 결과가 7이라는 사실을 Thread 2는 알 수 없다.
- 이것을 비가시성이라 한다.
이로 인해 counter 변수에 대한 값의 불일치가 발생하고 우리는 원하는 결과를 얻을 수 없게 된다. 그러나 Volatile 키워드를 사용하게 되면, Thread1의 결과가 CPU1의 Cache Memory에 저장되었다가 Main Memory에 적재되는 게 아닌, 바로 Main Memory에 적재되기 때문에 Thread 2가 읽은 값은 Thread1의 결괏값이 될 것이다.
필자는 여기까지 읽고 Volatile 키워드면 모든 게 해결될 줄 알았다.
✏️Atomicity, 원자성
필자는 이번 주제에 대해서 살펴보면서 원자성, 원자적 연산 등에 대한 개념을 처음 접하게 되었다. 양자역학을 다루는 기술도 아닌데 웬 원자?
원자성에 대한 설명이 좀 다양한 것 같던데 필자가 이해한 원자성이란 양자역학의 원자가 더 이상 쪼갤 수 없는 단위인 것처럼, 중단이 불가능한 것이었다. 즉, 멀티 스레드 환경에서 한 스레드가 하나의 연산을 수행하는 과정에서 다른 스레드가 끼어들 수 없는 것이라 생각했다.
이와 같은 상호 배제적인 성격을 Java의 Syncronized 키워드와 유사하다고 생각했었는데, 실제로 이를 보장하기 위한 임계 영역을 지정하는 방법 중 하나였다.
여기까지 읽은 뒤, 가시성에서 설명했던 예시가 원자성을 가지는 가?라고 했을 때 원자성이 아니라고 생각됐다.
단순히 값을 1 증가시키는 과정은 위의 그림처럼, 3단계로 나뉘게 된다.
- 기존의 값을 읽고
- 연산을 수행하고
- 수행 결과를 쓴다
만약, 위의 예시에서 Thread1이 counter 값을 증가시키기 위해 2번까지 수행한 상태일 때, Thread 2가 1번의 과정에 진입한다면 Thread 2가 읽은 counter 값은 Thread1의 연산 결과가 반영되지 않은 값일 것이다. 그 결과, Thread1의 연산 결과인 1이 counter에 저장되고 Thread 2의 연산 결과 역시 1로 counter에 저장될 것이다. 즉, couner 값은 2가 되어야 하는데 1인 것이다.
즉, 원자성이 보장되지 않는 상태인 것이다. 결국, Volatile 키워드는 항상 최신의 값을 보장해주긴 하지만 원자성이 보장되지 않아 결과적으로는 우리가 원하는 값을 얻을 수 없는 것은 마찬가지이다.
그렇기에 Volatile 키워드를 이용해 가시성을 보장했다면, 원자성을 보장하는 방법이 필요한 것인데 그 방법으로는 아래와 같다고 한다.
- Volatile 키워드에 접근하는 스레드 중 1개의 스레드만이 Read & Write 작업을 수행하고, 나머지 스레드는 Read만 수행한다면 원자성은 보장된다.
- Synchronized 함수 또는 블록을 이용해 임계 영역을 설정하여 원자성을 보장할 수 있다.
- 단일 연산(atomic) 변수를 이용한다. (AtomicInteger 등.. 잘 찾아보진 않았다.)
추가적인 내용으로, 우리가 흔히 사용하는 Synchronized 키워드는 Blocking 동기화 방법으로 한 스레드가 이용하는 동안 Lock이 걸리기 때문에 다른 스레드가 Blocking 상태로 들어가는 등의 이유로 성능 이슈가 존재한다고 한다.
그렇기에 서로 다른 스레들 사이에 변수 값 일치만을 보장한다면, 굳이 Synchronized를 사용하기보다 Volaile 키워드를 사용하는 것이 좋고 또는 단일 연산 변수를 이용한 Non - Blocking 동기화 방법을 사용하는 것이 좋다고 한다.
✏️마치며
어쩌다 보니 새롭게 알게 된 내용들이 많아 이해하는 데 시간이 많이 걸렸다. 어쨌든 몰랐던 내용들에 대한 기초적 정의 정도로 살펴보았기 때문에 다음에 Volatile 키워드 또는 Synchronized 키워드를 만나면 어렴풋이라도 생각날 거라 생각한다.