05 - 01 멀티스레드 환경에서 싱글톤 패턴 사용하기

Mando·2023년 3월 22일
0

디자인 패턴

목록 보기
5/6

디자인 패턴을 읽다보면 싱글턴 패턴이 멀티스레드 환경에서는 하나의 객체만을 생성하지 않을 수도 있다고 했다.

이를 코드를 통해 알아보자

일반적인 싱글턴 패턴

public class Singleton {
    private static Singleton singleton;

    private Singleton() {
    }

    public static Singleton getInstance(){
        if(singleton==null){
            try{
                Thread.sleep(2000);
            }catch (InterruptedException e){
                e.printStackTrace();
            }
            singleton=new Singleton();
        }
        return singleton;
    }
}

t1 스레드는 람다를 이용해서 Thread 클래스의 run메서드를 구현해주었고
t2 스레드는 Thread 클래스를 상속해서 만든 클래스에 run메서드를 구현해주었고
해당 스레드 인스턴스를 통해 스레드를 실행시켰다.

public class Test {
    public static void main(String[] args) {
        Thread t1 = new Thread(()->{
            Singleton instance1 = Singleton.getInstance();
            System.out.println(instance1);
        });

        t1.start();

        MyThread myThread = new MyThread();
        myThread.start();
    }

    static class MyThread extends Thread{
        @Override
        public void run() {
            Singleton instance = Singleton.getInstance();
            System.out.println(instance);
        }
    }
}
Singleton@786c3879
Singleton@3054293a

그렇다.
우리는 싱글턴 패턴을 통해서 인스턴스가 단 1개만 생성되는 것을 기대했는데
멀티스레드(스레드가 총 2개가 있는 환경)에서 객체가 2개가 생성된 것을 알 수 있다.

멀티스레드 환경에서는 왜 인스턴스가 단 1개만 생성되지 않을 수 있는 것일까?

우리는 총 2개의 스레드를 생성하였다.

A스레드가 getInstance() 메서드를 통해 인스턴스를 요청햇다.
이 시점에서 singleton에 인스턴스가 존재하지 않았기에 인스턴스를 생성하는 부분으로 넘어간다. 그 전에 스레드는 sleep 구간에 들어간다.

A 스레드가 sleep 구간에 들어간 동안
B 스레드가 getInstance() 메서드를 통해 인스턴스를 요청했다.
아직 A 스레드가 sleep 구간에 존재하기에 인스턴스 생성이 안 된 상태이다.
따라서 이 시점에도 singleton 정적 변수에 인스턴스가 존재하지 않기에 인스턴스를 생성하는 부분으로 넘어간다.

동시성 문제 때문에 인스턴스가 총 2개가 생성되는 것이다.

해결방법

멀티 스레드 환경에서 싱글턴 패턴 사용시 발생할 수 있는 동시성 문제를 해결하는 방법은 여러 가지가 있다.
하지만 모든 방법에는 trade - off 가 발생한다.
그렇기 때문에 각 방법의 장단점을 파악하고 알맞은 해결방법을 적용해야 한다.

1. synchronized 메서드 선언

synchronized 키워드를 통해 getInstance()를 동기화했다.

최초로 해당 메서드를 호출한 스레드가 종료할 때까지 다른 스레드가 접근하지 못하도록 lock을 건다.

public class Singleton {
    private static Singleton singleton;

    private Singleton() {
    }

    public static synchronized Singleton getInstance(){
        if(singleton==null){
            try{
                Thread.sleep(2000);
            }catch (InterruptedException e){
                e.printStackTrace();
            }
            singleton=new Singleton();
        }
        return singleton;
    }
}
Singleton@2402d0a6
Singleton@2402d0a6

장점

여러 개의 스레드가 동시 접근하면서 생길 수 있는 문제를 해결할 수 있다.

단점

getInstance를 호출할 떄마다 lock이 걸려 성능 저하가 발생한다.

여러 개의 instance가 생성되는 것을 방지하고자 동기화를 했는데
instance가 생생된 후에는 어차피 이미 생성된 인스턴스를 반환해주기 떄문에 lock을 걸 필요가 없다.

따라서 역할 수행은 잘 하지만 성능 저하가 발생한다는 문제점이 있다.

synchronized 키워드
멀티 스레드 환경에서 두 개이상의 쓰레드가 하나의 변수에 동시에 접근할 때 Race Condition(경쟁 상태)가 일어나지 않도록 한다.
즉, 쓰레드가 해당 메서드를 실행하는 동안 다른 쓰레드가 접근하지 못하도록 잠금(lock)을 거는 것이다.

위처럼 thread-1이 메서드에 접근하는 순간 다른 thread - 2~4의 접근을 제한하고, thread-1이 완료되면 다른 쓰레드를 접근시킨다.

