Goal
- Singleton이 무엇인지 이해한다
- Singleton을 생성하는 방법에 대해 알아보고 이해한다
Singleton Pattern
싱글턴 패턴이란 인스턴스를 하나만 만들어 사용하기 위한 패턴이다. 프로그램 시작부터 종료 시까지 어떤 클래스의 인스턴스가 메모리 상에 단 하나만 존재할 수 있게 하고 이 인스턴스에 대해 어디에서나 접근할 수 있도록 하는 패턴이다.
왜 싱글턴 패턴을 사용할까?
- 예로, 로깅을 찍는 객체, 커넥션 풀, 스레드 풀, 디바이스 설정 객체 등과 같은 인스턴스를 여러개 만들게 되면 불필요한 자원을 사용하게 되며 또 프로그램이 예상치 못한 결과를 낳을 수 있다.
- 결과에 일관성이 없어질 가능성이 있다
- 한정된 리소스에서 메모리를 사용할 때 사용한다 => 메모리를 많이 사용하면 안될 때
결론 : 자원을 많이 잡아먹는 인스턴스가 있다면, 싱글턴이 유용하다.
싱글턴 패턴을 적용할 경우 의미상 두 개의 객체가 존재할 수 없다. 이를 구현하려면
- 객체 생성을 위한 new에 제약을 걸어야 하고- private 접근제어자를 사용하여 외부로부터 인스턴스 생성 차단
- 만들어진 유일한 단일 객체를 반환할 수 있는 정적 메서드가 필요하다.
- 유일한 단일 객체를 참조할 정적 참조 변수가 필요하다
Singleton Pattern 을 만드는 방법
1. Eager Initialization
인스턴스를 필요시 생성이 아닌, static을 통해 해당 클래스를 Class Loader가 로딩할 때 객체를 생성해 준다.
public class Singleton {
private static final Singleton instance = new Singleton();
private Singleton(){}
public static Singleton getInstance(){
return instance;
}
}
=> 하지만 이 방법은 객체를 사용하지 않더라도 객체가 무조건 생성되기 때문에 자원 낭비가 될 수 있는 단점이 존재한다. 또한 Exception에 대한 처리를 하지 않는다.
그러나 해당 클래스를 사용할 때 static영역에 올라가고 ,사용하지 않으면 애초에 인스턴스 조차 만들어지지 않을텐데 왜 자원 낭비가 된다는 거지 ? 라는 의문이 계속 드는 것이다. 책을 보고 구글링을 할 수록 정보가 취합이 안되서, 멘토링날 멘토님께 질문을 드렸다.
멘토님은 클래스 로딩 동작과정을 이해할 필요가 있다고 말씀해주셨다. 위 코드가 자원의 낭비를 가져올 수 있다는 점은 아래와 같다.
final 키워드를 사용한 경우
private static final Singleton instance = new Singleton();
- final 키워드를 사용한 경우, 두 가지 경우에 의해 초기화가 된다
- 생성자에 의한 초기화 => 그러나 싱글턴의 경우 생성자에 의한 초기화가 불가능하기 때문에 위의 경우 해당 x
- 클래스 로딩 시 초기화 => 선언과 동시에 할당해야한다
- 위와 같은 구조이기 때문에 클래스 로딩이 일어나는게 실제 클래스를 인스턴스화 하는 의미가 아니고, 그렇기 때문에 메모리의 낭비를 초래하게 되는 것
- 다만 클래스 로딩에 의한 초기화에는 멀티 쓰레드에도 thread safety를 보장하게 되어 동시성에서는 자유로움
- 클래스가 로딩될때 jvm에서 Singleton의 하나뿐인 인스턴스를 생성햊고, JVM에서 하나뿐인 인스턴스를 생성하기 전까지 그 어떤 스레드도 instance 정적변수에 접근이 불가하기 때문
final을 사용하지 않은 경우
public class EagerSingleton {
//static 정적 상수
private static EagerSingleton eSingleton = new EagerSingleton();
private EagerSingleton() {
System.out.println("싱글톤 인스턴스 생성");
}
// static 정적 메소드
public static EagerSingleton getESingleton(){
return eSingleton;
}
public static void nothing(){
}
}
public class Driver {
public static void main(String[] args){
System.out.println("main 실행");
EagerSingleton.nothing();
}
}
결과
main 실행
싱글톤 인스턴스 생성
- 위와 같이 멤버변수는 클래스 로딩이 될 때 초기화므로, 사용되지 않을 때는 클래스 로딩이 되지 않을 것이다.
- 그러나 EagerSingleton 클래스에 다른 static 메소드인 nothing()이 존재하고, 그 메소드를 호출 한다면 getESingleton() 를 호출하지 않아도 EagerSingleton 클래스의 인스턴스는 생성된다. 다른 static 메소드에 의해 EagerSingleton 클래스가 로드되기 때문이다. => 자원의 낭비 발생
- 개선안으로 나온 final을 사용하지 않는 경우 final이 인스턴스 변수에 없기 때문에 클래스 로딩이 되는 시점에 초기화가 되지 않아 메모리 측면에서는 유리하나 static 메서드를 부를 때 인스턴스 변수의 할당 상태를 확인 해야되는데 이 부분에 멀티 쓰레드에서 동시에 접근 하는 경우 싱글턴 조건이 깨질 수 있다.
2. Static Block Initialization
Eager Initialization 방법과 비슷하지만 Static Block을 사용하여 Exception 처리를 해주는 방법이다.
public class Singleton2 {
private static Singleton2 instance;
private Singleton2(){}
static{
try{
instance = new Singleton2();
}catch (Exception e){
throw new RuntimeException("Exception occured in creating singleton instance");
}
}
public static Singleton2 getInstance(){
return instance;
}
}
Static Bolck은 초기화 블록(Initialization Block)이라고 불리며 클래스가 처음 로딩될 때 한 번만 수행되는 블록을 의미한다. 잘 사용하지는 않지만 클래스 변수의 복잡한 초기화에 사용된다. 비슷한 개념으로 Instance Block이 존재한다.
하지만 이 방법도 클래스 로딩 단계에서 객체를 생성하기 때문에 자원의 비효율성을 해결할 수 없다.
3. Lazy Initialization
static으로 선언된 getInstance() 메서드를 통해 객체를 생성해 주는 방법이다.
정적 변수 instance가 null이면 인스턴스가 생성되지 않았다는 사실을 알 수있고, null이 아니면 이미 객체가 생성됨을 알 수 있다.
아직 인스턴스가 만들어지지 않았다면 private으로 선언된 생성자를 사용하여 singleton 객체를 만든 다음 instance에 그 객체를 대입한다. 이러면 인스턴스가 필요한 상황까지 인스턴스를 생성하지 않게 되므로, 이런 방법을 게으른 인스턴스 생성이라고 한다
public class Singleton3 {
private static Singleton3 instance;
private Singleton3(){}
public static Singleton3 getInstance(){
if(instance == null){
instance = new Singleton3();
}
return instance;
}
}
이 방법은 1, 2단계의 문제점인 자원의 비효율성을 해결해 줄 수 있지만 싱글톤 패턴은 한 객체를 여러 곳에서 접근할 수 있기 때문에 멀티 스레드 환경에서 동기화 문제가 발생할 수 있다. 만약 한 번에 여러 곳에서 getInstance() 메서드를 호출한다면 여러 개의 객체가 생성될 수 있기 때문이다.
즉, 이 방법은 single-thread 환경에서는 괜찮은 방법이지만 multi-thread 환경에서는 동기화 문제가 발생할 수 있다
=> thread unsafe
4. Thread Safe Singleton
lazy initialization에서 synchronized를 사용하여 동시성 문제를 해결한 방법
public class Singleton3 {
private static Singleton3 instance;
private Singleton3(){}
public static synchronized Singleton3 getInstance(){
if(instance == null){
instance = new Singleton3();
}
return instance;
}
}
어떤 한순간에는 하나의 스레드 만이 임계 영역(Critical Section) 안에서 실행하는 것이 보장된다 = thread safe
하지만 이 방법도 문제가 존재한다. 우리는 해당 객체를 안전하게 한 번 생성하기 위해 synchronized를 사용하는 것인데 이 방법은 해당 객체를 생성한 후 접근할 때에도 계속해서 synchronized를 호출하게 된다.
synchronized는 하나의 thread만 접근할 수 있고, 하나의 thread만 사용할 수 있다 = 오버헤드
즉, 싱글톤 객체를 자주 사용해야 한다면 synchronized가 자주 호출되면서 많은 비용이 발생하게 되고 이에 따른 성능 저하가 발생하게 된다.
Double Checked Locking 방식
위의 오버헤드를 피하기 위해서 인스턴스를 생성되어있는지 확인한 다음, 생성되어 있지 않은 경우에만 synchronized를 통해 동기화하는 방식. 이러면 처음에만 동기화 하고 나중에는 동기화하지 않아도 된다.
public class Singleton3 {
private static Singleton3 instance;
private Singleton3(){}
public static Singleton3 getInstance(){
if(instance == null){
synchronized (Singleton3.class){
instance = new Singleton3();
}
}
return instance;
}
}
메서드에 synchronized를 붙이지 말고 메서드 내부에 synchronized를 사용하여 이름 그대로 두 번의 검사를 통해 싱글톤 객체를 생성 및 반환하는 방법이다. => 특정 영역만 동기화, 메서드 영역보다 범위가 작다.
객체가 null 일 경우에만 synchronized가 실행되도록 하여 객체가 생성된 후에는 synchronized가 실행되지 않는다. 즉, 무분별한 synchronized 호출의 비용을 절약할 수 있다.
그러나, 자바 1.4이전 버전에서 사용 불가
문제
그러나 멀티코어 환경에서 DCL(Doulbe-Checked Locking) 방식으로 싱글톤 객체를 생성할 때 동기화 문제가 발생할 수 있다.
1. 동기화 문제: 다중 스레드가 동시에 객체의 인스턴스 생성 메서드를 호출할 경우, 인스턴스 생성 로직이 여러 번 실행될 수 있어, 이로 인해 동일한 객체가 여러 개 생성될 수 있다.
2. 컴파일러 최적화: 컴파일러나 JVM은 코드를 최적화하기 위해 사용된다. 하지만 DCL 방식에서는 인스턴스 변수를 생성하고 초기화하는 코드와, 인스턴스를 반환하는 코드 사이에 다른 스레드가 인스턴스 변수를 참조하는 경우 최적화에 의해 의도하지 않은 결과를 가져올 수 있습니다.
해결
private volatile static Singleton3 instance;
이러한 동기화 문제를 해결하기 위해 volatile 키워드를 사용한다. volatile 키워드를 사용하면 인스턴스 변수를 메인 메모리에 쓰고 읽는 것을 보장하여 최신 값으로 접근할 수 있도록 한다.
volatile 란?
- volatile 키워드는 java 변수를 Main Memory에 저장하겠다 라는 것을 명시한다.
- 매번 변수의 값을 읽을 때마다 CPU cache에 저장된 값이 아닌 Main Memory에서 읽는 것이다.
- 또한 변수의 값을 쓸 때마다 Main Memory까지 작성한다.
- volatile 변수에 대한 각 write 는 즉시 메인 메모리로 플러시 된다.
- 스레드가 변수를 캐시하기로 결정하면 각 read/write 시 메인 메모리와 동기화 된다.
따라서 DCL 방식을 사용할 때는 멀티코어 환경에서 동기화 문제를 해결하기 위해 volatile 키워드를 사용하는 것이 좋다.
5. Bill Pugh Singleton Implementation or Lazy Holder
Inner Static Helper Class를 사용하는 방식으로 현재 가장 널리 사용되고 있는 싱글톤 패턴 구현 방법이다.
public class Singleton4 {
private Singleton4(){}
private static class SingletonHelper{
private static final Singleton4 INSTANCE = new Singleton4();
}
public static Singleton4 getInstance(){
return SingletonHelper.INSTANCE;
}
}
ingletonHelper 클래스는 Inner Class로 선언되었기 때문에 Singleton 클래스가 Class Loader에 의해 로딩될 때 로딩되지 않다가 getInstance()가 호출될 때 JVM 메모리에 로드되고 객체를 생성하게 된다.
또한 클래스가 로드될 때 객체가 생성되기 때문에 multi-thread 환경에서도 안전하게 사용이 가능하다.
지금까지의 문제점을 모두 해결해 줄 수 있으며 Double Checked Locking 방식보다 구현도 간단하기 때문에 굉장히 좋은 방법이다.
하지만 이 방법도 Java의 Reflection을 사용하면 private 생성자, 메서드에 접근이 가능해지며 단 하나의 객체라는 조건을 깨뜨려버린다.
6. Enum Singleton
Enum을 사용하여 싱글톤 패턴을 구현하는 방법이다.
enum EnumSingleton {
INSTANCE;
public static void doSomething() {
// do something
}
}
Enum Singleton 방법은 구현하기가 매우 간단하며 동기화, Reflection, 직렬화와 역직렬화의 문제점도 해결해 줄 수 있다.
하지만 Eager Initialization, Static Block Initialization 방식처럼 Lazy Loading이 아니기 때문에 자원의 비효율성을 해결해 주지 못하는 단점도 존재한다.
참고
멘토님
스프링 입문을 위한 자바 객체 지향의 원리와 이해
https://sabarada.tistory.com/128
'JAVA > 디자인 패턴' 카테고리의 다른 글
[Proxy Pattern] 프록시 패턴 - 가상 프록시 (0) | 2023.07.03 |
---|---|
[Proxy Pattern] 프록시 패턴 - 원격 프록시 (0) | 2023.07.03 |
[Proxy Pattern] 프록시 패턴이란? (0) | 2023.07.03 |
[Adapter Pattern] 어댑터 패턴이란? (0) | 2023.06.28 |
[Design Pattern] 디자인 패턴이란? (0) | 2023.06.28 |