[Java] 멀티스레드환경에서의 싱글톤 객체 그리고 Enum

devdo·2022년 1월 24일
0

Java

목록 보기
23/56
post-thumbnail

스프링은 빈이라는 싱글톤(유일한 하나의 인스턴스) 객체를 IOC 컨테이너에 생성하여 사용한다.

스프링의 빈은 일반적으로

  1. 상태를 가지고 있지 않게 설계 (@Controller, @Service, @Repository, ...)

  2. 어플리케이션 구동 시 ApplicationContext라는 Static Sharing Pool에 싱글톤 인스턴스 생성

2가지 방법으로 멀티스레드에서의 빈 동기화를 신경쓰지 않아도 된다.

😂하지만 싱글톤 객체는 사실 구현 방법에 따라 Thread-Safe 할 수도, 하지 않을 수도 있다.


구현1 - 정적 팩토리 메소드에서 인스턴스 생성 (Not Thread-Safe)

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

private으로 생성자를 숨기고, getInstance()라는 정적 팩토리 메소드로만 인스턴스를 반환받을 수 있다.

싱글스레드 환경에선 문제가 되지 않는다.

하지만 멀티스레드 환경에선 다수 스레드가 메소드에 진입하여 경합을 벌이는 과정(Raced Condition)에서 서로 다른 2개의 인스턴스가 생성될 수 있다.


구현2 - 클래스 초기화 시 인스턴스 생성 (Thread-Safe, 스프링의 방법)

class Singleton {
    private static Singleton instance = new Singleton();
    
    public static Singleton getInstance() {
        return instance;
    }
    
    private Singleton() {}
}

private으로 생성자를 숨기고, 클래스 초기화 시 인스턴스를 생성하여 static 멤버 변수에 할당한다.

getInstance()는 싱글톤 인스턴스를 반환하기만 하므로 팩토리 메소드라 칭하긴 어렵다.

스프링에서 쓰는 방식
스프링에선 Lazy-Intialization을 사용하지 않는 기본적인 경우, 어플리케이션을 구동하면서

ApplicationContext 내에 해당 방법과 같이 인스턴스를 생성하게 된다.

다만 미사용 인스턴스임에도 생성되어 불필요한 시스템 리소스를 낭비할 수도 있다는 단점이 존재한다.


구현3 - 정적 팩토리 메소드에서 인스턴스 생성 + 메소드 동기화 (Thread-Safe, 성능저하)

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

메소드 동기화로 Thread-Safe하지만 성능이 매우 떨어진다.


구현4 - LazyHolder (Thread-Safe, 강력추천)

class Singleton {

    public static Singleton getInstance() {
        return LazyHolder.INSTANCE;
    }
    
    private static class LazyHolder {
        private static final Singleton INSTANCE = new Singleton();
    }
    
    private Singleton() {}
}
  • lazy itialization

이 방법은 이러한 자원 낭비를 막고, 싱글톤 객체에 처음 액세스할 때 인스턴스가 생성된다. 또한 synchronized 예약어를 통해 멀티 스레드 환경에서 객체가 두 개 이상 생성되지 않도록 막는다.

public class Singleton {
    private static Singleton INSTANCE = null;
    private Singleton(){};
    public static Singleton getInstance(){
        if(INSTANCE == null){
            synchronized (Singleton.class){
                if(INSTANCE == null)
                    INSTANCE = new Singleton();
            }
        }
        return INSTANCE;
    }
}

멀티스레드환경에서 위 방식이 제일 좋은 방법이다. 해당 방법으로 구현하자.

JVM이 클래스 정보를 로딩할 때 Singleton 클래스의 정보를 가져오지만

LazyHolder는 inner static class로, INSTANCE 멤버 변수는 LazyHolder.INSTANCE를 처음 참조할 때 초기화된다.

따라서 특정 스레드가 Singleton 객체의 getInstance() 메소드를 호출하는 순간
LazyHolder 클래스의 static final 멤버 변수가 초기화되고 (싱글톤 인스턴스 생성)
이후 getInstance()를 호출하는 스레드들은 해당 인스턴스를 반환받게 된다.

구현1처럼 메소드를 호출하며 경합하게 되지 않을까? 하는 의문이 들 수 있다.