2. DCL(Double Checked Locking) 방식

synchronized 메서드를 선언해서 동기화하는 방식은 동기화가 필요하지 않은 상황 (즉, 생성되어 있는 객체를 가져가는 경우)에도 lock이 걸리는 문제점이 있다.

이 방법은 최초 인스턴스 생성이 필요할 때는 lock을 이용해 인스턴스를 생성한 뒤 할당하고, 이후 해당 인스턴스 접근 시 불필요하게 lock을 걸지 않고 사용할 수 있는 방법이다.

public class Singleton {
    private static Singleton singleton = new Singleton();

    private Singleton() {
    }

    public static Singleton getInstance(){
        if(singleton==null){
        	//메서드 내의 특정 로직에 대해서만 동기화를 진행한다.
            synchronized (Singleton.class){
                if(singleton==null){
                    singleton=new Singleton();
                }
            }
        }
        return singleton;
    }
}

하지만 이 방식도 문제점이 있다.

멀티스레드 어플리케이션의 non-volatile 변수에 대한 작업은 성능상의 이슈로 CPU 캐시를 사용한다.

  • non-volatile 변수를 사용하고 있는 멀티스레드 어플리케이션에서는 task를 수행하는 동안 메인 메모리로부터 읽은 값을 각 스레드의 CPU 캐시로 저장한다.
  • 따라서 멀티스레드 환경에서 스레드가 변수 값을 읽어올 때 각각의 CPU 캐시에 저장된 값이 다르기 때문에 변수 값 불일치 문제가 발생한다.

    votile 키워드
    java에서는 쓰레드를 여러 개 사용할 경우, 성능을 위해서 각각의 쓰레드들은 변수를 메인 메모리에서 가져오는 것이 아니라 캐시 메모리에서 가져온다.
    따라서 각 쓰레드마다 할당되어 있는 캐시 메모리 변수값이 일치하지 않을 수 있다는 문제점이 있다.

    이런 경우에 voltile 키워드를 사용해서 캐시에서 read/write를 하지 않고 바로 main memeory에서 read/write를 하도록 할 수 있다.

예를 들어 두 개의 스레드가 공유 객체에 접근하는 경우를 생각해보자

스레드1이 counter 변수의 값을 증가시켰다.
하지만 counter 변수에 volatile 키워드가 없기 때문에 counter 변수의 값이 언제 CPU 캐시에서 메인 메모리로 written(쓰일지) 알 수 없다.

따라서 CPU 캐시의 counter 변수와 메인 메모리의 counter 변수가 다른 값을 가질 수 있다.

public class SharedObject {

    public int counter = 0;

}


이처럼 스레드가 변경한 값이 메인 메모리에 written 되지 않아서 다른 스레드가 이 값을 볼 수 없는 상황을 가시성 문제라고 한다.

한 스레드의 변경이 다른 스레드에 보이지 않는 것이다.

3. DCL 방식에 volatile 키워드 사용

2번의 방식은 volatile 키워드를 추가함으로써 해결할 수 있다.

volatile 키워드를 사용하면 이 변수에 대한 쓰기 작업은 즉각 메인 메모리로 이뤄질 것이고, 읽기 작업 또한 메인 메모리로부터 이루어진다.

public class Singleton {
    private volatile static Singleton singleton = new Singleton();

    private Singleton() {
    }

    public static Singleton getInstance(){
        if(singleton==null){
        	//메서드 동기화가 아닌 Singleton 클래스 자체를 동기화 건다.
            synchronized (Singleton.class){
                if(singleton==null){
                    singleton=new Singleton();
                }
            }
        }
        return singleton;
    }
}
Singleton@7b541e3d
Singleton@7b541e3d

volatile 키워드를 사용하면 thread-safe 한데 권장하지 않은 이유?

  1. volatile 키워드를 사용하기 위해서는 JVM 1.5이상이여야하고, JVM에 대한 심층적인 이해가 필요하다
  2. JVM에 따라서 여전히 thread-safe 하지 않은 경우도 발생한다.

volatile 키워드를 사용하지 않는 DCL 방식에서는 아래와 같은 문제가 발생할 수도 있다.

  • 스레드 A가 instance를 생성하고 synchronized를 벗어남
  • 스레드 B가 synchronized 블록에 들어와서 null 체크를 하는 시점에서
  1. 스레드 A가 생성한 인스턴스가 working memory에만 존재하고 main memory에는 존재하지 않는 경우

  2. main memory에는 존재하지만 스레드B의 working memory에 존재하지 않을 경우

    즉, 메모리 간의 동기화가 이루어지지 않았더라면

  • 스레드B는 인스턴스를 또 생성하게 된다.

