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이 명령을 재배치하여, 생성자가 끝나기 전에 객체 참조가 먼저 할당될 수 있습니다. 다른 스레드는 초기화되지 않은 객체를 보게 됩니다.
올바른 방법은?
instance를 volatile로 선언하세요:
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 캐시 때문에 잘못된 결과를 내는 경우가 많습니다.
GO TO FULL VERSION