인생을 코딩하다.

[JAVA] 싱글톤 패턴은 객체가 단 1개만 생성되는 것을 보장할까? 및 자바에서 싱글톤을 구현하는 패턴들 (멀티쓰레드 환경에서 본 관점) 본문

Java

[JAVA] 싱글톤 패턴은 객체가 단 1개만 생성되는 것을 보장할까? 및 자바에서 싱글톤을 구현하는 패턴들 (멀티쓰레드 환경에서 본 관점)

Hyung1 2021. 5. 21. 02:36
728x90
반응형

싱글톤 패턴의 기본적인 구현 방법입니다.

public class Singleton {

    private static Singleton instance;

    private Singleton() {}

    public static Singleton getInstance() {

      if (instance == null){
          instance = new Singleton();
      }
      return instance; 
    } 
}

getInstance() 를 여러 번 호출 해도 단 하나의 동일한 instance 만을 반환해 줍니다.

 

싱글톤 패턴의 중요한 두 가지는,

  1. 생성 패턴은 시스템이 어떤 Concrete Class를 사용하는지에 대한 정보를 캡슐화합니다.
  2. 생성 패턴은 이들 클래스의 인스턴스들이 어떻게 만들고 어떻게 결합하는지에 대한 부분을 완전히 가려줍니다.
  3. 동일한 instance를 반환해 줍니다.

쉽게 말해, 생성자를 private하게 만들어 클래스 외부에서는 인스턴스를 생성하지 못하게 차단하고, 내부에서 단 하나의 인스턴스를 생성하여 외부에는 그 인스턴스에 대한 접근 방법을 제공할 수 있습니다.

 

하지만 위의 코드는 멀티쓰레드 환경에서 Thread-safe하지 않습니다.

 

조건문에서 

instance == null

이 조건 때문에 멀티스레드 환경에서 동시에 실행됬을 때, instance를 두 번 생성될 수도 있습니다. 그래서 동일하지 않은 instance가 반환될 수 있습니다.

어떻게 해결해야 할까요?

Synchronized 사용

public class Singleton {
    private static Singleton instance;

    private Singleton() {}

    public static synchronzied Singleton getInstance() {
      if(instance  == null) {
         instance  = new Singleton();
      }
      return instance;
    }
}

여기서 우리는 Synchronzied를 적용하여 멀티 스레드 환경에서 getInstance() 함수 호출 시에 발생할 수 있는 동시성 문제를 해결 할 수 있습니다.

 

하지만 문제점이 있습니다.

둘 이상의 클라이언트 요청이 있을 시에, getInstance 메서드에 Synchronzied를 적용했기 때문에 쓰레드에 Lock가 걸립니다. 그럼 다른 쓰레드는 Lock이 풀릴때까지 기다려야 합니다.

 

이것은 성능 저하를 불러옵니다. 조금 더 나은 성능을 보장 할 수 있는 방법이 있습니다.

 

dclp (double checked locking pattrn)

double-checked locking 은 null check 와 같은 부분을 synchronized 밖으로 빼서synchronized 를 기다리지 않고 처리하게 만들어 줍니다.

public class Singleton {
    private static DoubleCheckedSingleton instance;

    private DoubleCheckedSingleton() {}
    
    public DoubleCheckedSingleton getInstance() {

     if(instance == null) {
         synchronized(Singleton.class) {
            if(instance == null) {
               instance = new DoubleCheckedSingleton(); 
            }
         }
      }
      return instance;
    }
}

getInstace 메서드에 Synchronzied를 사용하지 않고 instance가 null인지 확인한 후, null이라면 그때 Synchronzied를 적용하여 스레드를 동기화해서 instance가 null인지 다시 체크하는 방법법입니다.

 

밖에서 하는 체크는 이미 인스턴스가 생성된 경우 빠르게 인스턴스를 리턴하기 위함이고, 안에서 하는 체크는 인스턴스가 생성되지 않은 경우 단 한개의 인스턴스만 생성되도록 보장하기 위함입니다.

 

lazy하다는 생각이들고 getInstace 메서드에 Synchronzied를 사용한 것보다는 성능 저하를 어느정도 개선할 수 있다고 생각합니다.

하지만 이것도 다른 인스턴스를 생성할 수 있는 문제점이 있다.

우리가 volatile를 쓰지 않았을떄의 문제점을 생각해보면 됩니다. volatile에 관해 모른다면 여기를 참고해주시면 됩니다.

 

멀티쓰레드 어플리케이션에서의 non-volatile 변수에 대한 작업은 성능상의 이유로 CPU 캐시를 이용합니다. 둘 이상의 CPU가 탑제된 컴퓨터에서 어플리케이션을 실행한다면, 각 쓰레드는 변수를 각 CPU의 캐시로 복사하여 읽어들입니다. 

non-volatile 변수에 대한 작업은 JVM 이 메인 메모리로부터 CPU 캐시로 변수를 읽어들이거나, CPU 캐시로부터 메인 메모리에 데이터를 쓰거나 할 때에 대한 어떠한 보장도 하지 않습니다.

  • volatile 변수를 사용하고 있지 않는 MultiThread 어플리케이션에서는 Task를 수행하는 동안 성능 향상을 위해 Main Memory에서 읽은 변수 값을 CPU Cache에 저장하게 됩니다.
  • 만약에 Multi Thread환경에서 Thread가 변수 값을 읽어올 때 각각의 CPU Cache에 저장된 값이 다르기 때문에 변수 값 불일치 문제가 발생하게 됩니다.
