[Java] volatile 키워드 (feat. Atomic & Concurrent)
😵 서론
멀티 스레드로 인한 동시성 문제를 해결하기 위해 Java는 synchronized 키워드와 Atomic 타입이 있다고 알려져있다. 그런데 Atomic 타입은 왜 Thread-safe하다고 할까? 이 이유에 대해서 찾다가 volatile이라는 키워드에 대해 알게 되었다. 이번 포스팅에서는 해당 키워드에 대해 파헤쳐보려고 한다.
😎 Atomic & Concurrent & volatile
예제를 살펴보기 위해 AtomicInteger의 내부 코드를 가져왔다. 특별할 것 없어 보이지만 value에 volatile이라는 키워드가 붙는다는 점이 눈에 띈다. AtomicBoolean, AtomicLong도 살펴봤는데 모두 volatile 키워드가 붙었다.
public class AtomicInteger extends Number implements java.io.Serializable {
private volatile int value;
}
thread-safe하다고 알려진 컬렉션인 ConcurrentHashMap의 내부도 열어봤더니 마찬가지로 volatile을 사용하고 있었다. (ConcurrentHashMap의 메서드에서는 일부 로직에 synchronized 키워드도 있었지만 ConcurrentLinkedQueue에서는 그마저도 없이 volatile만 사용하고 있었다.)
public class ConcurrentHashMap<K,V> extends AbstractMap<K,V> implements ConcurrentMap<K,V>, Serializable {
// ...
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
volatile V val;
volatile Node<K,V> next;
// ...
}
}
😏 멀티 프로세서 아키텍쳐
volatile 키워드를 자세히 알아보기 앞서 멀티프로세서 아키텍쳐에 대해 잠깐 살펴보자.
프로세서는 프로그램 명령 실행을 담당한다. 따라서 RAM에서 프로그램 명령과 필요한 데이터를 모두 검색해야 한다. CPU는 초당 정말 많은 수의 명령을 수행할 수 있기 때문에 모든 것을 RAM에서 직접 가져오는 것은 효율이 좋지 않다. 이를 개선하기 위해 프로세서는 캐싱과 같은 기능을 사용한다.
아래 그림을 살펴보면 코어마다 각자 캐시를 갖고 있는 것을 확인할 수 있다. 이렇게 되면 캐시 일관성 문제가 발생할 수는 있으나 전체 성능이 향상된다.
위 그림을 Java Memory Model로 생각해보면 아래와 같다.
🙄 Mutual Exclusion vs Visibility
synchronized 키워드와 volatile 키워드의 차이점을 알기 위해 Mutual Exclusion과 Visibility 개념을 설명한다.
- Mutual Exclusion: 한 코드 블록은 하나의 스레드 또는 프로세스만 실행할 수 있음
- Visibility: 한 스레드가 공유 데이터를 변경하면 다른 스레드에서도 볼 수 있음
synchronized 키워드는 Mutual Exclusion과 Visibility 모두 지원한다. 공유 변수 값을 수정하는 블록을 synchronized 처리하면 한 번에 한 스레드만 접근이 가능해진다. 공유 변수 값을 변경하면 메인 메모리에도 저장된다. Visibility만 지원해도 충분한 경우에 synchronized를 사용하는 것은 비효율적인 일이다.
volatile 키워드는 Visiblity만을 지원하고 싶을 때 사용한다. volatile로 지정된 변수의 값은 캐싱되지 않으며 모든 읽기쓰기는 메인 메모리에서 수행된다.
😨 Reordering으로 인한 문제
JVM은 명령의 의미가 동일하게 유지된다면 성능상의 이유로 명령을 재정렬(reordering)할 수 있다.
// before reordering
int a = 1;
int b = 2;
a++;
b++;
// after reodering
int a = 1;
a++;
int b = 2;
b++;
하지만 volatile 키워드를 사용하는 변수의 경우에는 위와 같은 재정렬이 문제가 된다. 아래와 같은 코드의 update() 메서드를 실행하면 volatile이 붙은 days에 의해 앞서 값을 변경한 years, months도 메인 메모리에 쓰여진다.
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;
}
}
하지만 Reordering에 의해 아래와 같이 실행 순서가 변경된다면 years, months는 메인메모리에 반영되지는 않는다.
public void update(int years, int months, int days){
this.days = days;
this.months = months;
this.years = years;
}
volatile 변수를 사용하는 위치에 따라 메모리에 반영되는 타이밍이 다르다. JVM이 마음대로 명령어를 재정렬하면 내가 작성한 명령어의 순서대로, 나의 의도대로 잘 동작하는지 예상할 수 없다. 이 문제를 해결하기 위해 volatile은 Happens-Before을 보장한다.
- When reordering any write to a variable that happened before a write to a volatile, will remain before the write to the volatile variable.
- When reordering any read of a volatile variable that is located before read of some non-volatile or volatile variable, is guaranteed to happen before any of the subsequent reads.
- volatile 변수의 쓰기 작업 전의 쓰기 작업들을 재정렬할 때, volatile 변수 쓰기 작업 전에 실행되도록 유지한다
- volatile 변수의 읽기 작업 후의 읽기 작업들을 재정렬할 때, volatile 변수 읽기 작업 후에 실행되도록 유지한다
참고