[자바] 싱글톤 정말 한개만 만들어봅시다.

skyepodium·2022년 1월 15일
2
post-thumbnail

스레드 세이프하게 싱글톤을 만들어봅시다.

❗ 싱글톤

1) 정의

싱글톤은 인스턴스를 오직 하나만 생성할 수 있는 클래스 입니다.

2) 목적

  • 오직 하나의 인스턴스만 생성
  • 인스턴스 글로벌 접근

3) 사용 용도

키보드 리더, 프린터 스풀러, 환경 세팅 등 클래스의 인스턴스를 오직 한개만 만들어야 하는 경우 사용합니다.

또한, 스프링은 기본적으로 container에 bean을 등록할때 싱글톤으로 등록합니다.

@Controller, @Service, @Repository등은 여러개의 인스턴스를 가질 필요는 없기 때문에 기본적으로 딱 한개의 인스턴스만 생성되고 컨테이너에 등록됩니다.
(참고로, 싱글톤으로 등록하지 않을 수도 있습니다.)

4) 생성 패턴

디자인 패턴은 크게 1. 생성, 2. 구조, 3. 행위로 구분되는데 싱글톤은 객체의 생성에 대한 패턴 입니다.

😱 멀티 스레드 환경에서의 싱글톤

멀티 스레드 환경에서 동시에 인스턴스 생성을 시도하면, 인스턴스가 여러개 만들어질 수 있습니다.

Settings는 싱글톤 클래스로 null 값을 검사후 인스턴스를 단 한개만 만들도록 설계되었지만, 여러개의 스레드가 동시에 인스턴스 생성을 시도하면 여러개가 생성됩니다.

// Settings.java 싱글톤 클래스
public class Settings {
    private static Settings instance;

    private Settings() {
        System.out.println("새로운 인스턴스가 생성되었습니다.");
    }

    public static Settings getInstance() {
        if (instance == null) {
            instance = new Settings();
        }
        return instance;
    }
}
// Main.java
import java.util.concurrent.*;

class Main {
    public static void main(String args[]) {
        // 1. 스레드 풀 생성
        ExecutorService service = Executors.newCachedThreadPool();

        // 2. 반복문을 통해 - 여러개의 스레드가 거의 동시에 인스턴스를 생성
        for (int i = 0; i < 10; i++) {
            service.submit(() -> {
                Settings.getInstance();
            });
        }
        // 3. 종료
        service.shutdown();
    }
}

실행해보면... 인스턴스가 8개 생성되었습니다.

어... 난 싱글아니야

싱글톤 생성 방법

1. private 생성자

예시의 생성방법이며, 멀티 스레드 환경에서 안전하지 않은 방법5입니다.

1) 생성방법

public class Settings {
    // 1. 클래스 변수로 인스턴스 지정
    private static Settings instance;

    // 2. private 생성자 - new로 인스턴스 생성불가
    private Settings() {}

    // 3. 클래스 메서드를 통해 인스턴스 생성
    public static Settings getInstance() {
        // 주의 - 여러개의 스레드가 동시에 if문을 통과하는 경우 여러개의 인스턴스 생성 가능
        if (instance == null) {
            instance = new Settings();
        }

        return instance;
    }
}

2) 특징

private 생성자

new를 통해 인스턴스 생성 불가

static getInstance() 메소드

클래스 메서드, 인스턴스를 생성하지 않고 메서드를 사용하기 위함

스레드 언세이프

멀티스레드 환경에서 2개 이상의 스레드가 if 문을 통과하는 경우 여러 인스턴스 생성

// 멀티스레드 환경에서 2개 이상의 스레드가 if 문을 통과하는 경우 여러 인스턴스 생성
if (instance == null) {
	instance = new Settings();
}

3) 장단점

장점

  • 없음

단점

  • 스레드 언세이프 - 멀티스레드 환경에서 여러개의 인스턴스 생성 가능

2. synchronized

1) 생성 방법

