CodeGym /행동 /JAVA 25 SELF /Java Memory Model (JMM)

Java Memory Model (JMM)

JAVA 25 SELF
레벨 58 , 레슨 4
사용 가능

1. Java Memory Model (JMM) 소개

가시성과 순서 보장 문제

단일 스레드 프로그램에서는 모든 것이 단순합니다. 변숫값을 기록하면 곧바로 그 값을 읽을 수 있습니다. 하지만 멀티스레드 현실에서는 다르게 동작합니다. 프로세서는 값을 캐시하고, 컴파일러와 JVM은 때때로 명령의 순서를 바꾸며, 한 스레드는 다른 스레드가 방금 변경한 값이 있어도 “오래된” 값을 볼 수 있습니다.

Java Memory Model (JMM)은 정확히 스레드가 메모리를 통해 어떻게 소통하는지를 설명합니다. 한 스레드의 변경이 언제 다른 스레드에 보이는지, 그리고 어떤 순서로 연산이 일어나는지를 정의합니다. 이를 고려하지 않으면, 겉보기에는 정상처럼 보여도 프로그램이 예측 불가능하게 동작할 수 있습니다.

JMM을 이해하면 왜 어떤 때는 스레드가 최신 데이터를 보지 못하는지, volatile, synchronized와 원자적 클래스들을 어떻게 올바르게 사용하는지, 그리고 멀티스레드 버그가 왜 종종 프로덕션에서만 드러나는지를 이해하는 데 도움이 됩니다. 간단히 말해 JMM은 메모리의 “게임 규칙”이며, 이를 무시하면 아무리 꼼꼼한 코드라도 여러분에게 불리하게 작용할 수 있습니다.

비유

두 사람이(스레드) 칠판(메모리)에 쪽지(변수)를 쓰고 읽는다고 상상해 보세요. 어떤 때는 한 사람이 썼는데도 다른 사람이 새 기록을 보지 못합니다. 자기만의 칠판 사본(캐시)을 보고 있기 때문이죠. JMM은 이러한 쪽지가 언제, 어떻게 모두에게 보이게 되는지를 정의합니다.

2. happens-before: JMM의 토대

happens-before란?

happens-before는 프로그램의 두 동작 사이의 관계입니다. 동작 A가 동작 B보다 happens-before라면, A에서 이루어진 모든 변경은 B에서 반드시 보입니다.

중요: happens-before는 단순한 “먼저 실행되었다”가 아니라 “가시성이 보장된다”는 의미입니다.

happens-before의 주요 규칙

1. 단일 스레드 내부

하나의 스레드 안에서 일어나는 일은 순서가 보장됩니다. 변수를 기록한 뒤 나중에 그 변수를 읽으면, 자신의 변경을 볼 수 있습니다.

2. synchronized 블록/모니터

동기화 블록(synchronized)을 빠져나오기 전에 발생한 모든 일은 이후에 그 블록에 진입하는 스레드에 보입니다.

synchronized(lock) {
    sharedVar = 42; // 쓰기
}
// ...
synchronized(lock) {
    System.out.println(sharedVar); // 42를 확실히 볼 수 있음
}

3. volatile 쓰기/읽기

volatile 필드에 대한 쓰기는 다른 스레드에서 그 필드를 이후에 읽는 동작보다 happens-before입니다.

volatile boolean ready = false;

// 스레드 1
data = 123;
ready = true; // volatile write

// 스레드 2
if (ready) { // volatile read
    System.out.println(data); // data = 123을 확실히 볼 수 있음
}

4. 스레드 시작과 종료

  • Thread.start() 호출은 스레드의 실행 시작보다 happens-before입니다.
  • 스레드의 종료는 Thread.join() 반환보다 happens-before입니다.

5. Executor에서의 작업 완료

Executor에 작업을 제출하고 그 완료를 기다리면(Future.get()), 작업에서 이루어진 모든 변경은 get() 이후에 보입니다.

6. final 필드

생성자에서 final 필드를 초기화하는 것은 해당 객체 참조의 퍼블리시보다 happens-before입니다. immutable 객체에서 중요합니다.

3. 객체의 안전한 퍼블리시

문제: “stale” 객체

한 스레드가 객체를 생성해 동기화 없이 다른 스레드에 넘기면, 다른 스레드는 필드의 “날것” 값(예: 초기화되지 않았거나 오래된 값)을 볼 수 있습니다.

class Holder {
    int value;
    Holder() { value = 42; }
}

Holder holder = null;

// 스레드 1
holder = new Holder(); // 객체 생성

// 스레드 2
if (holder != null) {
    System.out.println(holder.value); // 42가 아니라 0을 볼 수도 있음!
}

객체를 어떻게 올바르게 퍼블리시할까?

