싱글턴 패턴과 쓰레드 세이프

de_sj_awa·2021년 6월 14일
0
post-custom-banner

1. 일반적인 싱글턴 패턴

// 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이라고 판단해 둘 다 동시에 객체를 생성하려고 시도하거나 불완전하게 초기화 된 객체에 대한 참조를 얻을 수 있다는 것이다. 즉 위의 싱글턴 패턴은 동시성 문제가 있으며, 쓰레드 세이프하지 않다.

2. Lazy Initialization with synchronized (동기화 블럭)

// 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배 이상 저하될 수 있다.

3. Lazy Initialization. Double Checking Locking(DCL)

두 번째 방법의 문제를 해결하기 위한 방법으로 Lazy Initialization과 Double Checking Locking(DCL)을 결합한 방식이 등장했다.

  1. 변수가 초기화되었는지 확인한다 (Lock을 얻지 않음). 초기화되면 즉시 반환한다. (synchronized 블럭을 거치지 않음)
  2. 변수가 초기화되지 않았다면 Lock을 얻는다.
  3. 변수가 이미 초기화되었는지 다시 확인한다. 다른 스레드가 먼저 잠금을 획득했다면 이미 초기화를 수행했을 수 있다. 그렇다면 초기화 된 변수를 반환한다.
  4. 그렇지 않으면 초기화하고 변수를 반환한다.
// 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...
}
  1. Thread A 는 값이 초기화되지 않았음을 확인하여 Lock을 획득하고 값을 초기화하기 시작합니다.
  2. 컴파일러가 생성 한 코드는 A 가 초기화를 완료 하기 전에 부분적으로 구성된 객체를 가리 키도록 공유 변수를 업데이트 할 수 있다. 예를 들어 Java에서 생성자에 대한 호출이 인라인 된 경우 스토리지가 할당되고 인라인 생성자가 객체를 초기화하기 전에 공유 변수가 즉시 업데이트 될 수 있다.
  3. 스레드 B 는 공유 변수가 초기화되었음을 (또는 그렇게 표시됨) 인식하고 해당 값을 반환한다. 스레드 B 는 값이 이미 초기화되었다고 믿기 때문에 Lock을 획득하지 않는다. 이 경우에 스레드 B가 수행 초기화 이전에 객체 사용을 하게 되는데(A는 그것을 초기화 완료되지 않았거나 오브젝트의 초기화 값의 일부가 아직 쓰레드 B의 메모리에 여과하지 않았기 때문에(캐시 일관성 문제), 프로그램이 충돌할 가능성이 있다.

이는 스레드가 동일 메모리(공유하는 메모리)에서 값을 읽어오는 것이 아니라 각각의 CPU 캐시에서 값을 읽어오기 때문에 변수의 값이 일관성이 없는 것이다.

4. volatile를 이용한 개선된 DCL(Double-Checked-Locking) Singleton 패턴 (jdk 1.5이상에서 사용)

// 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%까지 높였다.

5. Java 9에서 도입된 VarHandle, getHelperAcquire(), setHelperRelease()

// 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 필드가 더이상 동기화 순서에 참여하지 않는다.

6. LazyHolder Singleton 패턴

또한 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;
    }
}

7. Java 5에서 도입된 FinalWrapper 클래스

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을 사용하지 않고 쓰레드 세이프함을 보장한다.

8. Lazy Initailization. Enum(열거 상수 클래스)

public enum Singleton{
    INSTANCE;
}

ENUM 인스턴스의 생성은 기본적으로 스레드 세이프하다. 또한 스레드 관련 코드가 없어서 코드가 간단해진다. 그러나 만들려는 싱글턴 객체가 Enum 외의 클래스를 상속해야 하는 경우는 사용할 수 없다.

참고

profile
이것저것 관심많은 개발자.
post-custom-banner

0개의 댓글