첫번째 생성 방법의 getInstance 메서드에 synchronized 키워드를 붙인 것입니다.

synchronized는 동기화 블록으로 지정된 코드 블록은 한번에 하나의 스레드만 접근이 가능합니다.

다만, 멀티 스레드 환경에서 동시에 여러 스레드가 접근하지 못하고 순차적으로 접근해야하기 때문에 느립니다.

public class Settings {
    // 1. 클래스 변수로 인스턴스 지정
    private static Settings instance;

    // 2. private 생성자 - new로 인스턴스 생성불가
    private Settings() {}

    // 3. 클래스 메서드를 통해 인스턴스 생성
    public static synchronized Settings getInstance() {
        // 주의 - 여러개의 스레드가 동시에 if문을 통과하는 경우 여러개의 인스턴스 생성 가능
        if (instance == null) {
            instance = new Settings();
        }

        return instance;
    }
}

2) 특징

synchronized 를 통해 스레드가 순차적으로 접근, 느림

3) 장단점

장점

  • 스레드 세이프

단점

  • 느림, 여러개의 스레드가 순차적으로 접근해야함

4) 스레드 세이프 보장 방법

synchronized

3. 이른 초기화(eager initialization)

1) 생성 방법

클래스가 로드되는 시점에 인스턴스를 생성하는 방법. 원하는 시점에 인스턴스를 생성하지 못합니다.

public class Settings {
    // 1. 클래스가 로드되는 시점에 인스턴스를 생성
    private static final Settings INSTANCE = new Settings();

    // 2. private 생성자
    private Settings() {}

    // 3. 클래스 변수를 통한 인스턴스 획득
    public static Settings getInstance() {
        return INSTANCE;
    }
}

2) 특징

클래스 로딩 시점에 인스턴스를 미리 만들어 놓는다.

3) 장단점

장점

  • 스레드 세이프

단점

  • 인스턴스가 필요한 시점이 아닌, 클래스 로드 시점에 생성합니다.
  • 비용을 많이 소모하고 만들었는데 사용하지 않으면 비효율적이다.

4) 스레드 세이프 보장방법

클래스 로딩 및 초기화

참고 - 클래스 로딩

클래스 로딩으로 스레드 세이프를 보장하는 방법은 1. eager initialization, 2. LazyHolder, 3. eunm 에 사용됩니다.

멀티 스레드 환경에서 여러개의 스레드가 동시에 클래스를 로딩하려고 오직 한개의 클래스만 로딩됩니다.

다음 코드는 10개의 스레드가 동시에 클래스 로딩을 시도합니다.

다만, 클래스 로딩은 한번만 수행되고, 클래스가 로딩되었을 때 초기화를 수행합니다.

이 의미는 멀티 스레드 환경에서 클래스 로딩은 스레드 세이프함을 의미합니다.

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

class Main {
    public static void main(String args[]) {
        // 1. 스레드 풀 생성
        ExecutorService service = Executors.newCachedThreadPool();

        // 2. 반복문을 통해 - 10개의 스레드가 동시에 인스턴스 생성
        for (int i = 0; i < 10; i++) {
            service.submit(() -> {
                new Single();
            });
        }
        // 3. 종료
        service.shutdown();
    }
}
class Single {
    static {
        System.out.println("static 블록 호출");
    }

    public Single() {
        System.out.println("생성자 호출");
    }
}
javac Main.java
java -verbose:class Main

4. double checked locking

1) 생성 방법

synchronized 생성방식에 변화를 준 것으로 synchronized를 메서드 호출에 두지 않고, 인스턴스 검사에 두었습니다.

volatile 키워드는 변수를 메인 메모리에 저장하겠다는 의미로, 스레드에는 CPU 캐시가 있는데, read, write 할때 CPU 캐시를 사용하여 스레드마다 다른 값을 가지는 것이 아닌 메인 메모리의 값을 사용합니다.

public class Settings {
    // 1. 변수를 메인 메모리에 저장
    private static volatile Settings instance;

