싱글턴(singleton): 인스턴스를 오직 하나만 생성할 수 있는 클래스, 2021/01/19 - [책읽기/Design Patterns] - Singleton Pattern

 

클래스를 싱글턴으로 만들면 이를 사용하는 클라이언트를 테스트하기가 어려워질 수 있다.

타입을 인터페이스로 정의한 다음 그 인터페이스를 구현해서 만든 싱글턴이 아니라면, 싱글턴 인스턴스를 가짜(mock) 구현으로 대체할 수 없기 때문이다.

 

싱글턴을 만드는 방식

기본적으로 생성자는 private 으로 감춰두고, => 인스턴스 생성 불가, 서브클래스 생성 불가

유일한 인스턴스에 접근할 수 있는 수단으로 public static 멤버 를 하나 마련해둔다.

 

 

1. Eager Initialization

가장 간단한 형태의 구현 방법으로, 인스턴스를 클래스 로딩 단계에서 객체를 프로그램 시작과 동시에 초기화하는 방법이다.

package com.example.demo.item03;

public class EagerInitializationSingleton {

	private static final EagerInitializationSingleton instance = new EagerInitializationSingleton();

	private EagerInitializationSingleton() {
		if (instance != null) {
			throw new InstantiationError("Creating of this object is not allowed.");
		}
	}

	public static final EagerInitializationSingleton getInstance() {
		return instance;
	}
}

private 생성자는 private static final 필드인 instance를 초기화할 때 딱 한 번만 호출되고, getInstance는 항상 같은 객체의 참조를 반환하므로, EagerInitializationSingleton 클래스가 초기화될 때 만들어진 인스턴스가 전체 시스템에서 하나뿐임이 보장된다.

기본 생성자에서 예외를 리턴하는데, 클라이언트가 자바 리플렉션 API(아이템 65)를 사용하여 private 생성자를 호출할 수도 있다.

이러한 공격을 방어하려면 생성자를 수정하여 두 번째 객체가 생성되려 할 때 예외를 던지게 하면 된다.

package com.example.demo.item03;

import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;

public class SingletonCreateSecondExceptionTest {

	@Test
	public void testEagerInitializationSingletonException() throws IllegalAccessException, InvocationTargetException, InstantiationException {
		Constructor<?>[] constructors = EagerInitializationSingleton.class.getDeclaredConstructors();
		Constructor theConstructor = constructors[0];
		theConstructor.setAccessible(true);
		EagerInitializationSingleton singleton1 = (EagerInitializationSingleton) theConstructor.newInstance();
		EagerInitializationSingleton singleton2 = (EagerInitializationSingleton) theConstructor.newInstance();

		System.out.println(singleton1);
		System.out.println(singleton2);

		Assertions.assertSame(singleton1, singleton2);
	}
}

 

이 방법의 장점은 API를 바꾸지 않고도 싱글턴이 아니게 변경할 수 있다는 점이다. 또한, 두 번째 장점은 원한다면 정적 팩터리를 제네릭 싱글턴 팩터리로 만들 수 있다는 점이다(아이템 30). 세 번째 장점은 정적 팩터리의 메서드 참조를 공급자(supplier)로 사용할 수 있다는 점이다. 가령 Singleton::get Instance를 Supplier<Singleton>로 사용하는 식이다(아이템 43, 44). 

 

자세한 클래스 로딩 시점은 2020/12/18 - [작성중...] - JVM의 구조와 array, hashmap max size에 대한 생각 - 작성중...에서 볼 수 있다.

인스턴스를 사용하지 않더라도 인스턴스를 생성하기 때문에 낭비가 될 수 있다.

즉, File System, Database Connection 등 객체 생성에 많은 리소스를 사용하는 싱글톤을 구현할 때는 직접 사용할 때까지 싱글톤 인스턴스를 생성하지 않는 방법이 더 좋다.

//예시 java.util.HashSet
public class HashSet<E>
    extends AbstractSet<E>
    implements Set<E>, Cloneable, java.io.Serializable
{
    static final long serialVersionUID = -5024744406713321676L;

    // Dummy value to associate with an Object in the backing Map
    private static final Object PRESENT = new Object();

 ...
 }

 

