🧐 Volatile
자바에서 지원하는 volatile이라는 키워드는 다음과 같은 특성을 가집니다.
- volatile로 선언된 변수가 있는 코드는 최적화되지 않습니다.
- volatile 키워드는 변수를 'Main Memory에 저장하겠다'라고 명시하는 것입니다.
- 변수의 값을 Read할 때마다 CPU cache에 저장된 값이 아닌, Main Memory에서 읽는 것입니다.
🧐 사용하는 이유
volatile키워드의 사용 이유를 알기 위해서는 메모리 구조를 알아둘 필요가 있습니다.
🧐 메모리 구조
보통의 메모리 구조는 다음과 같습니다.
CPU 내에는 성능 향상을 위해서 L1 Cache가 내장되어 있습니다.
CPU 코어는 메모리에서 읽어온 값을 캐시에 저장하고, 캐시에서 값을 읽어서 작업합니다.
값을 읽어올 때 우선 캐시에 해당 값이 있는지 확인하고 없는 경우에만 메인 메모리에서 읽어옵니다.
그러다보니 도중에 메모리에 저장된 변수의 값이 변경되었는데도 캐시에 저장된 값이 갱신되지 않아 메모리에 저장된 값과 달라지는 경우가 발생합니다.
이해를 돕기 위해 예제를 만들었습니다.
public class ThreadTest {
boolean running = true;
public void test() {
new Thread(()->{
int count = 0;
while (running) {
count++;
}
System.out.println("Thread 1 finished. Counted up to " + count);
}
).start();
new Thread(()-> {
try {
Thread.sleep(100);
} catch (InterruptedException ignored) {
}
System.out.println("Thread 2 finishing");
running = false;
}
).start();
}
public static void main(String[] args) {
new ThreadTest().test();
}
}
첫 번째 쓰레드는 running flag를 검사하며 count를 증가시킵니다.
두 번째 쓰레드는 1초 쉬었다가 running flag를 false로 바꿉니다.
두 번째 쓰레드가 실행된다면 첫 번째 쓰레드의 무한루프가 종료될 것이라 예상했지만, 막상 실행해보면 쓰레드 1은 종료되지 않습니다.
🧐 문제 발생 이유
쓰레드 1은 running 변수를 참조할 때 자신의 CPU cache를 참조합니다.
쓰레드 2는 자신의 CPU cache의 running 변수를 false로 바꾼 것이기 때문에, 변수가 같음에도 불구하고 서로 다른 메모리 주소를 참조하게 되는것입니다.
해결?
이와 같은 동기화 문제를 방지하는 것이 volatile키워드입니다.
변수를 volatile로 선언하면 메인 메모리 영역을 참조하게 되므로 다른 스레드라도 같은 메모리 주소를 참조하게 됩니다.
🧐 volatile의 문제
Multi Thread 환경에서 여러개의 Thread가 write하는 상황이라면 race condition을 해결할 수 없습니다.
예시
- Thread-1이 값을 읽어 1을 추가하는 연산을 진행한다.
- 추가하는 연산을 했지만 아직 Main Memory에 반영되기 전 상황이다.
- Thread-2이 값을 읽어 1을 추가하는 연산을 진행한다.
- 추가하는 연산을 했지만 아직 Main Memory에 반영되기 전 상황이다.
- 두 개의 Thread가 1을 추가하는 연산을 하여 최종결과가 2가 되어야 하는 상황이지만?
- 각각 결과를 Main Memory에 반영하게 된다면 1만 남는 상황이 발생하게 된다.
이럴 경우에는 synchronized를 사용하여 원자성(atomic)을 보장해야 합니다.
🧐 Mutual Exclusion & Visibility
Mutual Exclusion: 하나의 코드 블록은 하나의 스레드 또는 프로세스만 실행할 수 있다는 것을 의미합니다.
Visibility: 한 스레드가 공유 데이터를 변경하면 다른 스레드에서도 볼 수 있음을 의미합니다.
volatile은 Visibility만을 지원하며, 따라서 위와 같이 여러 Thread가 write하는 상황 race condition이 발생할 수 있는 것입니다.
그에 반해 synchronized는 Mutual Exclusion과 Visibility 모두 지원합니다.
🧐 volatile의 Full Visibility
volatile은 volatile이 붙은 변수만을 메인 메모리에 기록하는 것이 아닌, 해당 변수와 함께 보여지는 모든 변수가 메인 메모리에 기록됩니다.
다음과 같이 설명되어 있습니다.
Thread A가 volatile 변수에 write 하는 경우, volatile 변수 외에도 Thread A가 볼 수 있는 모든 변수들은 메인 메모리에 함께 기록되고, Thread B는 그 최신 값을 볼 수 있습니다.
Thread A가 volatile 변수를 read 하는 경우, Thread A에 표시되는 모든 변수는 메인 메모리에서도 다시 읽힙니다.
예시 코드는 다음과 같습니다.
public class YMD {
private int years;
private int months;
private volatile int days;
public void update(int years, int months, int days){
this.years = years;
this.months = months;
this.days = days;
}
public int totalDays() {
int total = this.days;
total += months * 30;
total += years * 365;
return total;
}
}
update() 시 Full Visibility를 지원한다는 것은 days 변수에 값이 쓰이면, 해당 쓰레드에서 볼 수 있는 모든 변수들 또한 메인 메모리에 기록된다는 것을 의미합니다.
즉 years와 months도 메인 메모리에 기록됩니다.
totalDays() 시에도 days 변수를 읽어오면서 years, months 값도 메인 메모리에서 읽어옵니다.
🧐 명령어 Reordering으로 발생하는 문제
JVM과 성능을 향상시키기 위해 코드의 실행 동작을 바꾸지 않는 선에서 프로그램의 명령어 순서를 변경할 수 있습니다.
아래와 같은 프로그램을 생각해 보도록 하겠습니다.
public class Exchanger {
private int val1 = 0;
private int val2 = 0;
private volatile int val3 = 0;
public void setValues(Values source) {
this.val1 = source.getVal1();
this.val2 = source.getVal2();
this.val3 = source.getVal3();
}
public void getValues(Values dest) {
dest.setVal3(this.val3);
dest.setVal1(this.val1);
dest.setVal2(this.val2);
}
}
setValues에서는 Full Visibilty를 이용하기 위해 val3을 마지막에 write 하며,
getValues에서는 맨 처음 read 함으로써 val1과 val2 모두 최신 값을 쓰고 읽을 수 있게 코드를 작성하였습니다.
그러나 JVM에서 다음과 같이 명령어를 reordering 했다고 생각해 보도록 하겠습니다.
public class Exchanger {
private int val1 = 0;
private int val2 = 0;
private volatile int val3 = 0;
public void setValues(Values source) {
this.val2 = source.getVal2();
this.val3 = source.getVal3(); // val3 write -> val1도 같이 메인 메모리에 write
this.val1 = source.getVal1(); // val1은 메인 메모리에 바로 써지지 않을 수 있음
}
public void getValues(Values dest) {
dest.setVal1(this.val1); // val1은 메인 메모리에서 읽히지 않았을 수도 있음
dest.setVal3(this.val3); // val3을 읽으면서 val1과 val2를 같이 읽음
dest.setVal2(this.val2); // 메인 메모리에서 읽힌 최신 val2 세팅
}
}
위 경우 setValues 에서는 val1이 메인 메모리에 어느 시점에 저장될지에 대한 보장이 없으며,
마찬가지로 getValues 에서도 val1이 메인 메모리에서 읽힐거라는 보장 또한 없습니다.
이렇듯 volatile을 사용하면 reordering이 문제가 될 수 있는데, 이를 방지하기 위해 자바의 volatile은 happens befored을 보장합니다.
🧐 happens before
JVM에서 명령어를 reordering하는 경우,
volatile 변수에 대한 쓰기 명령 이전의 명령들은 reordering 이후에도 volatile 변수에 대한 쓰기 명령 이전에 실행되도록 유지합니다.
volatile 변수에 대한 읽기 명령 이후의 명령들은 reordering 이후에도 volatile 변수에 대한 읽기 명령 이후에 실행되도록 유지합니다.
즉 위의 예시와 같이 reordering이 발생하지 않는다는 것을 의미합니다.
🧐 Volatile을 통한 long과 double의 원자화
JVM은 데이터를 4바이트(32비트) 단위로 처리하기 때문에, int와 int보다 작은 타입들은 한번에 읽거나 쓰는 것이 가능합니다.
즉 단 하나의 명령어로 읽기와 쓰기 작업이 가능하다는 것입니다.
하나의 명령어는 더 이상 나눌 수 없는 작업의 최소단위이므로, 작업의 중간에 다른 쓰레드가 끼어들 틈이 없습니다.
그러나 크기가 8바이트인 long과 double 타입은 하나의 명령어로 값을 읽거나 쓸 수 없기 때문에 변수의 값을 읽는 과정에서 다른 쓰레드가 끼어들 여지가 있습니다.
다른 쓰레드가 끼어들지 못하게 하기 위하여 변수를 읽고 쓰는 모든 문장을 synchronized 블럭으로 감쌀 수도 있지만 volatile을 통해 더 간단히 해결할 수 있습니다.
변수 선언 시 volatile을 사용하므로써 해당 변수에 대한 읽거나 쓰기 작업이 원자화됩니다.
예시로 AtomicLong 역시 long 값을 volatile을 통해 원자화하였습니다.
🧐 정리
- volatile 은 Main Memory에 read & write를 보장하는 키워드입니다.
- Multi Thread 환경에서 하나의 Thread만 Read&Write 하고, 다른 Thread들은 Only Read가 보장되는 경우에 사용합니다.
- 만일 여러 Thread가 Write 하는 상황이라면 `synchronized`를 사용하여 원자성(atomic)을 보장해야 합니다.
Reference
https://www.youtube.com/watch?v=nhYIEqt-jvY
'☕️ Java > 기본' 카테고리의 다른 글
[Java] 슈퍼 타입 토큰 (0) | 2022.01.03 |
---|---|
[Java] 제네릭에 대하여 (타입이레이저, 변성) (2) | 2022.01.02 |
[JAVA] 자바의 표준 함수형 인터페이스 (0) | 2021.12.22 |
[JAVA] Comparator, Comparable (0) | 2021.12.22 |
[JAVA] Arrays의 메서드 (배열의 복사, 채우기, 정렬, 검색, 비교(deepEquals)) (0) | 2021.12.19 |