    // 2. private 생성자
    private Settings() {}

    // 3. getInstance 메서드 호출될때는 lock 걸리지 않음
    public static Settings getInstance() {
        if (instance == null) {
            synchronized (Settings.class) {
                if (instance == null) {
                    instance = new Settings();
                }
            }
        }
        return instance;
    }
}

2) 특징

메서드 호출할때는 락을 걸지 않기 때문에 synchronized 방법보다 조금 빠름

3) 장단점

  • 장점

    • getInstance라는 메서드를 호출할때 마다 매번 synchronized가 걸리지않는다.
    • 인스턴스가 필요한 시점에 만들 수 있다.
    • 스레드 세이프
  • 단점

    • JDK 1.5부터 동작한다.

4) 스레드 세이프 보장방법

synchronized

5. Lazy Holder

클래스를 로딩하고 초기화하는 과정이 스레드 세이프함을 이용하는 방법입니다.

내부의 클래스는 static 임에도 불구하고 LazyLoading 지연로딩이라고 부릅니다. 자세한 내용은 5) 왜 LazyLoading인가에서 알아봅시다.

1) 생성 방법

public class Settings {
    // 1. private 생성자
    private Settings() {}

    // 2. static inner 클래스
    private static class SettingsHolder {
        private static final Settings INSTANCE = new Settings();
    }

    // 3. static 메서드
    public static Settings getInstance() {
        return SettingsHolder.INSTANCE;
    }
}

2) 특징

원하는 시점에 인스턴스 생성

3) 장단점

  • 장점
    • 스레드 세이프
    • 원하는 시점에 인스턴스 생성 (getInstance 메소드 호출 시점)
    • 자바의 모든 버전에서 사용 가능

4) 스레드 세이프 보장 방법

클래스 로딩 및 초기화

5) 왜 LazyLoading인가

왜 LazyHolder인지 몰라서, stackoverflow에 여쭤봤는데 잘 알려주신다.
질문
위키 피디아 - Initialization-on-demand holder
오라클 - 초기화

정리하면 다음과 같습니다. 자세한 과정 및 코드

0. 클래스 로딩 의미

클래스로더가 .class 파일을 찾고 메모리에 올려놓는 것을 의미합니다.

JVM은 모든 클래스를 메모리에 로딩하지 않습니다. 필요한 클래스가 있을때 메모리에 올려놓습니다.

1. 클래스 로딩 시점

  • 클래스의 인스턴스 생성 (생성자 호출)
  • 클래스의 정적 변수 사용 (단, 정적 변수는 final로 선언된 상수 x)
  • 클래스의 정적 메소드 호출

2. 외부, 내부 클래스 로딩

외부 클래스 로딩 - 내부 클래스 로딩 X
내부 클래스 로딩 - 외부 클래스 로딩 X

3. 초기화 의미

  • static 블록 수행
  • static 변수 메모리 할당

-> static 클래스, static 메서드와는 관계 없음

4. 초기화 시점

클래스가 로딩될때 초기화도 수행

5. 스레드 세이프

클래스 로딩 및 초기화 여러 스레드가 동시에 시도해도, 오직 한번만 수행 - 즉, 스레드세이프함

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

class Main {
    public static void main(String args[]) {
        // 1. 스레드 풀 생성
        ExecutorService service = Executors.newCachedThreadPool();

        // 2. 반복문을 통해 - 10개의 스레드가 동시에 인스턴스 생성
        for (int i = 0; i < 10; i++) {
            service.submit(() -> {
                new Single();
            });
        }
        // 3. 종료
        service.shutdown();
    }
}
class Single {
    static {
        System.out.println("static 블록 호출");
    }

    public Single() {
        System.out.println("생성자 호출");
    }
}

10개의 스레드가 동시에 클래스를 로딩해도 1개의 클래스만 로딩되고, 초기화는 딱 한번만 이루어집니다.

6. LazyHolder 정리

