명령 재정렬을 막고 변경을 다른 코어가 보도록 강제하는 장치.
원자성과 다른 문제다
-
원자성 — 연산이 쪼개지지 않는가 (counter++ 가 통째로 실행되는가)
-
가시성 — 내가 쓴 값을 다른 코어가 보는가
-
둘은 별개다 — 원자적이어도 안 보일 수 있고, 보여도 원자적이지 않을 수 있다
왜 안 보이나
- 각 코어가 자기 캐시에 쓴다 (아직 다른 코어에 전파되지 않음)
- 스토어 버퍼에 쌓여 있다
- 컴파일러가 레지스터에 올려 두고 메모리에 안 쓴다
- CPU 가 명령 순서를 바꿔 실행한다 (out-of-order)
단일 스레드에서는 결과가 같아 보이므로 이 최적화가 허용된다 문제는 다른 스레드가 볼 때다
배리어가 하는 일
"이 지점 이전의 메모리 연산이 이후 연산보다 먼저 보이게 하라"
종류
- LoadLoad · StoreStore · LoadStore · StoreLoad
- StoreLoad 가 가장 비싸다 (스토어 버퍼를 비워야 한다)
언어 수준에서
volatile int flag; // 자바: 읽기·쓰기에 배리어가 삽입된다
// 가시성 + 재정렬 금지. 원자성은 보장하지 않는다
synchronized // 진입·해제 시 배리어. 상호배제 + 가시성
- volatile 로는 counter++ 를 못 고친다 (읽기·증가·쓰기 세 단계)
- AtomicInteger 나 락이 필요하다
이중 검사 잠금이 깨지는 이유
-
instance = new Singleton() 은
- ① 메모리 할당 ② 생성자 실행 ③ 참조 대입
-
② 와 ③이 재정렬되면 다른 스레드가
-
'null 은 아니지만 생성자가 안 끝난' 객체를 본다
-
volatile 이 재정렬을 막아 해결한다
캐시 일관성이 있는데 왜 안 보이나
흔한 오해: "MESI 로 캐시가 일관되니 가시성 문제는 없다"
캐시는 일관적이다. 문제는 그 앞단이다
스토어 버퍼 쓴 값이 캐시에 도달하기 전에 잠시 쌓인다
무효화 큐 무효화 메시지를 나중에 처리한다
즉 '캐시에 들어간 뒤' 는 일관되지만 '캐시에 들어가기 전' 이 비동기다
- 배리어는 이 버퍼·큐를 비우게 만든다
아키텍처별 강도
-
x86(TSO) — 비교적 강하다. StoreLoad 재정렬만 허용
- 잘못된 코드가 x86 에서만 우연히 동작하는 일이 흔하다
-
ARM — 훨씬 약하다. 대부분의 재정렬이 허용된다
- 모바일·서버 ARM 으로 옮기면 잠재 버그가 드러난다
-
"내 머신에서는 되던데" 가 동시성에서 특히 위험한 이유
자바 메모리 모델의 happens-before
락 해제 → 이후 같은 락의 획득 volatile 쓰기 → 이후 같은 변수의 volatile 읽기 스레드 시작 전 동작 → 그 스레드 안의 모든 동작 스레드의 모든 동작 → 다른 스레드의 join() 반환
이 관계가 성립하면 앞의 쓰기가 뒤에서 반드시 보인다 "어느 하드웨어에서든" 을 언어가 보장하는 방식이다
final 필드의 특별 규칙
생성자에서 대입된 final 필드는, 생성자가 끝난 뒤 그 객체 참조를 본 스레드에게 동기화 없이도 올바른 값으로 보인다.
불변 객체를 안전하게 공유할 수 있는 근거이고, 이중 검사 잠금 문제와 짝을 이루는 규칙이다.
면접 함정
- ❌ "volatile이 동기화를 대신한다" → 가시성만이다. 원자성은 별개다.
- ❌ "단일 코어면 문제없다" → 컴파일러 재정렬은 여전히 일어난다.