※ 이 글은 블로그 주인 본인의 volatile 공부를 위해 Jakob Jenkov 님의 Java Volatile Keyword 글을 번역해본 글입니다.
Java의 volatile 키워드는 Java 변수를 "메인 메모리에 저장 중"으로 표시하기 위해 사용된다. 더 정확히 말하자면 휘발성 변수의 모든 읽기는 CPU 캐시가 아닌 컴퓨터의 메인 메모리에서 읽혀지며 휘발성 변수에 대한 모든 쓰기도 CPU 캐시뿐만 아니라 메인 메모리에 기록된다.
사실, Java 5 이후로 volatile 키워드는 단순히 휘발성 변수가 메인 메모리에 기록되고 읽히는 것 그 이상을 보장한다. 이것은 뒤에 이어지는 섹션에서 확인해보자.
변수의 가시성 문제
Java volatile 키워드를 사용하면 쓰레드에 걸쳐 변수의 변경사항을 확인할 수 있다. 이것은 다소 추상적으로 들릴 수 있으니 좀 더 자세히 보자.
멀티 쓰레드 애플리케이션에서 각 쓰레드는 성능상의 이유로 메인 메모리에서 CPU 캐시로 변수를 복사할 수 있다. 그리고 쓰레드는 변수를 읽을 때도 메인 메모리까지 접근하지 않고 CPU 캐시에서 읽을 수 있다. 흐름은 아래 그림과 같다.
- 변수에 값을 쓰는 경우: 쓰레드 -> CPU캐시 -> 메인 메모리
- 변수의 값을 읽는 경우: 메인 메모리 -> CPU 캐시 -> 쓰레드
그런데 Java Virtual Machine(JVM)은 메인 메모리에서 CPU 캐시로 데이터를 읽어들이거나 CPU 캐시에서 메인 메모리로 데이터를 쓰는 시기를 보장하지 않는다. 이로 인해 몇 가지 문제가 발생할 수 있는데, 다음 섹션에서 설명하겠다.
아래의 counter 변수를 두개의 쓰레드가 공유한다고 가정해보자.
public class SharedObject {
public int counter = 0;
}
쓰레드1만이 counter 변수를 증가시키고 읽는 것은 쓰레드1과 쓰레드2 모두 할 수 있다고 가정해보자.
counter 변수가 volatile로 선언되지 않은 경우 counter 변수의 값이 CPU 캐시에서 메인 메모리로 다시 기록되는 시점에 대한 보장은 없다. 즉, CPU 캐시의 카운터 변수 값이 메인 메모리와 동일하지 않을 수 있다. 이 상황은 아래 그림과 같다.
- 쓰레드1이 counter 변수를 0에서 7로 변경했다. CPU 캐시에만 변경된 값이 쓰이고 메인 메모리에는 기록되지 않은 시점.
- 메인 메모리에는 아직 최신 값이 반영되지 않았기 때문에 쓰레드2는 이전 값인 0을 읽어들인다.
쓰레드가 변경한 값이 아직 메인 메모리에 기록되지 않았기 때문에 다른 쓰레드가 변수의 최신 값을 볼 수 없는 문제를 "가시성" 문제라고 한다. 한 쓰레드의 업데이트는 다른 쓰레드에 표시되지 않는 문제다.
Java volatile의 가시성 보장
변수의 가시성 문제를 해결하기 위해 Java의 volatile 키워드가 나왔다. counter 변수를 volatile로 선언하면 counter 변수에 값을 쓸 때 즉각 메인 메모리에 기록된다. 또한 counter 변수를 읽을 때 항상 즉시 메인 메모리로부터 읽혀진다.
counter 변수에 volatile 선언은 다음과 같다.
public class SharedObject {
public volatile int counter = 0;
}
따라서 volatile 변수를 선언하면 해당 변수에 대한 다른 쓰레드의 가시성이 보장된다.
하나의 쓰레드(T1)가 counter를 수정하고 다른 쓰레드(T2)가 counter를 읽는 시나리오라면 counter 변수에 volatile을 선언했을 때 T2의 가시성을 보장하기에 충분하다.
그러나 T1과 T2가 모두 counter 변수를 증가시킨다면 counter 변수를 volatile로 선언하는 것으로는 충분하지 않다. 그것은 뒤에서 설명하겠다.
volatile의 완전한 가시성 보장
사실, Java volatile 키워드의 가시성 보장은 volatile 변수 그 자체를 넘어선다. 가시성 보장은 다음과 같다.
- Thread A가 volatile 변수에 쓰면, volatile 변수 외에 Thread A가 볼수 있는 모든 변수는 메인 메모리에 함께 기록되고 Thread B는 그 최신 값을 볼 수 있다.
- Thread A가 volatile 변수를 읽을 경우, volatile 변수를 읽을 때 Thread A에 표시되는 모든 변수는 메인 메모리에서도 다시 읽힌다.
아래 코드를 예로 보자.
public class MyClass {
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;
}
}
udpate() 메소드는 세 개의 변수를 쓰는데, 그 중 days 변수만이 volatile로 선언되어 있다.
완전한 volatile 가시성 보장은 days 변수에 값이 쓰이면, 쓰레드가 볼 수 있는 모든 변수들도 또한 메인 메모리에 기록된다는 것을 의미한다. 즉, years와 months 변수도 메인 메모리에 기록된다.
다음은 년, 월, 일을 읽어들이는 코드이다.
public class MyClass {
private int years;
private int months
private volatile int days;
public int totalDays() {
int total = this.days;
total += months * 30;
total += years * 365;
return total;
}
public void update(int years, int months, int days){
this.years = years;
this.months = months;
this.days = days;
}
}
totalDays() 메소드는 days 변수를 읽어들이는 것으로 시작한다. volatile의 완전한 가시성 보장에 따라 days 변수를 읽을 때 years, months 값도 메인 메모리에서 읽어들인다. 따라서 위 순서대로 days, months, years의 가장 최신의 값을 확인 할 수 있다.
명령어 재정렬 문제
Java VM과 CPU는 성능상의 이유로 프로그램의 명령어 순서를 변경할 수 있다. 다음 예를 보자.
int a = 1;
int b = 2;
a++;
b++;
이 명령어들은 프로그램의 의미를 잃지 않고 다음의 순서로 재배열할 수 있다.
int a = 1;
a++;
int b = 2;
b++;
그러나 변수 중 하나가 volatile 변수인 경우 명령어 순서 변경은 문제가 된다. MyClass 클래스를 예시로 살펴보자.
public class MyClass {
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;
}
}
update() 메소드가 days 변수에 값을 기록하면, years와 months 변수들도 메인 메모리에 값이 새롭게 기록된다. 그러나, 만약 Java VM이 다음과 같이 명령어를 재정렬하면 어떻게 될까.
public void update(int years, int months, int days){
this.days = days;
this.months = months;
this.years = years;
}
이번에는 days 변수가 가장 먼저 수정된다. 그리고 years와 months 변수는 days 변수가 수정될때는 아직 수정되기 전이다. 하지만 days는 volatile 변수이기 때문에 값이 수정될때 years와 months 변수도 함께 메인 메모리에 기록된다. 따라서 years와 months는 수정 되어야 할 새로운 값이 아닌 기존 값이 메인 메모리에 기록된다. 재배열된 명령어의 의미가 변경된 것이다.
Java에는 다음 섹션에서 볼 수 있듯이 이 문제에 대한 해결책이 있다.
Java volatile의 Happens-Before 보장
명령어 순서 변경 문제를 해결하기 위해 Java의 volatile 키워드는 가시성 보장과 더불어 "Happens-Before" 보장을 제공한다. Happens-Before 보장이 무엇일까. 원문을 번역해봐도 이해가 잘 가지 않아 정리가 잘 되어있는 다른 분의 블로그를 참고했다.
(참고: https://parkcheolu.tistory.com/16)
"volatile 변수에 대한 읽기/쓰기 명령은 JVM 에 의해 재정리되지 않음을 보장한다는 의미이다. volatile 변수에 대한 읽기/쓰기 명령을 기준으로, 이 변수 전에 존재하는 다른 명령들은 자기들끼리 얼마든지 재정리 될 수 있다. 그리고 이 변수 뒤에 존재하는 다른 명령들 또한 자기들끼리 재정리 될 수 있다. 다만, volatile 변수에 대한 명령 이전/이후에 존재한다는 그 전제는 반드시 지켜진다."
volatile이 항상 충분한 것은 아니다.
volatile 키워드가 volatile 변수의 모든 읽기를 메인 메모리에서 직접 읽어들이고 volatile 변수에 대한 모든 쓰기가 직접 메인 메모리에 기록된다고 해도 여전히 충분하지 않은 상황이 존재한다.
쓰레드1만 공유 변수에 쓰는 상황에서 해당 변수를 volatile로 선언하면 쓰레드2가 항상 최신 값을 볼 수 있습다.
즉, 하나의 쓰레드만 쓰는 역할을 한다면 멀티 쓰레드간의 가시성이 보장된다.
또한, 변수에 기록되는 새 값이 이전 값에 의존하지 않는 경우도 있다. 공유 volatile 변수에 업데이트할 값을 위해 쓰레드가 현재 값을 먼저 읽어들일 필요가 없는 케이스다.
하지만 쓰레드가 volatile 변수를 메인 메모리에서 읽은 값을 기반으로 새 값을 생성해야 하는 상황이라면 volatile 변수는 더 이상 완전한 가시성을 보장하기에 충분하지 않다.
volatile 변수를 읽고 새 값을 쓰는 사이의 짧은 시간 간격은 여러 쓰레드가 volatile 변수의 동일한 값을 읽고 변수에 대한 새 값을 생성하고 쓸 때 경쟁 상태(race condition)를 생성한다. 그리고 값을 메인 메모리에 쓸 때 서로의 값을 덮어 쓰는 상황이 일어나게 된다.
때문에 여러 쓰레드가 동일한 counter를 증가시키는 상황은 volatile 변수만으로는 충분하지 않은 상황이다. 다음 상황을 예로 보자.
쓰레드1이 값이 0인 counter 변수를 CPU 캐시로부터 읽고, 이를 1로 증가시킨 후 메인 메모리에 다시 쓰기 전에 쓰레드2도 변수값이 여전히 0인 counter 변수를 메인 메모리로부터 CPU 캐시에 읽어들인다. Thread2도 마찬가지로 counter를 1로 증가시킨다.
쓰레드1과 쓰레드2는 이제 실질적으로 동기화에서 멀어졌다. 공유 counter 변수의 실제 값은 2여야 하지만 각 쓰레드는 CPU 캐시에 있는 변수의 값을 메인 메모리에 옮기더라도 1이 될 것이다. 엉망진창이다! 쓰레드가 공유 counter 변수에 대한 값을 결국 메인 메모리에 다시 쓰더라도 값이 잘못될 수 있다.
언제 volatile이 적절한가?
앞서 언급했듯이, 두개의 쓰레드가 공유 변수에 대해 읽기/쓰기를 모두 한다면 volatile 키워드를 사용하는 것만으로는 충분하지 않다. volatile 변수의 읽기/쓰기는 다른 쓰레드의 접근을 차단하지 않는다. 때문에 중요한 변수의 경우라면 읽기 및 쓰기에 대한 원자성을 보장하기 위해 synchronized 키워드를 사용해야 한다.
동기화된 블록의 대안으로 java.util.concurrent 패키지에 있는 많은 atomic 데이터 타입을 사용할 수 있다. 예를 들어 AtomicLong이나 AtomicReference가 있다.
결론적으로, 한 쓰레드만 volatile 변수 값을 쓰고, 다른 쓰레드는 읽기만 하는 경우라면 volatile 변수에 기록된 최신 값이 항상 보장된다. 하지만 둘 다 읽기/쓰기를 모두 한다면 더 이상 동기화가 보장된다고 확신할 수는 없다.
volatile의 성능 고려 사항
volatile 변수를 읽고 쓰면 변수가 메인 메모리에서 읽히거나 쓰여진다. 메인 메모리에서 읽고 쓰는 것은 CPU캐시에 엑세스하는 것보다 비용이 더 비싸다. volatile 변수에 엑세스하면 일반적인으로 JVM에서 성능 향상을 위해 하는 명령어 재정렬도 방지될 수 있다. 따라서 변수의 가시성을 실제로 적용해야 하는 경우에만 volatile 변수를 사용해야 한다.
'개발 > Java' 카테고리의 다른 글
JAVA 버전별 정리 (0) | 2021.07.22 |
---|