본문으로 건너뛰기
김신건의 로그

[Java] volatile

· 수정 · 📖 약 3분 · 1,133자/단어 #java #concurrency #memory-model #jmm #volatile #happens-before
volatile, java volatile, Java volatile, volatile keyword, volatile 키워드

정의

volatile 은 Java 의 키워드. 필드에 붙이면 두 가지를 보장한다.

  1. 가시성 (visibility): 한 스레드의 volatile 쓰기가 다른 모든 스레드에 즉시 보인다. CPU 캐시에 머무르지 않고 메인 메모리에 즉시 반영.
  2. 순서 (ordering): volatile 읽기 / 쓰기 주변의 다른 메모리 연산이 재배치 (reorder) 되지 않는다. [JMM (Java Memory Model)] 의 happens-before 관계 확립.

volatile상호 배제 (mutual exclusion) 를 보장하지 않는다. volatile inti++ 를 해도 atomic 하지 않다. 이걸 헷갈리면 잘못된 동시성 코드가 된다.

왜 필요한가, 가시성 문제

기본적으로 각 스레드는 변수를 자기 CPU 의 register 나 캐시에 복사 해 둘 수 있다. JIT 컴파일러도 적극적으로 캐시한다.

class Flag {
    boolean stop = false;     // ← volatile 아님

    void run() {
        while (!stop) {        // 캐시된 값을 계속 봄
            doWork();
        }
    }
}

// 다른 스레드
flag.stop = true;             // ❌ run() 이 영원히 못 볼 수 있음

stop 이 메인 메모리에 기록돼도 run() 을 도는 스레드는 자기 캐시에 false 를 들고 있어서 무한 루프 가 된다. 실제 production 에서 종종 만나는 버그.

class Flag {
    volatile boolean stop = false;  // ✓ volatile

    void run() {
        while (!stop) {              // 매번 메인 메모리 확인
            doWork();
        }
    }
}

이제 stop = true 가 즉시 보인다.

happens-before 의 핵심

volatile 변수 v 에 대해:

v 의 쓰기는 v 의 같은 변수에 대한 이후 모든 읽기 보다 happens-before.

이게 진짜로 중요한 함의:

쓰기 스레드가 v 쓰기 이전 에 수행한 모든 작업은, 읽기 스레드가 v 를 읽은 이후 의 작업에 모두 보인다.

volatile 한 변수의 쓰기/읽기는 그 주변 모든 메모리 연산 의 동기화 지점이 된다.

class Publisher {
    int x = 0;                    // 평범한 필드
    int y = 0;
    volatile boolean ready = false;  // ← volatile

    // Writer thread
    void publish() {
        x = 42;
        y = 100;
        ready = true;            // ready 쓰기 = barrier
    }

    // Reader thread
    void consume() {
        if (ready) {              // ready == true 를 본 순간
            // x == 42 와 y == 100 도 보장된다
            System.out.println(x + ", " + y);
        }
    }
}

이 패턴이 safe publication 의 기초.

volatile vs synchronized

특성volatilesynchronized
가시성
원자성 (read/write)✓ (단일 read/write)
원자성 (복합 연산)✗ (i++ 안 됨)
상호 배제
비용매우 낮음중간 (락 acquire/release)
블로킹✗ (lock-free)✓ (대기 가능)

CAUTION

volatile int counter; counter++; 는 thread-safe 가 아니다. 읽기-수정-쓰기 가 세 단계로 나뉘어 그 사이 다른 스레드가 끼어들 수 있다. atomic counter 가 필요하면 AtomicInteger.

언제 volatile 이 충분한가

  • 단일 read 또는 단일 write 가 atomic 으로 충분. 예: 상태 플래그, 캐시된 값의 참조 교체.
  • 다른 변수와 invariant 가 얽혀 있지 않다. volatile 은 그 변수 하나만 보장.
  • 읽기 매우 많음 + 쓰기 매우 적음. lock-free 라 읽기가 빠르다.

대표 사용 사례:

// 1. 상태 플래그
class Worker {
    private volatile boolean shouldStop = false;
    public void stop() { shouldStop = true; }
    public void run() { while (!shouldStop) { ... } }
}

