지연 초기화에서 락 비용을 줄이려고 검사를 두 번 하는 패턴. 가장 유명한 동시성 버그의 무대다.
깨지는 코드
private static Singleton instance; // volatile 이 없다
static Singleton get() {
if (instance == null) { // ① 락 없이 한 번 확인 (빠른 경로)
synchronized (Singleton.class) {
if (instance == null) // ② 락 잡고 다시 확인
instance = new Singleton();
}
}
return instance;
}
왜 깨지나 — 재정렬
instance = new Singleton() 은 한 단계가 아니다
ⓐ 메모리를 할당한다 ⓑ 생성자를 실행해 필드를 채운다 ⓒ instance 에 그 주소를 대입한다
JIT·CPU 는 ⓑ 와 ⓒ 의 순서를 바꿀 수 있다 (단일 스레드 결과는 같으니까)
ⓐ → ⓒ → ⓑ 로 실행되면
- 다른 스레드가 ① 에서 "null 이 아니네" 하고 통과한다
- 생성자가 아직 안 끝난 '반쯤 만들어진 객체' 를 받는다
- 필드가 기본값(0 · null)인 상태로 쓰인다
재현이 거의 안 되고, 특정 JVM·부하에서만 터진다.
고치는 법
private static volatile Singleton instance; // ← 이 한 단어
volatile이 ⓑ와 ⓒ의 재정렬을 막고(메모리 배리어) 가시성도 보장한다. Java 5 이후의 메모리 모델에서 이 패턴이 비로소 안전해졌다.
더 나은 방법 — 홀더 패턴
class Singleton {
private Singleton() { }
private static class Holder { static final Singleton INSTANCE = new Singleton(); }
static Singleton get() { return Holder.INSTANCE; }
}
Holder 클래스는 get() 이 처음 불릴 때 초기화된다 (지연 초기화) 클래스 초기화의 스레드 안전성은 JVM 이 보장한다
- 락도 volatile 도 필요 없다. 코드도 더 짧다
DCL을 직접 쓸 일은 사실상 없다 — 필드 단위 지연 초기화가 정말 필요할 때나 쓴다.
같은 뿌리의 형제 사고
- 안전하지 않은 발행(unsafe publication)
- 생성자에서 this 를 다른 스레드에 넘기면 미완성 객체가 노출된다
- 불변 객체는 이 문제가 없다
- final 필드는 생성자 완료 시점에 보이는 것이 보장된다 (final field semantics)
면접 함정
- ❌ "DCL은 옛날 JVM 버그" → 메모리 모델상의 재정렬 문제이고,
volatile없이는 지금도 깨진다. - ❌ "synchronized 안이니 안전하다" → 첫 번째 검사는 락 밖이다. 그게 문제의 지점이다.
다른 지연 초기화 선택지
// ① enum 싱글턴 — 직렬화·리플렉션 공격까지 막아 준다
enum Singleton {
INSTANCE;
void work() { ... }
}
// ② Java 25 의 StableValue — 지연 1회 초기화를 언어가 지원한다
private final StableValue<Config> cfg = StableValue.of();
Config cfg() { return cfg.orElseSet(this::loadConfig); } // 최초 1회만 로드
// ③ 인스턴스 필드라면
private final Supplier<Heavy> lazy = Suppliers.memoize(Heavy::new); // 라이브러리
②가 나오면서 DCL을 손으로 쓸 이유가 사실상 사라졌다 — 홀더 패턴이 못 다루던 인스턴스 필드 지연 초기화까지 커버한다.
왜 이 사고가 유명한가
-
코드가 논리적으로 완벽해 보인다
-
단일 스레드 테스트는 100% 통과한다
-
개발 환경(적은 부하 · 인터프리터)에서는 재현되지 않는다
-
운영에서 간헐적으로, 재현 불가능하게 터진다
-
"메모리 모델을 모르면 눈으로 못 잡는 버그" 의 대표 사례로 교과서에 실렸다