JVM, Java Memory Model 및 CPU: x86에서는 작동하고 ARM에서는 왜 깨지는가

발행: (2025년 12월 15일 오전 11:17 GMT+9)
7 min read
원문: Dev.to

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();

단일 연산처럼 보이지만 실제로는 다음과 같은 단계가 일어납니다:

  1. 메모리 할당;
  2. 필드 초기화(생성자);
  3. 레퍼런스 공개(instance 가 객체를 가리키게 함).

동기화가 없으면 JVM/CPU 가 실제로 3단계가 2단계보다 먼저 다른 스레드에 보이도록 허용할 수 있습니다. 따라서 두 번째 스레드는 다음과 같이 실행할 수 있습니다:

if (instance != null) {
    instance.doSomething();
}

instancenull 은 아니지만, 객체의 필드가 아직 “기본값”(0/null) 상태일 수 있습니다. double‑checked locking 같은 패턴에서 실제 운영 환경에 문제를 일으킨 사례가 있습니다.

volatile 사용

private static volatile MyObject instance;

레퍼런스를 volatile 로 선언하면 두 가지 중요한 보장이 추가됩니다:

  1. 가시성volatile 읽기는 최신 값을 보장합니다(지역 캐시에 “갇히지 않음”).
  2. 순서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 관계가 없으면, 아키텍처와 운에 맡기는 셈입니다.

Back to Blog

관련 글

더 보기 »

Java의 역사

Origins Java는 1991년 James Gosling에 의해 Green Project라는 연구 이니셔티브의 일환으로 시작되었습니다. 이 프로젝트의 주요 목표는 ...