// 2. Double-Checked Locking (DCL)
class Singleton {
    private static volatile Singleton instance;
    public static Singleton getInstance() {
        if (instance == null) {                   // 1st check (no lock)
            synchronized (Singleton.class) {
                if (instance == null) {           // 2nd check
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

// 3. 참조 교체 (예: CopyOnWriteArrayList 의 array)
class CowList<E> {
    private volatile Object[] array;
    public Object[] getArray() { return array; }     // lock-free read
    public void setArray(Object[] a) { array = a; }  // visible write
}

세 번째 패턴이 CopyOnWriteArrayList 의 핵심.

volatile 의 함정

1. i++ 같은 복합 연산 atomic 아님

class Counter {
    private volatile int n = 0;
    public void increment() {
        n++;   // ❌ atomic 아님 (read → +1 → write 분리)
    }
}

해법: AtomicInteger (CAS 기반) 또는 synchronized 메서드.

2. 여러 변수 간 invariant 보호 못 함

class Range {
    private volatile int low;
    private volatile int high;
    public void set(int l, int h) {
        low = l;   // ← 사이 시간
        high = h;
    }
    // 다른 스레드가 그 사이 set() 를 또 호출하면 low > high 일 수 있다
}

해법: synchronized 또는 ReentrantLock 으로 두 쓰기 묶기.

3. JIT 최적화는 막을 수 있지만, 알고리즘 동시성 문제는 못 막는다

volatile 을 붙이면 컴파일러가 “이 변수의 캐시는 못 쓴다” 고 인식해 매번 메모리 접근. 하지만 그뿐이다.

64-bit 값과 volatile

JVM 명세는 long, double 의 단일 read/write 가 64-bit OS 에서는 atomic 이지만 32-bit OS 에서는 두 번에 나뉠 수 있다고 한다. volatile long, volatile double 은 32-bit 환경에서도 atomic.

요즘은 거의 64-bit 환경이라 차이가 없지만, AtomicLong 을 쓰는 게 안전.

JMM 메모리 배리어

JVM 은 volatile 쓰기/읽기 주변에 메모리 배리어 명령어를 삽입한다.

위치배리어효과
volatile 쓰기 전StoreStore이전 쓰기가 먼저 보이도록 순서 고정
volatile 쓰기 후StoreLoad쓰기를 메인 메모리로 flush
volatile 읽기 후LoadLoad이후 읽기 재배치 차단
volatile 읽기 후LoadStore이후 쓰기 재배치 차단
flowchart TD
    WA["일반 쓰기: x=42, y=100"] -->|"StoreStore barrier"| VW["volatile ready=true"]
    VW -->|"StoreLoad barrier (flush)"| MM["Main Memory"]
    MM -->|"LoadLoad barrier"| VR["volatile ready 읽기"]
    VR -->|"LoadLoad barrier"| RA["일반 읽기: x, y 보임"]

CPU 수준에서는 mfence (x86) 또는 dmb ish (ARM) 명령어로 구현.

대안 비교

방법원자성가시성복합 연산상호 배제비고
volatileread/write 만lock-free, 최소 오버헤드
synchronizedbiased lock 최적화
AtomicIntegerCAS 기반compare-and-swap
LongAdderadd 만고성능 카운터, 스트라이프
ReentrantLocktryLock, Condition 지원

용도별 선택 기준:

  • 단순 상태 플래그 → volatile boolean
  • 단조 증가 카운터, 경쟁 심함 → LongAdder
  • CAS 기반 원자 연산 → AtomicInteger
  • 복합 임계 구역 보호 → ReentrantLock

VarHandle (Java 9+)

sun.misc.Unsafe 의 공개 대체제. volatile 과 동일한 메모리 접근 모드를 지원하면서 배열 요소, 필드 모두 접근 가능.

import java.lang.invoke.MethodHandles;
import java.lang.invoke.VarHandle;

class AtomicCounter {
    private volatile int value;

    private static final VarHandle VALUE;
    static {
        try {
            VALUE = MethodHandles.lookup()
                .findVarHandle(AtomicCounter.class, "value", int.class);
        } catch (ReflectiveOperationException e) {
            throw new ExceptionInInitializerError(e);
        }
    }

    public boolean compareAndSet(int expected, int newValue) {
        return VALUE.compareAndSet(this, expected, newValue);
    }

    public int getAndIncrement() {
        return (int) VALUE.getAndAdd(this, 1);
    }
}

접근 모드 (메모리 순서 보장 수준):

모드보장
PLAIN기본 (순서 보장 없음)
OPAQUE단일 변수 일관성
ACQUIRE / RELEASE방향성 barrier (단방향)
VOLATILEvolatile 동등, 양방향 barrier

참고

이 글의 용어 (5개)
[Java] AtomicIntegerjava
정의 는 lock-free 로 원자성을 보장 하는 래퍼. 내부적으로 CAS (Compare-And-Swap) 를 사용해 락 없이 atomic 한 read-modify-write …
[Java] CopyOnWriteArrayListjava
정의 는 쓰기 시 배열 전체를 복사 하는 thread-safe 구현. 읽기에는 lock 이 전혀 없고, 쓰기에는 으로 직렬화한다. (JSR-166) 의 컬렉션. 읽기 압도적 다,…
[Java] LongAdderjava
정의 는 고경합 카운터 를 위한 자료구조. JDK 1.8 도입. 보다 동시 증가 throughput 이 압도적으로 높다. 원리는 striped counter (분산 누적). 여러…
[Java] ReentrantLockjava
정의 는 키워드와 같은 상호 배제 (mutual exclusion) 를 제공하는 클래스 기반 락. JSR-166 (Java 5) 에서 추가됐다. "재진입 (reentrant)" …
[Java] ThreadLocaljava
정의 는 각 스레드마다 독립된 변수 사본을 가지게 해 주는 컨테이너. 로 접근하며, 한 스레드의 값은 다른 스레드에서 볼 수 없다. 쓰임은 두 가지: (1) 같은 인스턴스를 여러…

💬 댓글

사이트 검색 / 명령어

검색

스크롤 = 확대/축소 · 드래그 = 이동 · 0 = 원래 크기 · ESC = 닫기