1. volatile은 왜 필요할까?
멀티스레드 환경에서는 여러 Thread가 하나의 변수를 동시에 사용할 수 있다.
예를 들어 하나의 Thread가 계속 작업을 수행하고 있고, 다른 Thread가 작업을 종료시키는 상황을 생각해보자.
class Worker {
private boolean running = true;
public void run() {
while (running) {
// 작업 수행
}
}
public void stop() {
running = false;
}
}
두 개의 Thread가 다음처럼 동작한다.
Thread A Thread B
while (running) stop()
↓ ↓
작업 계속 running = false
우리는 당연히 Thread B가
running = false;
로 변경하면 Thread A도 변경된 값을 보고 반복문을 종료할 것이라고 생각할 수 있다.
하지만 Java의 멀티스레드 환경에서는 한 Thread가 변경한 값을 다른 Thread가 즉시 볼 수 있다고 보장되지 않는다.
Thread A
running = true
계속 true라고 판단
↑
Thread B
running = false로 변경
즉 Thread B는 값을 변경했는데 Thread A가 그 변경을 제대로 관찰하지 못할 수 있다.
이 문제를 가시성(Visibility) 문제라고 한다.
volatile은 이런 상황에서 사용한다.
2. volatile이란?
Java에서 volatile은 여러 Thread가 공유하는 변수의 가시성과 메모리 순서를 보장하기 위한 키워드이다.
private volatile boolean running = true;
이렇게 선언하면
Thread B
running = false
↓
변경된 값
↓
Thread A
running 읽기
↓
false 확인
처럼 volatile 변수에 대한 변경을 다른 Thread가 올바르게 관찰할 수 있도록 Java Memory Model이 보장한다.
Java 명세에서도 volatile 필드는 여러 Thread가 해당 변수의 값을 일관되게 볼 수 있도록 하는 메커니즘으로 정의한다.
따라서 앞의 코드를
class Worker {
private volatile boolean running = true;
public void run() {
while (running) {
// 작업 수행
}
}
public void stop() {
running = false;
}
}
처럼 변경하면 running을 Thread 간 상태 전달에 사용할 수 있다.
3. 가시성(Visibility)이란?
가시성이란
한 Thread가 변경한 값을 다른 Thread가 볼 수 있는가
를 의미한다.
예를 들어
초기값
running = true
Thread B가
running = false;
를 실행했다고 하자.
가시성이 보장되지 않는다면
Thread A가 보는 값 → true
Thread B가 변경한 값 → false
처럼 Thread마다 관찰하는 값에 차이가 생길 수 있다.
volatile을 사용하면
Thread B
volatile 변수에 false Write
↓
Thread A
volatile 변수 Read
↓
false 확인
처럼 변경사항이 다른 Thread에 보이도록 보장할 수 있다.
여기서 흔히
“
volatile은 항상 메인 메모리에서 값을 읽는다.”
라고 설명하기도 한다.
개념을 처음 이해하기에는 편하지만 정확하게는 Java Memory Model이 volatile Read/Write 사이의 가시성을 보장한다고 이해하는 것이 좋다.
4. happens-before란?
volatile을 이해할 때 함께 등장하는 개념이 happens-before이다.
happens-before 관계가 성립하면 앞에서 수행된 작업의 결과가 뒤의 작업에서 보이도록 보장된다.
Java에서는
volatile 변수에 대한 Write는 이후 같은 volatile 변수에 대한 Read보다 happens-before 관계에 있다.
즉
Thread A
data = 100;
ready = true; // volatile Write
↓ happens-before
Thread B
if (ready) { // volatile Read
data 사용
}
라고 했을 때 ready가 true인 것을 Thread B가 확인했다면, 그 전에 Thread A가 수행한:
data = 100;
같은 작업도 올바르게 관찰할 수 있도록 메모리 순서가 보장된다. Java 명세에서도 happens-before 관계가 있으면 앞선 작업이 뒤의 작업에서 가시적이고 순서상 앞선 것으로 보장된다고 정의한다.
따라서 volatile은 단순히 변수 하나의 최신 값만 확인하기 위한 기능이 아니라 Thread 사이에서 메모리 변경사항을 안전하게 전달하는 데도 중요한 역할을 한다.
5. volatile이면 Thread-Safe할까?
여기서 가장 중요한 부분이다.
volatile
≠
모든 동시성 문제 해결
volatile은 가시성은 보장하지만 여러 연산을 하나의 원자적인 작업으로 만들어주지는 않는다.
예를 들어
private volatile int count = 0;
public void increase() {
count++;
}
라고 해보자.
겉으로 보면
count++;
하나의 연산처럼 보인다.
하지만 실제 의미는 대략
1. count 값을 읽음
2. count + 1 계산
3. 결과를 count에 저장
과 같은 여러 단계이다.
두 Thread가 동시에 실행하면
초기 count = 0
Thread A Thread B
count 읽기 → 0 count 읽기 → 0
+1 +1
count = 1 count = 1
이 될 수 있다.
원래 두 번 증가했으므로
count = 2
가 되어야 하지만 실제 결과는
count = 1
이 될 수 있다.
이를 Race Condition이라고 한다.
volatile을 붙여도 이 문제는 해결되지 않는다.
6. 가시성과 원자성의 차이
따라서 volatile에서 가장 중요하게 구분해야 하는 것이
Visibility
vs
Atomicity
이다.
가시성
Thread A가 값을 변경
↓
Thread B가 변경된 값을 볼 수 있는가?
volatile이 보장한다.
원자성
Read
↓
Modify
↓
Write
같은 여러 동작이 다른 Thread의 간섭 없이 하나의 작업처럼 실행되는가?
를 의미한다.
volatile은 이런 복합 연산의 원자성을 보장하지 않는다.
따라서
volatile int count;
count++;
는 Thread-Safe하지 않다.
7. 그렇다면 count++은 어떻게 해야 할까?
여러 Thread가 동시에 값을 증가시켜야 한다면 다른 동시성 제어 방법이 필요하다.
대표적으로 synchronized를 사용할 수 있다.
private int count = 0;
public synchronized void increase() {
count++;
}
synchronized는 한 번에 하나의 Thread만 해당 영역을 실행하도록 한다.
Thread A
↓
Lock 획득
↓
count++
↓
Lock 해제
Thread B
↓
대기
↓
Lock 획득
따라서 다른 Thread가 중간에 끼어들 수 없다.
또는 AtomicInteger를 사용할 수도 있다.
private AtomicInteger count = new AtomicInteger(0);
public void increase() {
count.incrementAndGet();
}
AtomicInteger는 증가와 같은 연산을 원자적으로 수행할 수 있도록 제공되는 클래스이다.
8. volatile vs synchronized vs AtomicInteger
세 가지를 비교하면 다음과 같다.
| 구분 | volatile | synchronized | AtomicInteger |
|---|---|---|---|
| 가시성 보장 | O | O | O |
| 복합 연산 원자성 | X | O | O |
| Lock 사용 | X | O | 일반적으로 X |
| 상호 배제 | X | O | X |
| 대표 용도 | 상태 값 공유 | 여러 연산 보호 | 원자적 숫자 연산 |
예를 들어
단순 상태 확인
volatile boolean running;
실행 중인가?
종료해야 하는가?
설정이 변경됐는가?
처럼 한 Thread가 상태를 변경하고 다른 Thread들이 그 상태를 확인하는 경우에는 volatile이 적합할 수 있다.
여러 작업을 하나로 보호
if (stock > 0) {
stock--;
}
처럼
확인
+
변경
을 하나의 작업으로 처리해야 한다면 volatile만으로는 부족하다.
synchronized나 Lock 등의 동시성 제어가 필요하다.
단순한 숫자 증가
count++;
를 여러 Thread가 수행해야 한다면
AtomicInteger
같은 Atomic 클래스를 사용할 수 있다.
9. volatile은 언제 사용하면 좋을까?
volatile은 하나의 값을 여러 Thread가 공유하지만 그 값을 이용한 복잡한 연산이 필요하지 않을 때 사용하기 좋다.
대표적인 예가 종료 Flag이다.
private volatile boolean running = true;
public void stop() {
running = false;
}
Thread A
작업 수행
↓
running 확인
↓
계속 실행
Thread B
running = false
Thread A
false 확인
↓
종료
이런 경우에는
true → false
라는 상태 변경 자체가 중요하기 때문에 volatile이 잘 맞는다.
반대로
if (count < 10) {
count++;
}
처럼 현재 값을 읽고 판단한 뒤 다시 수정해야 한다면 여러 Thread가 중간에 끼어들 수 있으므로 volatile만 사용해서는 안전하지 않다.
10. volatile을 사용한다고 Lock이 걸리는 것은 아니다
volatile과 synchronized의 중요한 차이 중 하나이다.
synchronized는
Thread A
Lock 획득
Thread B
대기
처럼 임계 영역에 한 Thread만 접근하도록 **상호 배제(Mutual Exclusion)**를 제공한다.
반면 volatile은
Thread A ─┐
├→ volatile 변수 접근
Thread B ─┘
여러 Thread가 동시에 접근할 수 있다.
대신 변경된 값을 서로 올바르게 볼 수 있도록 한다.
따라서
volatile
→ "값의 변경을 다른 Thread에게 보여준다."
synchronized
→ "한 번에 한 Thread만 작업하게 한다."
라고 먼저 구분하면 이해하기 쉽다.
11. 핵심 정리
volatile에서 가장 중요한 개념은 가시성이다.
일반 공유 변수
Thread A가 값 변경
↓
Thread B가 변경을 본다는 보장 X
volatile 변수
Thread A가 값 변경
↓
Thread B가 변경을 볼 수 있도록 보장
하지만
volatile
≠
원자성 보장
이다.
따라서
volatile boolean running;
처럼 단순한 상태 공유에는 사용할 수 있지만
volatile int count;
count++;
처럼 여러 연산으로 이루어진 작업을 Thread-Safe하게 만들어주지는 않는다.
정리하면
| 개념 | 의미 |
|---|---|
| volatile | 공유 변수의 가시성과 메모리 순서를 보장 |
| Visibility | 한 Thread의 변경을 다른 Thread가 볼 수 있음 |
| Atomicity | 하나의 연산이 중간에 끊기지 않고 수행됨 |
| happens-before | 앞선 작업의 결과가 뒤의 작업에서 보이도록 하는 순서 관계 |
| synchronized | 상호 배제 + 가시성 보장 |
| AtomicInteger | 증가 등의 연산을 원자적으로 수행 |
결국 volatile의 핵심은
여러 Thread가 공유하는 변수의 변경사항을 올바르게 볼 수 있도록 하지만, 복합 연산 자체를 원자적으로 만들어주는 것은 아니다.
라고 이해하면 된다.