2. Static Block Initialization

Eager Initialization과 유사하지만 static block을 통해서 Exception Handling을 제공한다.

Eager Initialization과 마찬가지로 클래스 로딩 단계에서 인스턴스를 생성하므로, 큰 리소스를 다루는 경우에는 적절하지 않다.

package com.example.demo.item03;

public class StaticBlockInitializationSingleton {

	private static StaticBlockInitializationSingleton instance;

	private StaticBlockInitializationSingleton() {
		if (instance != null) {
			throw new InstantiationError("Creating of this object is not allowed.");
		}
	}

	static {
		try {
			instance = new StaticBlockInitializationSingleton();
		} catch (Exception e) {
			throw new RuntimeException("Creating of this object is not allowed.");
		}
	}

	public static StaticBlockInitializationSingleton getInstance() {
		return instance;
	}
}

 

3. Lazy Initialization > single thread 에서만 사용

앞선 두 방식과는 달리 나중에 초기화하는 방법이다.

public 메소드 getInstance()를 호출할 때 인스턴스가 없는지 확인하고, 없다면 생성한다. 따라서 인스턴스 낭비를 막을 수 있다.

package com.example.demo.item03;

public class LazyInitializationSingleton {

	private static LazyInitializationSingleton instance;

	private LazyInitializationSingleton() {
		if (instance != null) {
			throw new InstantiationError("Creating of this object is not allowed.");
		}
	}

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

하지만 multi-thread 환경에서 instance==null인 시점일 때 여러 쓰레드가 동시에 getInstance() 를 호출 한다면

thread-safe 하지 않을 수 있다는 치명적인 단점이 있다.

	@Test
	public void testLazyInitializationSingleton() throws InterruptedException {
		int numberOfThreads = 500;
		ExecutorService service = Executors.newFixedThreadPool(500);
		CountDownLatch latch = new CountDownLatch(numberOfThreads);

		HashSet<LazyInitializationSingleton> singletonHashSet = new HashSet<>();

		for (int i = 0; i < numberOfThreads; i++) {
			service.execute(() -> {
				LazyInitializationSingleton lazyInitializationSingleton = LazyInitializationSingleton.getInstance();
				singletonHashSet.add(lazyInitializationSingleton);
				latch.countDown();
			});
		}
		Assertions.assertEquals(singletonHashSet.size(), 1);
		latch.await();
	}

lazy initialization singleton 방식만 multi-thread 테스트를 통과하지 못함

 

4. Thread Safe Singleton

Lazy Initialization의 thread-safe 문제를 해결하기 위한 방법으로, getInstance() 메소드에 synchronized를 걸어두는 방식이다.

synchronized 키워드는 임계 영역(Critical Section)을 형성해 해당 영역에 오직 하나의 쓰레드만 접근 가능하게 해 준다.

package com.example.demo.item03;

public class SynchronizedSingleton {

	private static SynchronizedSingleton instance;

	private SynchronizedSingleton() {
		if (instance != null) {
			throw new InstantiationError("Creating of this object is not allowed.");
		}
	}

	public static synchronized SynchronizedSingleton getInstance() {
		if (instance == null) {
			instance = new SynchronizedSingleton();
		}
		return instance;
	}
}

getInstance() 메소드 내에 진입하는 쓰레드가 하나로 보장받기 때문에 멀티 쓰레드 환경에서도 정상 동작하게 된다.

그러나 synchronized 키워드 자체에 대한 비용이 크기 때문에 싱글톤 인스턴스 호출이 잦은 경우 성능이 떨어지게 됩니다.

 

5. Double checked Locking Singleton

synchronized 키워드가 성능상으로 좋지 않기 때문에,

getInstance() 메소드 수준에 lock을 걸지 않고 instance가 null일 경우에만 synchronized가 동작하도록 한다.

package com.example.demo.item03;

public class DoubleCheckingLockingSingleton {

	private static DoubleCheckingLockingSingleton instance;

	private DoubleCheckingLockingSingleton() {
		if (instance != null) {
			throw new InstantiationError("Creating of this object is not allowed.");
		}
	}