외부 클래스가 로딩되었을때 인스턴스를 생성하지 않고

getInstance 정적 메소드가 호출될때 내부의 클래스를 로딩하고 초기화하는 과정에서 인스턴스를 생성합니다.

클래스를 로딩하고 초기화하는 과정은 여러 스레드가 동시에 시도해도 오직 한번만 수행되기 때문에 스레드세이프합니다.

이를 통해 인스턴스가 오직 한개만 생성됨을 보장합니다.

6. enum 🎖️🎖️🎖️

enum은 클래스가 로드되는 시점에 딱 한번 생성되고 전역에서 접근할 수 있습니다.

enum은 존재 자체로 싱글턴의 2가지 목적을 달성합니다.

  • 오직 하나의 인스턴스만 생성
  • 인스턴스 글로벌 접근

또한 임으로 인스턴스를 생성할 수 없기 때문에 스레드 세이프 또한 만족합니다.

(참고로, enum의 생성자는 기본적으로 private이며, public으로 변경하면 컴파일 에러가 발생합니다.)

1) 생성 방법

public enum Settings {

    INSTANCE;
}

2) 특징

enum은 리플렉션에서 접근할 수 없도록 막혀있다.

enum 기본적으로 serializable을 구현하고 있다.

3) 장단점

장점

  • 스레드 세이프
  • 리플렉션로 인한 인스턴스 생성 방지
  • 직렬화, 역질렬화로 인한 인스언스 생성 방지

단점

  • 상속 불가(enum만 상속 가능, 인터페이스 구현은 가능)
  • 클래스 로드하는 순간 미리 만들어준다.

4) 스레드 세이프 보장 방법

클래스 로딩 및 초기화

5) 참고

10개의 스레드가 동시에 클래스 로딩을 시도해도, 오직 한개의 클래스만 로딩됩니다. 이를 통해 스레드 세이프를 보장받으며

인스턴스는 한개만 생성됩니다.

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

class Main {
    public static void main(String args[]) {
        // 1. 스레드 풀 생성
        ExecutorService service = Executors.newCachedThreadPool();

        // 2. 반복문을 통해 - 10개의 스레드가 동시에 인스턴스 생성
        for (int i = 0; i < 10; i++) {
            service.submit(() -> {
                System.out.println(SingleTon.INSTANCE);
            });
        }
        // 3. 종료
        service.shutdown();
    }
}

enum SingleTon {
    INSTANCE;
}

🔥 지금까지의 방법을 깨뜨려보자

1) 리플렉션

리플렉션은 클래스 타입을 알지 못해도 해당 클래스의 생성자, 메소드, 필드에 접근 및 사용 할 수 있는 Java API 입니다.

리플렉션을 사용해서 클래스의 생성자를 가져오고, 생성자 접근 수준을 public으로 변경 new Settings 를 실행한 것과 유사한 결과를 얻습니다.

공격 방법

import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;

class Main {
    public static void main(String args[]) throws NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException {
        // 1. 싱글톤 클래스의 인스턴스 생성
        Settings settings = Settings.getInstance();

        // 2. 리플랙션
        // 1) settings 클래스의 생성자 가져옴
        Constructor<Settings> constructor = Settings.class.getDeclaredConstructor();
        // 2) 생성자 접근 수준을 public으로 변경
        constructor.setAccessible(true);
        // 3) 생성자를 통한 인스턴스 생성 new Settgings()와 유사
        Settings settings1 = constructor.newInstance();

        // 3. 주소값 비교 - false
        System.out.println(settings == settings1);
    }
}
// static inner 
public class Settings {
    // 1. private 생성자
    private Settings() {}

    // 2. static inner 클래스
    private static class SettingsHolder {
        private static final Settings INSTANCE = new Settings();
    }

    // 3. static 메서드
    public static Settings getInstance() {
        return SettingsHolder.INSTANCE;
    }
}

막는 방법

private 생성자가 실행되려고 할때 예외를 던집니다.

