public class Singleton {
private static Singleton instance; // volatile 없음 - 문제 발생!
public static Singleton getInstance() {
if (instance == null) { // 첫 번째 체크 (동기화 밖)
synchronized (Singleton.class) {
if (instance == null) { // 두 번째 체크 (동기화 안)
instance = new Singleton(); // 문제 발생 지점
}
}
}
return instance;
}
}
instance = new Singleton()은 실제로 3단계로 나뉩니다:
// instance = new Singleton(); 의 실제 동작
// 1단계: 메모리 할당
memory = allocate();
// 2단계: 생성자 호출, 객체 초기화
constructorCall(memory);
// 3단계: instance 변수가 메모리 주소 참조
instance = memory;
JVM과 CPU는 성능 최적화를 위해 명령어 순서를 재정렬할 수 있습니다:
// 최적화로 인해 순서가 바뀔 수 있음!
// 1단계: 메모리 할당
memory = allocate();
// 3단계: instance 변수가 메모리 주소 참조 (초기화 전!)
instance = memory;
// 2단계: 생성자 호출, 객체 초기화
constructorCall(memory);
// Thread A가 실행 중
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
// 1. 메모리 할당
// 2. instance = 메모리 주소 (초기화 안 된 상태!)
// ← 여기서 Thread B가 개입!
// 3. 생성자 호출 (아직 실행 안됨)
}
}
}
// Thread B가 실행
if (instance == null) { // instance != null (메모리 주소는 할당됨)
// synchronized 블록을 건너뜀
}
return instance; // 초기화되지 않은 객체 반환! - 버그 발생!
public class Singleton {
private static volatile Singleton instance; // volatile 필수!
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
volatile은 happens-before 관계를 보장합니다:
// volatile을 사용하면 순서가 보장됨
// 1단계: 메모리 할당
memory = allocate();
// 2단계: 생성자 호출, 객체 초기화 (반드시 먼저!)
constructorCall(memory);
// 3단계: instance 변수가 메모리 주소 참조
instance = memory; // volatile 쓰기
instance에 쓰기 작업을 하면[Thread A]
1. instance == null 체크 (true)
2. synchronized 진입
3. instance == null 체크 (true)
4. 메모리 할당
5. instance = 메모리주소 (초기화 안됨!)
↓
[Thread B 개입]
6. instance == null 체크 (false) ← 문제!
7. 초기화 안된 객체 반환 ← 버그!
↓
8. 생성자 호출 (너무 늦음)
[Thread A]
1. instance == null 체크 (true)
2. synchronized 진입
3. instance == null 체크 (true)
4. 메모리 할당
5. 생성자 호출 (완전히 초기화!)
6. instance = 메모리주소 (volatile 쓰기)
↓
[Thread B]
7. instance == null 체크 (false)
8. 완전히 초기화된 객체 반환 ← 안전!
public class SingletonBugDemo {
private static Singleton instance; // volatile 없음
public static void main(String[] args) {
// 여러 Thread가 동시에 getInstance 호출
for (int i = 0; i < 100; i++) {
new Thread(() -> {
Singleton s = Singleton.getInstance();
// 초기화되지 않은 객체의 필드에 접근하면
// NullPointerException이나 이상한 값 발생 가능
System.out.println(s.getData());
}).start();
}
}
}
class Singleton {
private String data;
private Singleton() {
// 시간이 걸리는 초기화
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
this.data = "Initialized";
}
public String getData() {
return data; // null이 반환될 수 있음!
}
}
Double-Checked Locking이 복잡하다면, Lazy Holder 패턴을 사용할 수 있습니다:
public class Singleton {
// private 생성자
private Singleton() {}
// static 내부 클래스
private static class LazyHolder {
private static final Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance() {
return LazyHolder.INSTANCE;
}
}
장점:
volatile 키워드 불필요public enum Singleton {
INSTANCE;
private String data;
Singleton() {
this.data = "Initialized";
}
public String getData() {
return data;
}
public void doSomething() {
System.out.println("Doing something");
}
}
// 사용
Singleton.INSTANCE.doSomething();
장점:
| 패턴 | Volatile 필요? | 복잡도 | 안정성 | 권장도 |
|---|---|---|---|---|
| Double-Checked Locking | 필수 | 높음 | 주의 필요 | 중간 |
| Lazy Holder | 불필요 | 중간 | 높음 | 높음 |
| Enum | 불필요 | 낮음 | 매우 높음 | 매우 높음 |
결론:
volatile은 반드시 필요