	public static DoubleCheckingLockingSingleton getInstance() {
		if (instance == null) {
			synchronized (DoubleCheckingLockingSingleton.class) {
				if (instance == null) {
					instance = new DoubleCheckingLockingSingleton();
				}
			}
		}
		return instance;
	}

}

 

6. Bill Pugh Singleton Implementation

Bill Pugh가 고안한 방식으로, inner static helper class를 사용하는 방식이다.

앞선 방식이 안고 있는 문제점들을 대부분 해결한 방식으로, 현재 가장 널리 쓰이는 싱글톤 구현 방법이다.

package com.example.demo.item03;

public class InitializationOnDemandHolderIdiomSingleton {

	private InitializationOnDemandHolderIdiomSingleton() {
		if (instance != null) {
			throw new InstantiationError("Creating of this object is not allowed.");
		}
	}

	public static InitializationOnDemandHolderIdiomSingleton getInstance() {
		return SingletonLazyHolder.INSTANCE;
	}

	private static class SingletonLazyHolder {

		private static final InitializationOnDemandHolderIdiomSingleton INSTANCE = new InitializationOnDemandHolderIdiomSingleton();
	}
}

private inner static class를 두어 싱글톤 인스턴스를 갖게 하는데,

inner class인 SingletonHelper 클래스는 Singleton 클래스가 로드 될 때가 아닌, getInstance()가 호출됐을 때

비로소 JVM 메모리에 로드되고, 인스턴스를 생성하게 된다.

(+ synchronized를 사용하지 않기 때문에 성능 문제가 해결된다.)

 

7. Enum 방식

앞선 1~6 방식은 자바 리플렉션 api를 이용하여 싱글톤을 파괴할 수 있다.

package com.example.demo.item03;

public enum EnumSingleton {
	INSTANCE;
}

enum 방식은 이와 달리 간결할 뿐만 아니라, 쉽게 직렬화할 수 있고, 제2의 인스턴스를 생성하는 리플렉션 공격도 완벽히 막아준다.

조금 부자연스러워 보일 수는 있으나 대부분 상황에서는 원소가 하나뿐인 열거 타입이 싱글턴을 만드는 가장 좋은 방법 일 수 있다.

단, 만들려는 싱글턴이 Enum 외의 클래스를 상속해야 한다면 이 방법은 사용할 수 없다(열거 타입이 다른 인터페이스를 구현하도록 선언할 수는 있다).

그러나 이 방법 또한 1, 2번과 같이 클래스를 사용하지 않을 경우 메모리 낭비를 한다는 단점이 있다.

 

싱글턴 클래스의 직렬화

싱글턴 클래스를 직렬화하려면(12장) 단순히 Serializable을 구현한다고 선언하는 것만으로는 부족하다.

package com.example.demo.item03;

import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;

public class SingletonSerializeTests {

	@Test
	public void testEagerInitializationSingleton() throws IOException, ClassNotFoundException {
		EagerInitializationSingleton singleton1 = EagerInitializationSingleton.getInstance();
		ObjectOutputStream obs = new ObjectOutputStream(new FileOutputStream("filename1.ser"));
		obs.writeObject(singleton1);
		obs.close();

		ObjectInputStream objInputStream = new ObjectInputStream(new FileInputStream("filename1.ser"));
		EagerInitializationSingleton singleton2 = (EagerInitializationSingleton) objInputStream.readObject();
		objInputStream.close();

		Assertions.assertSame(singleton1.getClass(), singleton2.getClass()); // true
		Assertions.assertSame(singleton1, singleton2); // false
		Assertions.assertSame(singleton1.hashCode(), singleton2.hashCode()); // false
	}
}

모든 인스턴스 필드를 일시적 (transient)이라고 선언하고 readResolve 메서드를 제공해야 한다(아이템 89).

이렇게 하지 않으면 직렬화된 인스턴스를 역직렬화할 때 마다 새로운 인스턴스가 만들어진다. 가짜 Singleton 클래스 생성을 예방하고 싶다면 싱글톤 클래스에 다음의 readResolve 메서드를 추가해야 한다.

package com.example.demo.item03;

import java.io.Serializable;

public class EagerInitializationSingleton implements Serializable {

	private static EagerInitializationSingleton instance = new EagerInitializationSingleton();