public class Settings {
    // 1. private 생성자
    private Settings() {
        throw new RuntimeException("싱글턴 인스턴스를 리플렉션으로 생성할 수 없습니다.");
    }

    // 2. static inner 클래스
    private static class SettingsHolder {
        private static final Settings INSTANCE = new Settings();
    }

    // 3. static 메서드
    public static Settings getInstance() {
        return SettingsHolder.INSTANCE;
    }
}

또는 enum을 사용합니다.

리플렉션으로 새로운 인스턴스가 생성되는 이유는 생성자에 접근할 수 있기 때문인데, enum은 리플렉션으로 생성자에 접근 할 수 없습니다.

2) 직렬화, 역질렬화

  • 직렬화 - 객체를 파일로 작성
  • 역직렬화 - 파일을 객체로 변환

역직렬화할때 반드시 생성자를 통해 객체를 생성합니다. 이를 통해 새로운 인스턴스가 생성됩니다.

공격 방법

import java.io.*;

class Main {
    public static void main(String args[]) throws IOException, ClassNotFoundException {
        // 1. 싱글톤 클래스의 인스턴스 생성
        Settings settings = Settings.getInstance();
        Settings settings1 = null;

        // 2. 직렬화 - 객체를 파일로 작성 - 파일명: settings.obj
        try (ObjectOutput out = new ObjectOutputStream(new FileOutputStream("settings.obj"))) {
            out.writeObject(settings);
        }

        // 3. 역직렬화 - 파일을 객체로 변환
        // 역질렬화할때는 반드시 생성자를 사용해서 인스턴스 생성
        try (ObjectInput in = new ObjectInputStream(new FileInputStream("settings.obj"))) {
            settings1 = (Settings) in.readObject();
        }

        System.out.println(settings == settings1);
    }
}
import java.io.Serializable;

public class Settings implements Serializable {
    // 1. private 생성자
    private Settings() {}

    // 2. static inner 클래스
    private static class SettingsHolder {
        private static final Settings INSTANCE = new Settings();
    }

    // 3. static 메서드
    public static Settings getInstance() {
        return SettingsHolder.INSTANCE;
    }
}

막는 방법

역직렬화할때 readResolve가 수행됨을 이용하여 인스턴스를 반환하면 직렬화, 역직렬화로 인스턴스가 여러개 생기는 현상은 막을 수 있습니다.

import java.io.Serializable;

public class Settings implements Serializable {
    // 1. private 생성자
    private Settings() {}

    // 2. static inner 클래스
    private static class SettingsHolder {
        private static final Settings INSTANCE = new Settings();
    }

    // 3. static 메서드
    public static Settings getInstance() {
        return SettingsHolder.INSTANCE;
    }

    // 역직렬화할때 반드시 readResolve 메서드 수행
    protected Object readResolve() {
        // 인스턴스 반환하도록 작업
        return getInstance();
    }
}

정리

1) 권장하는 싱글톤 생성방법

1 enum
2 Lazy Holder, static inner

2) 싱글톤의 장단점

장점

  • 전역 - 인스턴스를 한개만 만들고 전역에서 접근 할 수 있습니다.

단점

  • 메모리 비효율 - 애플리케이션이 종료될때 까지 메모리를 점유하기 때문에 남용하면 사용가능한 메모리가 줄어듭니다.
  • 상속 불가 - 싱글톤 클래스는 상속받을 수 없습니다. 자식 클래스는 상속받을 때 부모클래스의 생성자를 먼저호출하는데 private 생성자는 호출 할 수 없습니다.
    싱글톤 클래스는 상속을 통한 확장이 불가능합니다.

참고

백기선 - 코딩으로 학습하는 GoF의 디자인 패턴
이펙티브 자바 - 아이템 3 private 생성자나 열거 타입으로 싱글턴임을 보증하라
http://literatejava.com/jvm/fastest-threadsafe-singleton-jvm/

profile
callmeskye

0개의 댓글