// Single-threaded version
class Foo {
private static Helper helper;
public Helper getHelper() {
if (helper == null) {
helper = new Helper();
}
return helper;
}
// other functions and members...
}
첫번째 방법의 문제는 동시에 두 스레드가 Foo 클래스에 접근해서 getHelper() 메서드를 통해 객체가 null인지 아닌지 판단할 때, A 스레드도 null이라고 판단하고 B 스레드도 null이라고 판단해 둘 다 동시에 객체를 생성하려고 시도하거나 불완전하게 초기화 된 객체에 대한 참조를 얻을 수 있다는 것이다. 즉 위의 싱글턴 패턴은 동시성 문제가 있으며, 쓰레드 세이프하지 않다.
// Correct but possibly expensive multithreaded version
class Foo {
private Helper helper;
public synchronized Helper getHelper() {
if (helper == null) {
helper = new Helper();
}
return helper;
}
// other functions and members...
}
두 번째 방법은 메소드에 synchronized
키워드를 이용한 방식이다. 게으른 초기화 방식
이란 컴파일 시점에 인스턴스를 생성하는 것이아니라, 인스턴스가 필요한 시점에 요청 하여 동적 바인딩(dynamic binding)(런타임시에 성격이 결정됨)을 통해 인스턴스를 생성하는 방식을 말한다.
이 방법은 쓰레드 세이프하지만, 인스턴스가 생성이 되었든 안되었든 무조건 동기화 블록을 거치게 됨으로써 메서드가 호출될 때마다 락을 획득하고 해제하는 오버헤드가 발생하므로 성능이 극단적인 경우 100배 이상 저하될 수 있다.
두 번째 방법의 문제를 해결하기 위한 방법으로 Lazy Initialization과 Double Checking Locking(DCL)을 결합한 방식이 등장했다.
// Broken multithreaded version
// "Double-Checked Locking" idiom
class Foo {
private Helper helper;
public Helper getHelper() {
if (helper == null) { // 1.
synchronized (this) { // 2.
if (helper == null) { // 3.
helper = new Helper(); // 4.
}
}
}
return helper;
}
// other functions and members...
}
그러나 세번째 방법에도 문제가 있다. 소스코드 상의 문제는 없지만 컴파일러에 따라서 재배치(reordering) 문제를 야기하기 때문이다.
위에 소스가 컴파일 되는 경우 인스턴스 생성은 아래와 같은 과정을 거치게 된다.
// Broken multithreaded version
// "Double-Checked Locking" idiom
class Foo {
private Helper helper;
public Helper getHelper() {
if(helper == null) // 1. Thread B 수행
synchronized(this) {
if (helper == null) {
// helper = new Helper(); // 아래와 같이 변환 됨
some_space = allocate space for Singleton object;
helper = some_space; // Thread A 수행
create a real object in some_space; // 실제 오브젝트 할당
}
}
}
return helper;
}
// other functions and members...
}
공유 변수
가 즉시 업데이트 될 수 있다.이는 스레드가 동일 메모리(공유하는 메모리)에서 값을 읽어오는 것이 아니라 각각의 CPU 캐시에서 값을 읽어오기 때문에 변수의 값이 일관성이 없는 것이다.
// Works with acquire/release semantics for volatile in Java 1.5 and later
// Broken under Java 1.4 and earlier semantics for volatile
class Foo {
private volatile Helper helper;
public Helper getHelper() {
Helper localRef = helper;
if (localRef == null) {
synchronized (this) {
localRef = helper;
if (localRef == null) {
helper = localRef = new Helper();
}
}
}
return localRef;
}
// other functions and members...
}
네번째 방법은 세번째 방법에서 공유 변수에 volatile
키워드를 이용함으로써 CPU 캐시에서 변수를 참조하지 않고 메인 메모리에서 변수를 참조하게 함으로서 세번째 방법의 문제였던 컴파일러의 재배치(redordering) 문제를 야기하지 않는다.
또한, 여기서 지역변수인 "localRef"를 사용한 이유는 공유 변수인 helper가 이미 초기화된 경우, 공유 변수인 helper, volatile 필드가 한 번만 액세스 된다는 장점이 있다. 이후에는 계속 localRef 지역변수만 검사하게 된다. 이 방법은 전체 성능을 최대 40%까지 높였다.
// Works with acquire/release semantics for VarHandles introduced in Java 9
class Foo {
private volatile Helper helper;
public Helper getHelper() {
Helper localRef = getHelperAcquire();
if (localRef == null) {
synchronized (this) {
localRef = getHelperAcquire();
if (localRef == null) {
localRef = new Helper();
setHelperRelease(localRef);
}
}
}
return localRef;
}
private static final VarHandle HELPER;
private Helper getHelperAcquire() {
return (Helper) HELPER.getAcquire(this);
}
private void setHelperRelease(Helper value) {
HELPER.setRelease(this, value);
}
static {
try {
MethodHandles.Lookup lookup = MethodHandles.lookup();
HELPER = lookup.findVarHandle(Foo.class, "helper", Helper.class);
} catch (ReflectiveOperationException e) {
throw new ExceptionInInitializerError(e);
}
}
// other functions and members...
}
4번 방법에서 Helper localRef = helper; helper = localRef = new Helper();
를 사용했던 것과는 달리 5번 방법에서는 Helper localRef = getHelperAcquire(); localRef = new Helper(); setHelperRelease(localRef);
를 사용한다. 이 방법은 volatile 필드가 더이상 동기화 순서에 참여하지 않는다.
또한 5번 방법에 대한 대안으로는 중첩된 helperHoler 클래스가 정적일 경우 사용하는LazyHolder Singleton 패턴
이 있다. 또한 이 패턴은 getHelper() 메서드에서 Static Nested Class를 처음 호출하여 로딩할때까지 초기화를 미루기 때문에 volatile이나 synchronized 키워드 없이도 동시성 문제를 해결하며 성능이 뛰어나다.
// Correct lazy initialization in Java
class Foo {
private static class HelperHolder {
public static final Helper helper = new Helper();
}
public static Helper getHelper() {
return HelperHolder.helper;
}
}
public class FinalWrapper<T> {
public final T value;
public FinalWrapper(T value) {
this.value = value;
}
}
public class Foo {
private FinalWrapper<Helper> helperWrapper;
public Helper getHelper() {
FinalWrapper<Helper> tempWrapper = helperWrapper;
if (tempWrapper == null) {
synchronized (this) {
if (helperWrapper == null) {
helperWrapper = new FinalWrapper<Helper>(new Helper());
}
tempWrapper = helperWrapper;
}
}
return tempWrapper.value;
}
}
final
키워드가 붙은 helpWrapper 멤버 변수를 사용하고 Setter 메서드가 없는 Immutable 클래스를 사용함으로써 volatile을 사용하지 않고 쓰레드 세이프함을 보장한다.
public enum Singleton{
INSTANCE;
}
ENUM 인스턴스의 생성은 기본적으로 스레드 세이프하다. 또한 스레드 관련 코드가 없어서 코드가 간단해진다. 그러나 만들려는 싱글턴 객체가 Enum 외의 클래스를 상속해야 하는 경우는 사용할 수 없다.
참고