	private EagerInitializationSingleton() {
		if (instance != null) {
			throw new InstantiationError("Creating of this object is not allowed.");
		}
	}

	public static EagerInitializationSingleton getInstance() {
		return instance;
	}

	private Object readResolve() {
		return instance;
	}
}
package com.example.demo.item03;

import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;

public class SingletonSerializeTests {

	@Test
	public void testEagerInitializationSingleton() throws IOException, ClassNotFoundException {
		EagerInitializationSingleton singleton1 = EagerInitializationSingleton.getInstance();
		ObjectOutputStream obs = new ObjectOutputStream(new FileOutputStream("filename1.ser"));
		obs.writeObject(singleton1);
		obs.close();

		ObjectInputStream objInputStream = new ObjectInputStream(new FileInputStream("filename1.ser"));
		EagerInitializationSingleton singleton2 = (EagerInitializationSingleton) objInputStream.readObject();
		objInputStream.close();

		Assertions.assertSame(singleton1.getClass(), singleton2.getClass()); // true
		Assertions.assertSame(singleton1, singleton2); // true
		Assertions.assertSame(singleton1.hashCode(), singleton2.hashCode()); // false
	}
}

같은 객체이기 때문에 hashcode 또한 같은 값을 리턴해야 한다고 생각했는데 다른 값을 리턴하는 것을 확인하고 이슈에 질문을 올렸다.

github.com/dolly0920/Effective_Java_Study/issues/9

 

[item 3] hashcode · Issue #9 · dolly0920/Effective_Java_Study

singleton 객체에 readResolve 함수를 구현하고 serialize & deserialize 해 보았는데 Assertions.assertSame(singleton1, singleton2)가 true를 리턴하는 것을 보아 같은 인스턴스로 직렬화 & 역직렬화가 된 것 같긴 한데 Ass

github.com

알고보니 Assertions.assertSame의 특성을 잘 이해하지 못하고 사용해서 문제가 생겼던 것이다.

assertSame 는 객체가 같은지 비교하는 가정문이다.

Assert.assertSame(1000, 1000); // false
Assert.assertSame(Integer.valueOf(1000), Integer.valueOf(1000)); // false
Assert.assertSame(new Integer(1000), new Integer(1000)); // false

assertSame 은 참조형(Reference Type)인 경우에만 사용해야 한다.
만약 기본형(Primitive Type)을 비교해야 한다면 값을 비교하는 가정문 assertEquals 을 사용해야 한다.

assertSame(Object arg, Object arg2) 의 파라미터 형은 Object 이기 때문에,
autoboxing이 일어나서 새로운 객체가 생성되어 같은 값이라도 hashCode 가 다르다 -> false 리턴

추가로,
Assert.assertSame(127, 127); // true
자바에서 -128 ~ 127 사이의 값은 미리 저장된 값을 이용하기 때문에 (새로운 객체를 생성하지 않아서) true를 리턴한다

 

용어

  • 무상태 (stateless) 객체: 
    •  인스턴스 변수가 없는 객체
    • Stateless object is an instance of a class without instance fields (instance variables). The class may have fields, but they are compile-time constants (static final).
    • A very much related term is immutable. Immutable objects may have state, but it does not change when a method is invoked (method invocations do not assign new values to fields). These objects are also thread-safe.
    • 장점: 다른 클래스에 종속적이지 않음, thread-safe, 인터페이스화 하기 용이하다
    • 공부해 볼 것: 
  • supplier: 인자는 받지않으며 리턴타입만 존재하는 메서드를 갖고있다. 순수함수에서 결과를 바꾸는건 오직 input parameter 뿐이다. 그런데 input이 없다는건 내부에서 랜덤함수같은것을 쓰는게 아닌이상 항상 같은 것을 리턴하는 메서드라는걸 알 수 있다.
  • synchronized: 여러개의 스레드가 한개의 자원을 사용하고자  , 현재 데이터를 사용하고 있는 해당 스레드를 제외하고 나머지 스레드들은 데이터에 접근할 수 없도록 막는다.
  • 임계영역: 둘 이상의 스레드가 동시에 실행될 경우 생길 수 있는 동시 접근 문제를 발생시킬 수 있는 코드 블록

+ Recent posts