public class SharedObject {

    public int counter = 0;

}

둘 이상의 쓰레드가 다음과 같은 공유 객체로 접근하는 경우를 생각해보도록 하겠습니다.

 

Thread1 은 counter 변수를 증가시키고, Thread1 과 Thread2 가 때에 따라서 counter 변수를 읽습니다.

 

만일 counter 변수에 volatile 키워드가 없다면, counter 변수가 언제 CPU 캐시에서 메인 메모리로 쓰일지(written) 보장할 수 없습니다. CPU 캐시의 counter 변수와 메인 메모리의 counter 변수가 다른 값을 가질 수 있다는 것입니다.

쓰레드가 변경한 값이 메인 메모리에 저장되지 않아서 다른 쓰레드가 이 값을 볼 수 없는 상황을 '가시성' 문제라고 합니다. 한 쓰레드의 변경(update)이 다른 쓰레드에게 보이지 않습니다.

 

counter 변수에 volatile 키워드를 선언한다면 이 변수에 대한 쓰기 작업은 즉각 메인 메모리로 이루어질 것이고, 읽기 작업 또한 메인 메모리로부터 다이렉트로 이루어질 것입니다.

 

이러한 문제는 volatile 키워드를 추가함으로써 해결할 수 있습니다.

public class SharedObject {
    public volatile int counter = 0;
}

volatile 키워드를 추가하게 되면 Main Memory에 저장하고 읽어오기 때문에 변수 값 불일치 문제를 해결 할 수 있습니다.

 

여기서 Main Memroy를 제외한 CPU Cache가 있는 부분을 Working Memory고 합니다.

 

이렇게 메인메모리와 스레드의 Working 메모리 간에 데이터의 이동이 있기 때문에 메인메모리와 Working 메모리간에 동기화가 진행되는 동안 빈틈이 생기게 됩니다.

 

volatile 을 사용하지 않는 Double Checked Locking 방법에서는 아래와 같은 문제들이 발생할 수 있습니다.

  • 첫번째 스레드가 instance 를 생성하고 synchronized 블록을 벗어남.
  • 두번째 스레드가 synchronized 블록에 들어와서 null 체크를 하는 시점에서,
  • 첫번째 스레드에서 생성한 instance 가 working memory 에만 존재하고 main memory 에는 존재하지 않을 경우
  • 또는, main memory 에 존재하지만 두번째 스레드의 working memory 에 존재하지 않을 경우
  • 즉, 메모리간 동기화가 완벽히 이루어지지 않은 상태라면
  • 두번째 스레드는 인스턴스를 또 생성하게 된다.

따라서, Double Checked Locking 으로 싱글톤 패턴 구현시 인스턴스를 레퍼런스하는 변수에 volatile 을 사용해줘야 합니다.

 

volatile 로 선언된 변수는 아래와 같은 기능을 하기 때문입니다.

  • 각 스레드가 해당 변수의 값을 메인 메모리에서 직접 읽어옵니다.
  • volatile 변수에 대한 각 write 는 즉시 메인 메모리로 플러시 됩니다.
  • 스레드가 변수를 캐시하기로 결정하면 각 read/write 시 메인 메모리와 동기화 됩니다.

그래서 DCLP에서

public class Singleton {
    private volatile static DoubleCheckedSingleton instance;

volatile 키워드를 선언해줘서 Thread-safe하게 사용할 수 있습니다.

LazyHolder 를 사용하는 싱글톤 패턴

public class Singleton {

    private Singleton() {}

    private static class LazyHolder() {
        private static final Singleton instance = new Singleton();
    }

    public static Singleton getInstance() {
        return LazyHolder.instance;
    }
    
}

private static class 인 LazyHolder 안에 instace 를 final 로 선언하는 방법입니다.

 

클래스는 Singleton 클래스가 Load 될 때에도 Load 되지 않다가 getInstance()가 호출됐을 때 비로소 JVM 메모리에 로드되고, 인스턴스를 생성하게 됩니다. 아울러 synchronized를 사용하지 않기 때문에 위에서 문제가 되었던 성능 저하 또한 해결됩니다

 

static이기 떄문에 초기화가 이루어집니다. LazyHolder 클래스가 초기화 되면서 instance 객체의 생성도 이루어지며, 이 과정에서 static이기 때문에 하나의 인스턴스만 생성되는 것을 보장해줍니다. 그리고 final로 선언했기 때문에 다시instance 가 할당되는 것 또한 막을 수 있습니다.

 

Synchronzied 를 사용하지 않아도 JVM 자체가 보장하는 원자성을 사용하여 Thread-Safe 하게 싱글톤 패턴을 구현할 수 있습니다.

 

JVM은 초기화과정이 sequential, non-concurrent 하므로, 추가적인 Synchronzied 를 사용하여 스레드 동기화를 더 할 필요가 없다는 걸 알 수 있습니다.

 

출처 : 

 

728x90
반응형
Comments