1. final 필드를 통해

객체의 모든 필드가 final이고 생성자에서 초기화된다면, 추가 동기화 없이도 안전하게 퍼블리시할 수 있습니다.

class SafeHolder {
    final int value;
    SafeHolder() { value = 42; }
}

2. volatile 참조를 통해

객체에 대한 참조가 volatile로 선언되면, 대입 이후 그 객체는 다른 스레드에 확실히 보입니다.

volatile Holder holder;

// 스레드 1
holder = new Holder();

// 스레드 2
if (holder != null) {
    System.out.println(holder.value); // 42를 확실히 볼 수 있음
}

3. 퍼블리시 이전 단일 스레드 초기화

객체가 다른 스레드에서 참조 가능해지기 전에 한 스레드에서만 생성 및 초기화된다면 안전합니다.

Holder holder = new Holder(); // 한 스레드에서만
// ... 그 후 holder가 다른 스레드에 공개됨

4. 락을 통해

객체를 동기화 블록 안에서 생성하고, 그 참조를 동일한 블록 안에서만 읽는다면 안전합니다.

Holder holder;

synchronized(lock) {
    if (holder == null) {
        holder = new Holder();
    }
}

// ... 다른 스레드에서
synchronized(lock) {
    if (holder != null) {
        // 안전함
    }
}

4. Double-checked locking와 volatile

double-checked locking이란?

lazy 초기화를 위한 singleton 패턴입니다:

class Singleton {
    private static Singleton instance;

