자바 언어·플랫폼 용어 사전
동시성visibility · happens-before · JMM · 메모리 배리어

가시성·volatile

한 스레드의 변경이 다른 스레드에 보이는가의 문제. volatile은 가시성과 순서를 보장하지만 원자성은 아니다.

동시성 버그의 뿌리는 두 가지다 — 가시성원자성.

가시성 — 왜 안 보이나

class Worker {
    private boolean running = true;
    void stop() { running = false; }
    void run() { while (running) { /* 일한다 */ } }   // 멈추지 않을 수 있다
}
각 CPU 코어에는 자기 캐시가 있다
  → 스레드 A 가 running=false 로 바꿔도 A 의 캐시에만 반영될 수 있다
  → 스레드 B 는 자기 캐시의 옛 값(true)을 계속 읽는다
  → JIT 이 while(running) 을 while(true) 로 최적화해 버리기도 한다

volatile이 하는 일

private volatile boolean running = true;   // 이제 멈춘다
  • ① 가시성 — 쓰면 즉시 메인 메모리로, 읽으면 항상 메인 메모리에서

  • ② 순서 — 앞뒤 명령의 재정렬을 막는다 (메모리 배리어)

  • 원자성 — 보장하지 않는다 — ← 여기가 핵심

volatile로 안 되는 것

private volatile int count = 0;
void inc() { count++; }        // 여전히 깨진다

count++ 는 세 단계다

  • ① 읽는다 ② 1 더한다 ③ 쓴다

두 스레드가 ①을 동시에 하면 둘 다 같은 값을 읽고 같은 값을 쓴다

  • 한 번의 증가가 사라진다 volatile 은 각 단계를 '보이게' 할 뿐 '나눌 수 없게' 만들지 않는다
private final AtomicInteger count = new AtomicInteger();
count.incrementAndGet();       // CAS 로 원자성까지 보장

happens-before — JMM의 규율

A happens-before B = A 의 결과가 B 에게 반드시 보인다

주요 규칙

  • 같은 스레드 안의 순서
  • unlock → 이후의 lock
  • volatile 쓰기 → 이후의 volatile 읽기
  • Thread.start() → 그 스레드의 첫 동작
  • 스레드의 마지막 동작 → 다른 스레드의 join() 반환

이 관계가 성립하지 않으면 무엇이 보일지 정의되지 않는다.

언제 쓰나

✅ 상태 플래그 (한 스레드가 쓰고 여럿이 읽는다) ✅ 이중 검사 잠금의 인스턴스 필드 ✅ 불변 객체 참조를 안전하게 발행할 때

❌ 증감 · 누적 (원자성 필요 → Atomic 계열) ❌ 여러 필드의 일관성 (락 필요)

면접 함정

  • "volatile이 동기화를 대체한다" → 원자성은 없다. 복합 연산에는 부족하다.
  • "멀티코어라서 생기는 문제" → 컴파일러·JIT의 재정렬로 단일 코어에서도 논리적으로 발생할 수 있다.

재현해 보기

public class VisibilityDemo {
    static boolean running = true;              // volatile 을 빼 본다
    public static void main(String[] a) throws Exception {
        new Thread(() -> { while (running) { } System.out.println("멈춤"); }).start();
        Thread.sleep(1000);
        running = false;
        System.out.println("플래그를 내렸다");
    }
}
// -server 모드에서 "멈춤" 이 영원히 안 찍히는 것을 볼 수 있다

JIT이 while(running)while(true)로 끌어올리는(hoisting) 것이 직접적 원인이다. 캐시 문제이기 이전에 컴파일러 최적화 문제다.

volatile이 배리어를 어떻게 거나

volatile 쓰기 앞  → StoreStore 배리어  (앞의 쓰기가 먼저 보이도록)
volatile 쓰기 뒤  → StoreLoad 배리어   (가장 비싼 배리어)
volatile 읽기 뒤  → LoadLoad · LoadStore 배리어

x86 은 원래 강한 메모리 모델이라 대부분 공짜이고 StoreLoad(lock 접두어 명령) 만 실제 비용이 있다 ARM 은 약한 모델이라 배리어 비용이 더 크다

안전한 발행이라는 개념

// final 필드는 생성자가 끝나는 시점에 보이는 것이 보장된다 (final field semantics)
class Config { final int timeout; Config(int t) { timeout = t; } }
// → 불변 객체는 volatile 없이도 안전하게 공유할 수 있는 이유

함께 보면 좋은 용어

노트에서 맥락과 함께 보기 — 동시성