메모리 가시성을 공부하면서 DCL이 안전하지 않을 수 있다 는 것을 알게 되었고, 이를 정리하기 위해 글을 작성하게 되었습니다.
1. Double-Checked Locking은 왜 안전해 보일까?
Double-Checked Locking(DCL)은 싱글톤을 지연 초기화하면서도 매번 synchronized를 사용하지 않기 위해 고안된 패턴입니다. 대부분의 호출은 첫 번째 if (instance == null) 에서 빠르게 반환되고, 실제 객체 생성 시점에만 동기화를 수행합니다. 이 구조만 보면 객체 생성은 항상 synchronized 블록 안에서 일어나므로, 동시에 여러 객체가 생성될 위험이 없어 보입니다.
1
2
3
4
5
6
7
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
instance = new Singleton( ) 은 하나의 문장처럼 보이지만, 자바 메모리 모델(JMM) 관점에서 이는 단일 동작이 아닌데요. 내부적으로는 다음과 같은 단계로 나뉩니다. 자바 컴파일러와 JVM은 성능 최적화를 위해 이 단계들의 실행 순서를 재배치할 수 있으며, 이는 JMM이 허용하는 동작입니다. 즉, 객체 초기화가 끝나기 전에 참조가 instance에 먼저 저장되는 실행이 이론적으로 가능합니다.
- 객체를 위한 메모리 공간을 할당
- 해당 메모리 주소를 instance 변수에 저장
- 생성자를 실행하여 필드를 초기화
DCL에서 첫 번째 if (instance == null) 은 synchronized 밖에 있습니다. 이는 객체 생성 쓰기와 이 읽기 사이에 happens-before 관계가 없다는 뜻입니다. 그 결과, 다른 스레드는 다음과 같은 상태를 관측할 수 있습니다. 이것이 흔히 말하는 부분 초기화 객체(미완성 객체) 관측 시나리오입니다.
- instance는 null이 아님
- 하지만 생성자는 아직 끝나지 않음
- 일부 필드는 기본값(0, null)을 유지하고 있음
이론적으로는 다른 스레드가 value == 0 인 상태의 Singleton 객체를 볼 수 있는 것이죠. 중요한 점은 생성자가 틀린 것이 아니라, 생성자가 끝나기 전에 객체 참조가 외부로 공개될 수 있다는 점입니다.
1
2
3
4
5
6
7
class Singleton {
int value;
Singleton() {
value = 42;
}
}
이 문제는 멀티스레드 테스트로 쉽게 재현되지 않는 경우가 많으며, 이는 테스트가 잘못되었기 때문이 아니라 실제로 널리 사용되는 HotSpot JVM과 x86 계열 CPU 환경이 자바 메모리 모델 명세보다 보수적으로 동작하는 경향이 있기 때문입니다. 이러한 환경에서는 객체 초기화와 참조 저장이 비교적 안전한 순서로 실행되는 경우가 많아, 명세상 허용된 공격적인 명령 재배치가 실제 실행에서는 잘 선택되지 않습니다. 그 결과 자바 메모리 모델이 허용하는 위험한 실행 경로가 현실의 실행 환경에서는 쉽게 관측되지 않지만, 이는 해당 실행이 불가능하다는 의미가 아니라 단지 우연히 드러나지 않았을 뿐입니다.
However, compilers are allowed to reorder the instructions in either thread, when this does not affect the execution of that thread in isolation. If instruction 1 is reordered with instruction 2, as shown in the trace in Table 17.4-B, then it is easy to see how the result r2 == 2 and r1 == 1 might occur.
2. 어떻게 해결해야 할까?
Double-Checked Locking 문제의 핵심 원인은 객체가 완전히 초기화되기 전에 그 참조가 다른 스레드에 노출될 수 있다는 점이며, 이는 동기화의 문제가 아니라 객체 생성과 참조 저장 사이의 순서가 자바 메모리 모델(JMM)에서 보장되지 않기 때문에 발생합니다. 이를 해결하려면 객체 초기화가 끝나기 전에는 다른 스레드가 해당 참조를 볼 수 없도록 메모리 가시성과 실행 순서를 보장해야 하며, 이를 위해 instance 변수를 volatile로 선언하면 쓰기와 읽기 사이에 happens-before 관계가 형성되어 초기화 완료 이후에만 참조가 관측되고 위험한 명령 재배치도 방지됩니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class Singleton {
private static volatile Singleton instance;
private int value;
private Singleton() {
value = 42;
}
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
public int getValue() {
return value;
}
}
이 코드에서 volatile은 단순히 메인 메모리에서 값을 읽고 쓰는 수준을 넘어, 자바 메모리 모델(JMM) 차원에서 객체의 안전한 공개를 보장하는 역할을 합니다. 다른 스레드가 instance가 null이 아닌 값을 관측하는 순간, 해당 객체의 생성자 실행과 모든 필드 초기화가 이미 완료되었음이 함께 보장됩니다. 그 결과 초기화가 끝나지 않은 상태의 객체를 다른 스레드가 관측하는 실행은 자바 메모리 모델 차원에서 허용되지 않으며, 부분 초기화 객체를 사용하는 문제 자체가 발생하지 않게 됩니다.
3. 정리
Double-Checked Locking 자체가 문제인 것이 아니라, volatile 없이 사용될 때 객체 생성과 공개 사이의 순서가 보장되지 않는 것이 문제입니다. volatile을 추가하면 이 순서가 명확히 정의되며, Double-Checked Locking은 자바 5 이후의 메모리 모델에서는 안전하게 동작합니다.