하지만 클래스를 로딩하고 초기화하는 시점은 Thread-Safe를 보장하기 때문에 synchronized와 같은 키워드가 없어도 동기화가 보장된다고 한다.


추가적인 문제1- 직렬화와 역직렬화 (Serialization & Deserialization)

클래스를 역직렬화할 때 새로운 인스턴스가 생성되어 싱글턴 속성을 위반한다.

public class Singleton implements Serializable {
    public static Singleton INSTANCE = new Singleton();
    private int value;
    
    private Singleton(){};
    
    public void setValue(int value){
        this.value = value;
    }
    public int getValue(){
        return this.value;
    }
}
public class SerializeDemo {
    
    public static void main(String[] args) {
        Singleton singleton = Singleton.INSTANCE;
        singleton.setValue(1);
        
        // Serialize
        try {
            FileOutputStream fileOut = new FileOutputStream("out.ser");
            ObjectOutputStream out = new ObjectOutputStream(fileOut);
            out.writeObject(singleton);
            out.close();
            fileOut.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
        
        singleton.setValue(2);
        
        // Deserialize
        Singleton singleton2 = null;
        try {
            FileInputStream fileIn = new FileInputStream("out.ser");
            ObjectInputStream in = new ObjectInputStream(fileIn);
            singleton2 = (Singleton) in.readObject();
            in.close();
            fileIn.close();
        } catch (IOException i) {
            i.printStackTrace();
        } catch (ClassNotFoundException c) {
            System.out.println("singletons.SingletonEnum class not found");
            c.printStackTrace();
        }
        
        if (singleton == singleton2) {
            System.out.println("Two objects are same");
        } else {
            System.out.println("Two objects are not same");
        }
        
        System.out.println(singleton.getValue());
        System.out.println(singleton2.getValue());
    }
}

✨ 해결방법

싱글톤 클래스에 readResolve 메서드를 구현한다.

public class Singleton implements Serializable {
    public static Singleton INSTANCE = new Singleton();
    private int value;
    
    private Singleton(){};
    
    protected Object readResolve() {
        return INSTANCE;
    }
    
    public void setValue(int value){
        this.value = value;
    }
    public int getValue(){
        return this.value;
    }
}


추가적인 문제2 - 리플렉션 (Reflectiom)

리플렉션을 이용하면 런타임에 private 생성자에 접근하여 새로운 인스턴스를 생성할 수 있다.

public class ReflectionDemo {
    
    public static void main(String[] args) throws Exception {
        Singleton singleton = Singleton.INSTANCE;
        
        Constructor constructor = singleton.getClass().getDeclaredConstructor(new Class[0]);
        constructor.setAccessible(true);
        
        Singleton singleton2 = (Singleton) constructor.newInstance();
        
        if (singleton == singleton2) {
            System.out.println("Two objects are same");
        } else {
            System.out.println("Two objects are not same");
        }
        
        singleton.setValue(1);
        singleton2.setValue(2);
        
        System.out.println(singleton.getValue());
        System.out.println(singleton2.getValue());
    }
}

해결 - Enum을 통해 싱글톤 만들기

위의 두 문제는 enum 타입을 사용하여 싱글톤을 만들면 쉽게 해결 가능하다.

public enum Singleton {
   INSTANCE;
}
  • enum 타입은 기본적으로 직렬화 가능하므로 Serializable 인터페이스를 구현할 필요가 없고, 리플렉션 문제도 발생하지 않는다.

  • 인스턴스가 JVM 내에 하나만 존재한다는 것이 100% 보장 되므로, Java에서 싱글톤을 만드는 가장 좋은 방법으로 권장된다.

public enum SingletonEnum {
    INSTANCE;
    int value;
    
    public int getValue() {
        return value;
    }
    public void setValue(int value) {
        this.value = value;
    }
}
public class EnumDemo {
    
    public static void main(String[] args) {
        SingletonEnum singleton = SingletonEnum.INSTANCE;
        
        System.out.println(singleton.getValue());
        singleton.setValue(2);
        System.out.println(singleton.getValue());
    }
}

💥주의사항!

열거형을 직렬화할 때 필드 변수는 소실된다. 즉, 위 코드에서 value 변수는 직렬화되지 않고, 소실된다.



출처

profile
배운 것을 기록합니다.

0개의 댓글