volatile 키워드를 사용한 경우

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

질문!!! 아직 해결하지 못함

메인 메모리와 working memory간에 동기화가 진행되는 동안에 또 다른 스레드가 값을 읽으면 이 경우도 데이터 일관성이 맞지 않는데?

예를 들어 스레드 A가 count 값을 1 증가했다. 그럼 이는 먼저 CPU 캐시에 저장되고 volatile를 사용했기 때문에 바로 main memory에 값이 반영된다.

하지만 값이 반영되기 전 스레드 B가 값을 읽으려고 하면?

그럼 volatile같은 경우는 synchronized가 CPU 캐시에 저장될 떄까지만 아니라 main memory에 flush 되는 것까지가 임무이니 이것까지 해결하고 synchronized 블록을 벗어나는 것인가?
(이 부분을 코드로 확인해보는 방법을 알아봐야겠다.)

해결

volatile 키워드에 대한 잘못된 이해로 오해가 있었다.(추후 정리 예정)
volatile 키워드아 붙은 변수는 메인 메모리로부터 읽히고, 변수에 대한 쓰기 작업 역시 메인 메모리로부터 직접 이루어지기 때문에 CPU 캐시가 쓰이지 않는다.

따라서 값 동기화가 무조건 이루어진다.

그러면 무조건 반영이 되고 다른 스레드가 접근할 수 있으니깐 그건 데이터 일관성이 있을 거 같다.

4. 전역 변수로 선언

런타임 시가 아니라 최초 JVM이 모든 클래스를 로드할 때 미리 인스턴스를 생성해두는 방식

public class Singleton {
    private static Singleton singleton = new Singleton();

    private Singleton() {
    }

    public static Singleton getInstance(){
        return singleton;
    }
}
Singleton@19a85a61
Singleton@19a85a61

장점

클래스를 로드 하면서 인스턴스가 생성되기 떄문에 동시성을 제어할 목적으로 사용되는 키워드 synchronized, volatile 키워드를 사용할 필요가 없다.

단점

전역 변수로 인스턴스를 생성하는 방식을 사용하면 클래스가 로딩될 때 만들어지기 때문에 필요하지 않은 시점에도 메모리 자원을 선점하고 있다는 문제점이 있다.

5. 👍️LazyHodler 방식👍️

Class Loader 에 trick을 줘서 static 초기화 방식의 문제점을 보안하는 방식

public class Singleton {
    private static Singleton singleton = new Singleton();

    private Singleton() {
    }

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

    private static class LazyHolder {
        private static final Singleton INSTANCE = new Singleton();
    }
}
Singleton@16b8a6a4
Singleton@16b8a6a4

장점

1. Eager Initialization이 아닌 Lazy Initialization

JVM은 클래스를 로딩할 때 static inner class는 바로 생성하지 않는다.
static inner class 같은 경우는 getInstance() 메서드가 호출되었을 때 호출된다.
즉, Lazy Initialization 방식이다.

2. JVM이 thread - safe 보장

다른 쓰레드가 getInstance()메서드를 호출할 때 static final로 선언된 INSTANCE가 이미 JVM에 올라와있기 떄문에 싱글톤 객체를 하나만 생성할 것이라고 JVM이 보장해준다.

6. Enum Singleton

Enum은 런타임이 아닌 컴파일 타임에 모든 값을 알고 있어야 한다.
따라서 Enum SingleTon 의 장점 - 직렬화를 자체적으로 처리

Enum은 고정된 상수들의 집합이다. 따라서 런타임이 아닌 컴파일 타임에 모든 값을 알고 있어야 한다. 그래서 Enum 클래스 내에서 인스턴스 생성은 불가능하며 생성자의 접근 제한자를 private으로 설정해 인스턴스 생성을 제어한다. Enum Singleton의 장점은 직렬화를 자체적으로 처리한다는 점이다. 기존 싱글톤 클래스의 경우 readObject() 메서드가 Java 생성자처럼 항상 새로운 인스턴스를 리턴하기 때문에 직렬화 인터페이스를 구현하면 싱글톤 클래스가 더 이상 싱글톤이 아니게 된다. 직렬화된 싱글톤 객체의 문제를 해결하기 위해서는 그 과정이 조금 복잡하다.

public enum EnumSingleton {
    INSTANCE;

    // other methods...
}

정리

  1. 👍️ Lazy Holder 방식 👍️ : 직렬화 가능성이 없을 때
  2. 👍️ enum 방식 👍️ : 직렬화 가능성이 있을 때

위 두 방식 모두 JVM이 동기화를 수행하기 때문에 thread - safe하고, Lazy Initialization하다.

0개의 댓글