본문 바로가기
CS

CS 스터디 (Java) 5 - Volatile 키워드에 대해 설명해주세요.

by gentle-tiger 2025. 5. 15.

Volatile 키워드란? 

volatile 키워드는 Java Memory Model(JMM)에 따라 스레드 간 가시성(visibility)과 명령어 재정렬 방지를 보장하는 경량 동기화 수단입니다. 변수에 volatile을 지정하면, 모든 스레드는 해당 변수의 값을 메인 메모리에서 직접 읽고 쓰게 되며, 변경 사항이 즉시 다른 스레드에 반영됩니다. 또한 volatile write 이후의 연산은 그 이전 연산들이 완료된 상태로 **happens-before 관계를 형성하므로, 특정 실행 순서 보장이 필요한 경우에도 유효합니다.

단, volatile은 원자성(atomicity)을 보장하지 않기 때문에 count++와 같은 복합 연산에서는 적절하지 않습니다. 이 경우에는 synchronized 또는 java.util.concurrent.atomic 패키지의 클래스를 사용해야 합니다.

 

**happens-before 관계란?

: Java Memory Model에서 한 스레드의 작업 결과가 다른 스레드에 반드시 보이도록 보장하는 순서 규칙입니다.

synchronized 와의 비교 

반면 synchronized는 가시성, 원자성, 재정렬 방지를 모두 보장하는 완전한 동기화 수단입니다. synchronized 블록 진입 시 메모리 동기화가 발생하고, 블록을 빠져나오면서 변경된 상태가 메인 메모리에 반영되어 다른 스레드에서도 일관된 상태를 관찰할 수 있습니다. 따라서 복잡한 상태 변경이나 임계 영역 보호에는 synchronized가 적합하고, 단순한 상태 플래그에는 volatile이 적합합니다.

 

volatile과 happens-before 규칙 예시

// Thread 1
sharedVar = 42;
initialized = true; // volatile 변수

// Thread 2
if (initialized) {
    System.out.println(sharedVar); // sharedVar의 값은? 42
}

- 이 예제의 핵심은, Thread 2가 initialized == true인 것을 확인했을 때, happens-before 규칙에 의해 sharedVar == 42라는 값이 확실히 보장된다는 것입니다.

- 그 이유는, volatile 변수에 값을 쓰고, 다른 스레드가 그 값을 읽는 구조에서는 **"volatile write → volatile read"가 happens-before 관계를 형성하기 때문입니다.

- 이 관계가 성립하면, initialized = true보다 먼저 수행된 모든 작업들(sharedVar = 42)도 함께 보이게 됩니다.

- 즉, volatile 변수는 메모리 동기화의 트리거 역할을 하며, 그 변수에 대한 읽기/쓰기는 그 이전 연산까지도 함께 관찰 가능하게 만들어 줍니다.

 

**"volatile write → volatile read"란?

: 한 스레드의 volatile 변수 쓰기가, 다른 스레드의 해당 변수 읽기보다 앞선 순서로 실행되어야 함을 보장하는 순서 규칙을 형성한다는 의미입니다.

 

 

SingleTon에서의 Volatile 사용 예시 

Java에서는 컴파일러(JIT)와 CPU가 성능 최적화를 위해 명령어 재정렬(Instruction Reordering) 을 수행할 수 있습니다.

 

1. 메모리 할당 → new로 객체를 위한 메모리 공간 확보

2. 생성자 실행 → 객체 내부 초기화 (value = 42 등)

3. 참조 연결 → 생성된 객체를 instance 변수에 할당

 

그런데, 이 2번과 3번이 재정렬될 수 있습니다.
즉, 참조를 먼저 할당한 후에 생성자가 실행될 수 있다는 말입니다.

 

public class UnsafeSingleton {
    private static UnsafeSingleton instance;
    public int value;

    private UnsafeSingleton() {
        value = 42; // 생성자에서 초기화
    }

    public static UnsafeSingleton getInstance() {
        if (instance == null) {
            instance = new UnsafeSingleton(); // ❗ 재정렬 발생 가능
        }
        return instance;
    }
}

- 이렇게 되면, 다른 스레드가 instance != null 조건을 통과해도 아직 생성자가 완료되지 않아, value == 0 상태로 접근하게 될 위험이 있습니다. 

 

private static volatile UnsafeSingleton instance;

- 이를 방지하기 위해 instance 변수를 volatile로 선언하면 재렬을 차단하고 happens-before 관계를 보장할 수 있습니다. 

- 이 덕분에 JVM은 2번(생성자 실행)이 끝나기 전에는 3번(참조 할당)을 앞당기지 못하게 됩니다.

- 즉, instance != null 조건을 통과한 경우에는 반드시 생성자 실행이 끝난 객체임이 보장됩니다.

 

 

※ 왜 JVM은 생성자 실행(2단계)보다 참조 연결(3단계)을 먼저 수행하려고 할까?

- 이는 성능 최적화를 위한 재정렬로, CPU는 객체 초기화보다 참조 연결이 빠르다고 판단할 수 있으며,

- 참조를 먼저 연결하면 이후 명령이 해당 참조를 통해 작업을 미리 준비할 수 있습니다.
- 또한 참조를 조기에 연결함으로써 캐시 적중률을 높이고, 파이프라인 효율을 개선할 수 있습니다.

 

 

변수 동기화가 필요한 이유 

- Java의 스레드 모델은 기본적으로 멀티스레드 환경을 전제로 하며, 각 스레드는 자신만의 작업 메모리(캐시)를 가지고 변수의 값을 복사해 사용합니다.

- 즉, 한 스레드가 sharedVar = 42로 값을 바꾸었다 해도, 다른 스레드는 그 변경된 값을 보지 못할 수 있습니다.

- 이러한 문제가 가시성 문제(visibility issue)입니다.

- 이는 JVM이 변수 접근을 성능 최적화하기 위해 로컬 캐시를 사용하거나, 명령어 재정렬을 수행하기 때문에 발생하는 정상적인 동작입니다.

 

 

Java의 변수 동기화 방법 

도구 보장 내용 예시
volatile 가시성 보장, 재정렬 방지 상태 플래그, 싱글톤 초기화
synchronized 가시성 + 원자성 + 재정렬 방지 임계 영역 보호, 복합 연산
AtomicXXX 원자성 보장, 경량 동기화 (CAS 기반) 카운터 증가, 참조 교체

 

 

결론 

volatile 키워드는 Java Memory Model(JMM)에서 정의한 순서 규칙(happens-before)을 기반으로, 스레드 간 변수의 가시성 보장명령어 재정렬 방지를 제공하는 경량 동기화 수단입니다. 이를 통해 객체 초기화 순서를 보장하거나 상태 플래그 전달과 같은 용도에 적합하지만, 원자성을 보장하지 않기 때문에 복합 연산이나 임계 영역 보호와 같은 경우에는 synchronized 또는 AtomicXXX와 같은 더 강력한 동기화 메커니즘이 필요합니다.