    public static Singleton getInstance() {
        if (instance == null) { // 첫 번째 검사
            synchronized (Singleton.class) {
                if (instance == null) { // 두 번째 검사
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

문제: instance에 대한 volatile 참조가 없으면 이 코드는 올바르게 동작하지 않습니다! 다른 스레드가 완전히 초기화되지 않은 객체를 볼 수 있습니다.

왜 volatile이 없으면 안 좋을까?

JVM이 명령을 재배치하여, 생성자가 끝나기 전에 객체 참조가 먼저 할당될 수 있습니다. 다른 스레드는 초기화되지 않은 객체를 보게 됩니다.

올바른 방법은?

instancevolatile로 선언하세요:

private static volatile Singleton instance;

이제 double-checked locking이 올바르게 동작합니다. volatile은 참조의 쓰기와 읽기 사이에 happens-before 관계를 보장합니다.

대안: 정적 초기화

singleton을 만드는 가장 간단하고 안전한 방법은 정적 초기화를 사용하는 것입니다:

class Singleton {
    private static final Singleton INSTANCE = new Singleton();
    public static Singleton getInstance() { return INSTANCE; }
}

여기서는 JVM이 올바른 초기화를 스스로 보장합니다.

5. VarHandle: 현대적인 저수준 접근

VarHandle이란?

VarHandle은 (Java 9+) 변수에 저수준으로 접근하기 위한 현대적 API로, 읽기/쓰기, 원자적 연산, 가시성과 명령 순서를 제어할 수 있습니다.

아토믹 클래스가 있는데 왜 VarHandle이 필요할까요?
VarHandle은 (int/long/Reference)에 한정되지 않고 임의의 필드에 사용할 수 있습니다.
— 접근 의미를 명시적으로 선택할 수 있습니다: volatile, acquire/release, opaque.
— 고성능 자료구조를 구현하는 데 사용됩니다.

접근 의미(semantics)

  • Volatile: happens-before 보장을 완전히 제공합니다(volatile 필드와 동일).
  • Acquire/Release: 더 약한 보장이지만 더 빠름(lock-free 구조에 사용).
  • Opaque: 최소한의 가시성 보장으로 최대 성능을 제공합니다.

VarHandle 사용 예

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

class Counter {
    int value;
    static final VarHandle VALUE_HANDLE;

    static {
        try {
            VALUE_HANDLE = MethodHandles.lookup().findVarHandle(Counter.class, "value", int.class);
        } catch (Exception e) {
            throw new Error(e);
        }
    }
}

Counter counter = new Counter();
Counter.VALUE_HANDLE.setVolatile(counter, 42);
int v = (int) Counter.VALUE_HANDLE.getVolatile(counter);

언제 VarHandle을 사용할까?

  • 직접 lock-free 자료구조를 구현할 때.
  • 명령 순서에 대한 최대한의 성능과 제어가 필요할 때.
  • 일반 애플리케이션에서는 보통 아토믹 클래스와 synchronized로 충분합니다.

6. False sharing과 캐시 라인 정렬

False sharing은 두 스레드가 서로 다른 변수를 다루지만, 그 변수들이 같은 CPU 캐시 라인에 놓여 있을 때 발생하는 상황입니다. 그 결과, 한 변수를 변경할 때 다른 변수의 캐시가 무효화되어 서로에게 방해가 됩니다.

비유: 두 사람이 같은 책상(캐시 라인)에 앉아 있는데, 각자 자기 쪽에서만 씁니다. 한 사람이 뭔가 바꾸면, 다른 사람은 종이 전체를 “다시 읽어야” 합니다.

왜 안 좋을까?

  • 성능이 급격히 떨어집니다. 프로세서가 캐시 동기화에 시간을 씁니다.
  • 서로 다른 스레드가 자주 변경하는 “핫” 변수에 특히 치명적입니다.

어떻게 피할까?

“핫” 필드를 서로 다른 객체로 분리하거나, 패딩/정렬을 위한 특수 애너테이션/구조(예: @Contended)를 사용하세요. 최신 JVM에서는 -XX:-RestrictContended 옵션을 켜고, 필드 정렬을 위해 @sun.misc.Contended(Java 8+)를 사용할 수 있습니다.

예:

@sun.misc.Contended
public volatile long value1;

@sun.misc.Contended
public volatile long value2;

NB: @Contended는 표준 API에 속하지 않지만, JDK에서 아토믹 클래스 최적화를 위해 사용됩니다.

7. 실습: singleton 수정과 JMH 미니 벤치마크

동작하지 않는 singleton 고치기

나쁨(volatile 없음):

class BrokenSingleton {
    private static BrokenSingleton instance;
    public static BrokenSingleton getInstance() {
        if (instance == null) {
            synchronized (BrokenSingleton.class) {
                if (instance == null) {
                    instance = new BrokenSingleton();
                }
            }
        }
        return instance;
    }
}

좋음(volatile 포함):

class SafeSingleton {
    private static volatile SafeSingleton instance;
    public static SafeSingleton getInstance() {
        if (instance == null) {
            synchronized (SafeSingleton.class) {
                if (instance == null) {
                    instance = new SafeSingleton();
                }
            }
        }
        return instance;
    }
}

가장 좋은 방법 — 정적 초기화:

class StaticSingleton {
    private static final StaticSingleton INSTANCE = new StaticSingleton();
    public static StaticSingleton getInstance() { return INSTANCE; }
}

JMH 미니 벤치마크: 가시성과 원자성

주의: JMH는 Java에서 마이크로벤치마크를 위한 전용 프레임워크입니다. JMH 없이 성능에 대한 결론을 내리지 마세요 — JVM 최적화와 CPU 캐시 때문에 결과가 왜곡될 수 있습니다!

예: volatile 가시성 확인

public class VolatileVisibility {
    volatile boolean flag = false;

    public void writer() {
        flag = true;
    }

    public void reader() {
        while (!flag) {
            // true를 볼 때까지 루프
        }
        // 변경을 관찰함
    }
}

예: volatile의 비원자성

public class VolatileNotAtomic {
    volatile int counter = 0;

    public void increment() {
        counter++; // 원자적이지 않음!
    }
}

volatile임에도 불구하고, 여러 스레드가 동시에 증가시키면 최종 값은 기대치보다 작게 됩니다(연산 counter++는 읽기, 계산, 쓰기로 분해됩니다).

8. JMM, volatile, 퍼블리시에 관한 흔한 실수

오류 1: volatile에 원자성을 기대함.
volatile은 변경의 가시성만 보장할 뿐, 연산의 원자성을 보장하지 않습니다. 변수에 volatile을 붙여도 counter++는 원자적이 되지 않습니다.

오류 2: 동기화 없이 객체를 퍼블리시함.
한 스레드에서 객체를 만들고 volatile, synchronized, final 필드 없이 다른 스레드에 넘기면, 다른 스레드는 “날것” 값을 볼 수 있습니다.

오류 3: volatile 없이 double-checked locking.
singleton 참조에 volatile이 없으면, 다른 스레드에서 초기화되지 않은 객체를 볼 수 있습니다.

오류 4: 가상 스레드와 함께 오래된 락을 사용함.
일부 오래된 동기화 메커니즘(native monitor)은 JVM이 가상 스레드를 효율적으로 관리하는 것을 방해할 수 있습니다.

오류 5: false sharing을 무시함.
“핫” 변수가 메모리에서 서로 가깝게 놓여 있으면, 캐시 라인 때문에 스레드가 서로 방해합니다.

오류 6: JMH 없이 성능을 단정함.
JMH 없이 수행한 마이크로벤치마크는 JVM 최적화와 CPU 캐시 때문에 잘못된 결과를 내는 경우가 많습니다.

1
설문조사/퀴즈
멀티스레딩 더 깊게 파고들기, 레벨 58, 레슨 4
사용 불가능
멀티스레딩 더 깊게 파고들기
멀티스레딩 더 깊게 파고들기
코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION