JVM, Java Memory Model 및 CPU: x86에서는 작동하고 ARM에서는 왜 깨지는가
Source: Dev.to
소개
“하지만 내 컴퓨터에서는 절대 문제가 안 생겼어.”
아마도 당신의 컴퓨터가 x86이기 때문일 겁니다. ARM으로 바꾸면 “잠자고 있던” 동시성 버그가 나타납니다.
핵심 아이디어: x86은 실무에서 더 “보수적”인 반면, ARM은 더 많은 재배열을 허용합니다. 하드웨어의 “착한” 동작에 의존하는 코드는 x86에서는 동작하지만 ARM에서는 실패할 수 있습니다.
Java Memory Model (JMM)
JMM은 단순히 “스레드가 메모리를 공유한다”는 것만 말하지 않습니다. 다음을 정의합니다:
- 가시성 – 한 스레드가 쓴 값이 다른 스레드에게 언제 보이는가.
- 순서 – 어떤 재배열이 허용되는가.
- Happens‑before – 스레드 간에 실제 보장을 만들어 주는 관계.
두 동작 사이에 happens‑before 관계가 없으면 다음에 대한 보장이 없습니다:
- 최신 값을 볼 수 있다;
- 쓰여진 순서대로 볼 수 있다;
- 객체가 “준비된” 상태를 볼 수 있다.
이것이 많은 “유령” 버그의 원인입니다.
고전적인 예
x = 42;
ready = true;
많은 사람은 ready == true 를 보면 다른 스레드가 반드시 x == 42 를 볼 것이라고 생각합니다. JMM은 동기화 없이도 다른 스레드가 다음과 같이 관찰할 수 있음을 허용합니다:
ready == true
x == 0
왜 그럴까?
x가 캐시/레지스터에 남아 있을 수 있다;- 쓰기 연산이 재배열될 수 있다;
- 다른 스레드가 오래된 값을 읽을 수 있다.
이것이 가장 교활한 버그입니다.
객체의 안전한 공개
instance = new MyObject();
단일 연산처럼 보이지만 실제로는 다음과 같은 단계가 일어납니다:
- 메모리 할당;
- 필드 초기화(생성자);
- 레퍼런스 공개(
instance가 객체를 가리키게 함).
동기화가 없으면 JVM/CPU 가 실제로 3단계가 2단계보다 먼저 다른 스레드에 보이도록 허용할 수 있습니다. 따라서 두 번째 스레드는 다음과 같이 실행할 수 있습니다:
if (instance != null) {
instance.doSomething();
}
instance 가 null 은 아니지만, 객체의 필드가 아직 “기본값”(0/null) 상태일 수 있습니다. double‑checked locking 같은 패턴에서 실제 운영 환경에 문제를 일으킨 사례가 있습니다.
volatile 사용
private static volatile MyObject instance;
레퍼런스를 volatile 로 선언하면 두 가지 중요한 보장이 추가됩니다:
- 가시성 –
volatile읽기는 최신 값을 보장합니다(지역 캐시에 “갇히지 않음”). - 순서 –
volatile은 변수 주변의 특정 재배열을 방지하는 장벽을 생성합니다.
volatile 에 대한 쓰기는 동일한 volatile 에 대한 이후 모든 읽기보다 happens‑before 관계를 가집니다. 실제로, 스레드 B 가 instance(volatile)를 null 이 아닌 값으로 읽었다면, 스레드 A 가 레퍼런스를 공개하기 전에 수행한 모든 쓰기(생성자에서 필드 초기화 포함)를 볼 수 있다는 보장을 얻게 됩니다.
JIT 가 volatile 을 다루는 방식
volatile 쓰기
volatile 에 대한 쓰기를 컴파일할 때 JVM 은 다음을 보장해야 합니다:
- 이전에 있었던 모든 쓰기가 “보류”되거나 보이지 않게 남지 않음;
- 값의 공개가 이전 연산보다 앞서 실행되지 않음.
이는 아키텍처와 JIT 에 따라 적절한 memory fence/barrier 를 삽입함으로써 구현됩니다.
volatile 읽기
volatile 에 대한 읽기를 컴파일할 때 JVM 은 다음을 보장해야 합니다:
- 캐시/레지스터에 남아 있는 오래된 값을 읽지 않음;
- 이후의 읽기 연산이 이 읽기보다 “앞으로” 이동되지 않음.
여기서도 장벽과 적절한 의미를 가진 명령어가 사용됩니다.
x86 vs. ARM
x86 에서는 많은 재배열이 덜 공격적이며 플랫폼이 “자동으로” 도와주는 경우가 많습니다. 이것이 코드가 올바르다는 의미는 아니며, 단지 버그가 나타나지 않을 가능성이 높다는 뜻입니다.
ARM 에서는 허용되는 재배열이 더 많고, 순서와 가시성을 보장하려면 명시적인 동기화가 필요합니다. volatile, synchronized, 락, 원자성을 사용하지 않았다면 ARM 에서는 버그가 드러날 확률이 더 큽니다.
결과: x86 에서는 “정상”인 동일한 프로그램이 부하가 걸릴 때 ARM 에서는 실패할 수 있습니다.
실용적인 규칙 (철학은 제외)
스레드 간에 공유가 있다면 적절한 전략을 선택하세요:
| 전략 | 사용 시점 |
|---|---|
volatile | 간단한 플래그/상태와 레퍼런스의 안전한 공개 |
synchronized / Lock | 불변 조건 및 복합 연산 |
Atomic* | 락 없이 원자적 연산(CAS) 필요, 자체 비용/제한이 있음 |
명시적인 happens‑before 관계가 없으면, 아키텍처와 운에 맡기는 셈입니다.