스터디 진행 중 싱글톤과 관련해서 재미있는 이야기가 나왔고 흥미로웠습니다. 기본적으로 싱글톤 객체를 생성하는 방법 외에 여러 가지 제약 조건이 붙었을 때 코드를 개선하는 과정을 정리하고자 합니다.
public class Foo {
private static final Foo INSTANCE = new Foo();
private Foo() {}
public static Foo getInstance() {
return INSTANCE;
}
}
JVM은 클래스 로딩 시점에 Metaspace 영역에 클래스의 정보, staic 변수, 메서드 등을 저장합니다. 그리고 static 변수와 함수는 JVM이 클래스 당 하나만 생성하므로 유일성이 보장됩니다. 따라서 즉시 초기화 방식을 사용하면 싱글톤이 보장될 수 있습니다. 이 방법 외에도 지연 초기화와 synchronized 키워드를 사용하여 싱글톤을 만들 수도 있습니다.
public class Foo {
private static Foo instance;
private Foo() {}
public static synchronized Foo getInstance() {
if (instance == null) {
instance = new Foo();
}
return instance;
}
}
synchronized 키워드를 사용하는 이유는 함수 내부가 원자적 연산이 아니기 때문입니다. 멀티 스레드 상황에서 동시성 문제가 발생할 수 있습니다. 만약 두 개의 스레드가 동시에 아래 코드를 동시에 실행한다면 어떤 상황이 발생할 수 있을까요?
if (instance == null) {
instance = new Foo();
}
return instance;

이처럼 Heap 메모리에 두 개의 인스턴스가 생성되면서 싱글톤이 깨질 수 있습니다. 극단적으로 스레드 100개가 동시에 getInstance() 함수에 진입하면 더 많은 메모리 공간을 낭비하게 되겠죠. 따라서 메서드 레벨에 synchronized로 동기화 블록을 설정해야 합니다.
일반적으로 사용하는 싱글톤 패턴의 예제 두 가지를 살펴봤습니다. 그런데 두 방법 모두 단점이 있습니다.
방법1, 방법2 모두 프로그램을 실행했을 때 인스턴스가 반드시 생성됩니다. 싱글톤 객체가 100개가 있을 때 실제 사용되는 객체가 1개라면 불필요하게 Heap 메모리를 차지하게 됩니다. 또한 방법2는 멀티 스레드 환경에서 임계 영역 진입을 위해 Lock 획득이 필요하고 바쁜 대기(busy waiting)가 발생하여 리소스 낭비로 이어질 수 있습니다.
정리하면 크게 두 가지 문제가 있습니다.
방법1, 방법2 해당)방법2 해당)public class Foo {
private static volatile Foo instance;
private Foo() {}
public static Foo getInstance() {
if (instance == null) { // 1차 검사 (락 없이)
synchronized (Foo.class) { // 2차 검사 (락 사용)
if (instance == null) {
instance = new Foo();
}
}
}
return instance;
}
}
이 방법은 앞서 살펴본 두 가지 방식의 단점을 보완한 방식입니다.
첫 번째 if문에서 인스턴스의 존재 여부를 검사합니다. 이미 인스턴스가 생성되어 있다면 synchronized 블록에 진입하지 않고 바로 인스턴스를 반환합니다.1차 검사로 불필요한 락 획득을 방지 가능주의할 점은 instance 변수에 volatile 키워드를 사용해야 한다는 것입니다. volatile을 사용하지 않으면 JVM의 최적화로 인한 메모리 재정렬(memory reordering)가 발생할 수 있어 다른 스레드에서 완전히 초기화되지 않은 인스턴스를 참조할 수 있기 때문입니다.

public class Foo {
private Foo() {}
// 정적 내부 클래스
private static class FooHolder {
private static final Foo INSTANCE = new Foo();
}
// 외부에서 호출하는 메서드
public static Foo getInstance() {
return FooHolder.INSTANCE;
}
}
방법3은 thread-safe 하지만 코드가 복잡합니다. 이 방법은 정적 내부 클래스는 호출 시점에 JVM에 로드되고 초기화되는 특징을 사용합니다. 따라서 코드 가독성 향상과 synchronized 블록도 제거하면서도 thread-safe 하게 싱글톤 객체를 만들 수 있습니다.