해당 포스팅에 사용된 예제는 이 블로그 를 참고했습니다 

개요

등장 배경

  • 객체 지향 디자인 패턴의 기본 원칙(OCP; Open Closed Principle)
    • “확장에 있어서는 열려 있어야 하며, 수정에 있어서는 닫혀있어야 한다”
    • 코드를 수정하지 않아도 모듈의 기능을 확장하거나 변경할 수 있어야 함
    수정이 일어날 가능성이 큰 부분과 그렇지 않은 부분을 분리하는 것이 좋음
  • 객체는 속성과 함수가 변경, 추가될 가능성이 높음 → 객체의 생성을 담당하는 코드는 변경될 가능성이 높음
  • 객체의 생성을 담당하는 클래스를 한 곳에서 관리하여 클라이언트 코드와의 결합도를 줄이기 위해 사용
    • 결합도(의존도)란 한 클래스의 변경 사항이 다른 클래스에 얼마나 영향을 주는가를 의미
    • 클라이언트 코드란 분리시킨 객체 생성 코드를 호출하는 쪽을 말함

 

정의 및 특징

  • 객체를 생성하는 작업을 (팩토리) 클래스에 따로 모아두는 것을 의미
  • 상속 관계에서 부모 클래스가 중요한 뼈대를 결정하고, 자식 클래스가 객체 생성에 관한 구체적인 내용을 결정함
  • 상위 클래스와 하위 클래스가 분리되기 때문에 느슨한 결합을 가짐
  • 크게 2가지 종류를 가짐
    • 팩토리 메소드 패턴: 객체를 생성하기 위한 인터페이스를 정의할 때, 어떤 클래스의 인스턴스를 만들지 서브 클래스에서 결정
    • 추상 팩토리 패턴: 서로 연관된, 또는 의존하는 객체를 구상 클래스를 지정하지 않고도 인터페이스를 이용하여 생성할 수 있음
      (ex: 핸드폰=기종+OS+…)
    • 추상 팩토리 패턴에 팩토리 메소드 패턴이 포함될 수 있음

 

장점

  • 서로 간의 결합도를 낮추고 확장을 쉽게 함 → 유지보수성 향상
  • 상위 클래스에서는 인스턴스 생성 방식에 대해 전혀 알 필요가 없기 때문에 더 많은 유연성을 가짐
  • 사용자가 사용할 때는 정의된 인터페이스에 정의된 추상 메소드만 사용하면 됨
  • 단일 책임 원칙을 따름. 프로그램의 코드에서 생성자 코드를 분리함으로써 코드를 더욱 간결하게 만들 수 있음
  • 개방 폐쇄 원칙을 따름. 기존 client의 코드를 파괴하지 않고 새로운 타입을 추가할 수 있음

 

활용

  • 어떤 클래스가 자신이 생성해야 하는 객체의 클래스를 예측할 수 없는 경우
  • 생성할 객체를 기술하는 책임을 자신의 서브 클래스가 지정했으면 할 경우
  • java.util 패키지에 있는 Calendar, ResourceBundle, NumberFormat 등의 클래스에 정의된 getInstance() 메소드에서 팩토리 패턴을 사용
  • Boolean, Integer, Long 등 Wrapper 클래스에 정의된 valueOf() 메소드에서 팩토리 패턴을 사용

 

단점

  • 패턴을 구현할 많은 서브 클래스를 도입함으로써 코드가 복잡해질 수 있음

 

종합

  • 팩토리 패턴에 사용되는 슈퍼 클래스는 인터페이스나 추상 클래스, 혹은 그냥 평범한 자바 클래스여도 상관 없음
  • 팩토리 클래스를 Singleton으로 구현해도 되고, 서브 클래스를 리턴하는 static 메소드로 구현해도 됨
  • 팩토리 메소드는 입력된 파라미터에 따라 다른 서브 클래스의 인스턴스를 생성하고 리턴함

팩토리 메소드 패턴

정의 및 특징

  • 객체를 생성하기 위한 인터페이스는 미리 정의하되, 어떤 클래스의 인스턴스를 만들지는 서브 클래스에서 결정하도록 함
  • 즉, 클래스의 인스턴스를 만드는 일을 서브클래스에게 맡기는 것
  • 여러 개의 서브 클래스를 가진 슈퍼 클래스가 있을 때, 인풋에 따라 하나의 자식 클래스의 인스턴스를 리턴해주는 방식

 

예제 코드

1. 프로토콜 생성

protocol Shape {
    func draw()
}

2. 클래스 생성

class Rectangle : Shape {
    func draw() {
        print("Inside Rectangle::draw() method")
    }
}
class Square : Shape {
    func draw() {
        print("Inside Square::draw() method")
    }
}
class Circle : Shape {
    func draw() {
        print("Inside Circle::draw() method")
    }
}

3. 팩토리 생성

class ShapeFactory {
    public func getShape(shapeType : String) -> Shape? {
        if shapeType == nil {
            return nil
        }
        if shapeType == "CIRCLE" {
            return Circle()
        }
        else if shapeType == "RECTANGLE" {
            return Rectangle()
        }
        else if shapeType == "SQUARE"{
            return Square()
        }
        return nil
    }
}

4. 데모

let shapeFactory = ShapeFactory()

let shape1 = shapeFactory.getShape(shapeType: "CIRCLE")
shape1?.draw()

let shape2 = shapeFactory.getShape(shapeType: "RECTANGLE")
shape2?.draw()

let shape3 = shapeFactory.getShape(shapeType: "SQUARE")
shape3?.draw()

5. 출력 결과

Inside Circle::draw() method
Inside Rectangle::draw() method
Inside Square::draw() method

추상 팩토리 패턴

정의 및 특징

  • 팩토리 메소드 패턴과 유사하나, 팩토리를 만드는 상위 팩토리(super-factory) 클래스가 존재
  • 연관된 서브 클래스를 그룹화할 수 있고 그 그룹을 자유롭게 교체할 수 있는 패턴
  • 인터페이스를 이용하여 구체적인 클래스에 의존하지 않고 서로 연관되거나 의존적인 객체들의 조합을 생성함

 

예제 코드

1. 클래스 생성

class RoundedRectangle : Shape {
    func draw() {
        print("Inside RoundedRectangle::draw() method")
    }
}
class RoundedSquare : Shape {
    func draw() {
        print("Inside RoundedSquare::draw() method")
    }
}

2. 슈퍼 팩토리 생성

protocol AbstractFactory {
    func getShape(shapeType : String) -> Shape?
}

3. 하위 팩토리 생성

class ShapeFactory : AbstractFactory {
    public func getShape(shapeType : String) -> Shape? {
        if shapeType == "RECTANGLE" {
            return Rectangle()
        } else if shapeType == "SQUARE"{
            return Square()
        }
        return nil
    }
}
class RoundedShapeFactory : AbstractFactory {
    public func getShape(shapeType : String) -> Shape? {
        if shapeType == "RECTANGLE" {
            return RoundedRectangle()
        } else if shapeType == "SQUARE"{
            return RoundedSquare()
        }
        return nil
    }
}

4. 팩토리를 생성하는 Producer 생성

class FactoryProducer {
    static func getFactory(rounded : Bool) -> AbstractFactory {
        if rounded {
            return RoundedShapeFactory()
        }else {
            return ShapeFactory()
        }
    }
}

5. 데모

let shapeFactory1 = FactoryProducer.getFactory(rounded: false)

let shape1 = shapeFactory1.getShape(shapeType: "RECTANGLE")
shape1?.draw()

let shape2 = shapeFactory1.getShape(shapeType: "SQUARE")
shape2?.draw()


let shapeFactory2 = FactoryProducer.getFactory(rounded: true)

let shape3 = shapeFactory2.getShape(shapeType: "RECTANGLE")
shape3?.draw()

let shape4 = shapeFactory2.getShape(shapeType: "SQUARE")
shape4?.draw()

6. 출력 결과

Inside Rectangle::draw() method
Inside Square::draw() method
Inside RoundedRectangle::draw() method
Inside RoundedSquare::draw() method

 


References

접근

우선 예제를 보고 문제에서 구하고자 하는 바이토닉 수열은 연속되지 않아도 된다는 점을 알았다. 바이토닉 수열은 계속해서 증가하다가 어느 시점을 기점으로 계속해서 감소하는 수열인데, 그러면 배열 하나로 구하기보다는 증가하는 수열의 길이를 담는 배열과 감소하는 수열의 길이를 담는 배열 2개를 계산해서 그 합을 비교하면 될 것 같다. 감소하는 수열은 결국 증가하는 수열을 반대 방향에서 계산한 것과 동일하므로, 부호만 수정해주면 된다. 즉 11053번의 응용 문제이다.

 

그치만 여기까지 생각해놓고 LIS 알고리즘이 생각 안나는 나는,,, 처음에는 스택 두개로 어떻게 해보려고 삽질하다가 이게 맞나 싶어서 결국 솔루션을 찾아봤다. 해보지는 않았는데 스택 방법도 논리적으로는 문제가 없다고 생각한다.. 아마도? 간략하게만 적어보면 스택 2개를 두고, 하나에는 (현재) 가장 긴 증가하는 수열을 저장 / 나머지 하나는 (앞으로) 가장 길 수도 있는 수열을 저장하는 것이었다. 그래서 만약 {7, 8, 9, 1, 2, 3, 4, 5} 가 있다면 뒤쪽의 5숫자가 가장 길기 때문에, 작은 수가 나온다면 임시 후보로 스택에 저장해두다가 그 길이가 더 길어지면 스위치하는 방식을 생각해봤다. 

 

코드

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.Arrays;
import java.util.StringTokenizer;

public class Main {
    public static void main(String[] args) throws IOException {
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        int n = Integer.parseInt(br.readLine());
        int answer = 0;

        int[] arr = new int[n];
        int[] increase = new int[n];
        int[] decrease = new int[n];

        StringTokenizer st = new StringTokenizer(br.readLine());
        for(int i = 0;i < n;i++)
            arr[i] = Integer.parseInt(st.nextToken());

        // 최장 증가 수열(LIS) 구하기
        for(int i = 0;i < n;i++){
            increase[i] = 1;
            for(int j = 0;j < i;j++){
                if(arr[i] > arr[j])
                    increase[i] = Math.max(increase[i], increase[j] + 1);
            }
        }

        // 최장 감소 수열(LDS) 구하기
        for(int i = n-1;i >= 0;i--){
            decrease[i] = 1;
            for(int j = n-1;j >= i;j--){
                if(arr[i] > arr[j])
                    decrease[i] = Math.max(decrease[i], decrease[j]+1);
            }
        }

        for(int i = 0;i < n;i++)
            answer = Math.max(answer, increase[i] + decrease[i]);

        System.out.println(answer - 1); // 가운데 중복되는 숫자 제외
    }
}

'Algorithm' 카테고리의 다른 글

[백준] 2531: 회전 초밥 JAVA  (0) 2023.01.15
[백준] 20922: 겹치는 건 싫어 JAVA  (0) 2023.01.15
[백준] 12865: 평범한 배낭 JAVA  (0) 2023.01.09
[백준] 1932: 정수 삼각형 JAVA  (0) 2023.01.09
[백준] 1912: 연속합 JAVA  (0) 2023.01.08

접근

DP 알고리즘 다시 봐야지! 하면 항상 냅색 문제부터 먼저 풀었더니 결국 감이 없는 상태에서 솔루션 참고하고 내 힘으로 풀었던 적은 없는 것 같다. 그래서 이번에는 쉬운 dp 문제들로 먼저 빌드업을 하고왔다 ㅎ 처음에 dp가 어려웠던 이유는 자꾸 그리디처럼 접근해서 그랬던 것 같다. 그냥 이전의 결과를 활용해서 하나씩 새로운 값을 누적하여 계산하면 된다.

 

짐을 쌀 때 (1) 물건을 포함하는 경우 (2) 물건을 포함하지 않는 경우 2가지로 나뉘기 때문에, 특정 무게일 때 두 가지 경우 중 더 높은 가치를 가지는 값을 저장하면 된다. 즉 무게는 1부터 k의 최대값(100,000)까지 반복하며, 모든 물건별로 넣고 안넣고를 비교해준다. 이 때 dp[i-1][]을 참조해야 하므로 배열의 크기를 n+1로 잡아줬다.

 

 

코드

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.StringTokenizer;

public class Main {
    public static void main(String[] args) throws IOException {
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        StringTokenizer st = null;

        st = new StringTokenizer(br.readLine());
        int n = Integer.parseInt(st.nextToken());
        int k = Integer.parseInt(st.nextToken());

        int[] weight = new int[n+1];
        int[] value = new int[n+1];
        int[][] dp = new int[n+1][k+1];

        for(int i = 1;i <= n;i++){
            st = new StringTokenizer(br.readLine());
            weight[i] = Integer.parseInt(st.nextToken());
            value[i] = Integer.parseInt(st.nextToken());
        }

        for(int i = 1;i <= n;i++){
            for(int j = 1;j <= k;j++){
                if(weight[i] > j) // 해당 물건을 넣을 수 없음
                    dp[i][j] = dp[i-1][j]; 
                else // 물건을 넣지 않는 경우와 넣는 경우 비교
                    dp[i][j] = Math.max(dp[i-1][j], dp[i-1][j-weight[i]] + value[i]); 
            }
        }

        System.out.println(dp[n][k]);
    }
}

접근

n의 최대값이 500이기 때문에 그냥 n x n인 2차원 배열을 만들어도 됐겠지만, 전부 사용한다면 모를까 반은 비어있을 예정이기 때문에 메모리가 낭비되는게 거슬렸다. 그래서 코드는 조금 복잡해지더라도 n의 길이를 가지는 1차원 배열 2개를 만들어서 윗줄부터 하나씩 내려갈 때, 현재 계산하는 줄(cur)과 윗줄까지 계산한 결과(prev)를 담도록 했다. 이러면 낭비되는 메모리도 줄일 수 있을 뿐더러, 차지하는 공간이 n^2에서 n*2로 줄어들게 되기 때문에 n이 커질수록 더 이득일 것이라 생각했다. 

 

코드

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.StringTokenizer;

public class Main {
    public static void main(String[] args) throws IOException {
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        int n = Integer.parseInt(br.readLine());

        int[] prev = new int[n+1];
        int[] cur = new int[n+1];
        int answer = 0;

        for(int i = 0;i < n;i++) {
            StringTokenizer st = new StringTokenizer(br.readLine());
            for (int j = 0; j <= i; j++) {
                int val = Integer.parseInt(st.nextToken());

                if(j == 0) cur[j] = prev[j] + val;
                else cur[j] = Math.max(prev[j-1] + val, prev[j] + val);

                answer = Math.max(answer, cur[j]);
            }
            int[] tmp = prev;
            prev = cur;
            cur = tmp;
        }
        System.out.println(answer);
    }
}

접근

dp[i]에는 i번째 자리까지 고려했을 때 가장 컸던 연속합을 저장하도록 한다. 따라서 연속적으로 수를 더해 나가다가 그 합이 되려 작아지는 경우에는 값을 재설정해줬다. 질문 게시판은 반례를 올려주시는 분들이 많아서 애용하는 편인데, 1912번 문제에 해당하는 반례를 모아서 작성해준 분이 계셔서 링크를 남긴다.
https://www.acmicpc.net/board/view/44227 

 

코드

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.StringTokenizer;

public class Main {
    public static void main(String[] args) throws IOException {
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        int n = Integer.parseInt(br.readLine());
        StringTokenizer st = new StringTokenizer(br.readLine());

        int[] arr = new int[n];
        int[] dp = new int[n];

        for(int i = 0; i < n; i++)
            arr[i] = Integer.parseInt(st.nextToken());

        int sum = dp[0] = arr[0];

        for (int i = 1; i < n; i++) {
            sum = Math.max(sum + arr[i], arr[i]);
            dp[i] = Math.max(dp[i-1], sum);
        }

        System.out.println(dp[n-1]);
    }
}

접근

이번주 스터디에서는 알고리즘을 정한 후 문제를 골랐기 때문에 DP로 접근해야 한다는 사실을 알고 풀었지만, 모르고 문제를 푼다면 내 나름의 판단 근거는 실행 시간이다. 뭔가 복잡해보이는데 제한 시간이 1초 이내로 짧다면 DP일 가능성이 높다고 생각한다. 

 

각설하고 문제로 돌아가면, 사용되는 숫자가 1~3로 3가지밖에 없어서 10, 50, 100원으로 나누던 동전 문제와 유사하다는 느낌을 받았다. n의 최댓값이 10으로 매우 작으므로 숫자 2부터 하나씩 계산하면 될 것 같다. 사용 가능한 최대 숫자가 3이기 때문에 3번마다 규칙이 보이길 바라면서 개수를 세어봤다. 그런데 나는 합을 나타낼 때 수를 '1개' 이상 사용해야 한다는 조건을 제대로 읽지 못해서 시간을 많이 낭비했다..ㅎㅎ 무조건 합(+)이 들어가야 한다고 생각했는데 역시 문제를 잘 읽자.. 

 

n = 1) 1가지

1

n = 2) 2가지

2 / 1+1

n = 3) 4가지

3 / 2+1, 1+2

n = 4) 7가지

3+1, 1+3, 2+2 / 2+1+1, 1+2+1, 1+1+2 / 1+1+1+1

 

숫자 n에 따른 경우의 수를 저장하는 배열을 dp[]라고 할 때, 위 규칙을 보면 dp[i] = dp[i-1] + dp[i-2] + dp[i-3] 식을 유추해볼 수 있다. 이 방법대로 코드를 구현하자. 참고로 dp[5] = 13, dp[6] = 24... 으로 성립하는걸 알 수 있다. 이렇게 되는 이유는,

4 = 1 + 3 (3을 만들 수 있는 경우의 수)

   = 2 + 2 (2를 만들 수 있는 경우의 수)

   = 3 + 1 (1을 만들 수 있는 경우의 수)

가 되기 때문

 

코드

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;

public class Main {
    static int MAX_VALUE = 11;

    public static void main(String[] args) throws IOException {
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        StringBuffer sb = new StringBuffer();

        // n이 작으므로 매번 동일한 연산을 하지 않도록 미리 수행했음
        int[] dp = new int[MAX_VALUE];
        dp[1] = 1;
        dp[2] = 2;
        dp[3] = 4;

        for(int i = 4;i < MAX_VALUE;i++)
            dp[i] = dp[i-1] + dp[i-2] + dp[i-3];

        // 테스트케이스별 결과 출력
        int tc = Integer.parseInt(br.readLine());
        for(int t = 0; t < tc; t++){
            int n = Integer.parseInt(br.readLine());
            sb.append(dp[n]).append("\n");
        }

        System.out.println(sb);
    }
}

개요

정의

  • 애플리케이션이 시작될 때, 최초 한 번만 메모리를 할당하고(static) 그 메모리에 인스턴스를 만들어 사용하는 디자인 패턴
  • 즉, 객체의 인스턴스가 오직 ‘하나’만 생성됨
  • 인스턴스가 필요할 때 똑같은 것을 새로 만들지 않고 기존의 인스턴스를 활용하는 것
  • 생성자가 여러 번 호출되어도 실제로 생성되는 객체는 하나임
    • 자바에서는 생성자를 private로 선언해 다른 곳에서 생성하지 못하도록 방지

 

장점

  • 메모리 낭비 방지
    • 객체는 생성할 때마다 새로운 메모리 영역을 할당받아야 함
    • 한 번의 new를 통해 객체를 생성하기 때문에 고정된 메모리 영역을 사용
  • 이미 생성된 인스턴스를 활용하여 객체 로딩 시간이 줄어들며 속도 성능 향상
  • 전역으로 사용되기 때문에 다른 인스턴스들이 접근 가능하여 클래스 간 데이터 공유가 용이
  • 도메인 관점에서 인스턴스가 절대적으로 한 개만 존재하는 것을 보증하고 싶은 경우에 사용

 

활용

  • 주로 공통된 객체를 여러 개 생성해서 사용해야 하는 상황에 많이 사용됨
  • DB의 커넥션 풀, 스레드풀, 캐시, 로그 기록 객체 등
  • 안드로이드 앱에서도 각 Activity들이나 클래스마다 주요 클래스들이 하나하나 전달하는게 번거롭기 때문에 싱글톤 클래스를 만들어 어디서든 접근하도록 설계함

 

단점

  • 테스트가 어려움
    • 정적 필드는 한번 할당되면 프로그램이 종료되기 전까지 계속 살아있게 됨
    • 각 테스트는 독립적(다른 테스트에 영향을 미치지 않아야) 하는데, 한번 싱글톤 객체가 만들어지면 자원이 공유됨
      → 인스턴스의 상태를 매번 초기화해야 함
    • 일반적으로 인터페이스가 아닌 클래스를 통해 구현되는 싱글톤은 mock으로 대체할 수 없으므로 단위 테스트가 까다로워짐
    • 이를 해결하기 위해 주로 의존관계 주입(DI)를 사용하며, 이 경우 보통 DI 프레임워크가 싱글톤 객체의 생성을 제어함
  • 개방-폐쇄 원칙(OCP)” 위배 가능성
    • 객체 지향 설계 원칙(SOLID) 중 기존의 코드를 변경하지 않으면서, 기능을 추가할 수 있도록 설계되어야 한다는 원칙
    • 싱글톤 인스턴스를 여러곳에서 많이 공유할 경우 다른 클래스들 간의 의존도가 높아지면서 객체 간의 독립성을 지향하는 원칙에 어긋남 → 유지보수가 어려워짐
  • 멀티 스레드 동시성 문제
    • 동기화가 제대로 이루어지지 않으면 경쟁 상태(race condition)가 될 수 있음
    • 인스턴스가 2개가 생성될 수도 있는 것

⇒ 유연성이 많이 떨어지는 패턴이라고 할 수 있음

 

종합

  • 싱글톤 패턴은 안티 패턴으로 불릴 만큼 단독으로 사용한다면 객체 지향에 위반되는 사례가 많음
  • 애플리케이션의 한 곳에서만 사용될 모듈을 싱글톤으로 구현할 경우, 해당 인스턴스가 제대로 gc되지 않는다면 끝까지 메모리를 차지하고 있기 때문에 적절한 경우에 사용할 필요가 있음(남용x)
  • 스프링 컨테이너같은 프레임워크의 의존성 주입(DI)를 통해 모듈 간의 결합을 느슨하게 만들 수 있음

 


예제

아래 방법들은 모두 Lazy Initialization에 해당한다. 모두 처음부터 싱글톤 변수에 초기화를 시키지 않고, getInstance() 메소드를 통해 필요로 할 때(게으르게) 인스턴스를 생성하고 있다.

일반적인 방식

  • 단일 스레드에서는 전혀 문제가 없는 방식
public class Car {
	// 메모리에 static으로 최초에 한번만 생성
	private static Car instance;
	
	private Car() {}

	// 외부에서 멤버로 선언된 instance를 가져올 수 있는 메서드
	public static Car getInstance(){
		if(instance == null){
			instance = new Car();
		}
		return instance;
	}
}

/************************************/

public class Test {
	// 인스턴스 사용 요청
	Car car = Car.getInstance();
}

 

동시성 문제 해결 방식

1. 지연 초기화(Lazy Initialization) + synchronized

  • synchronized 동기화를 활용해 스레드를 안전하게 만듦
  • 하지만 synchronized는 큰 성능저하를 발생시키므로 권장하지 않는 방법
public class Car {
	private static Car instance;
	
	private Car(){}

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

 

2. 더블 체크 락킹(Double-checked Locking)

  • 조건문으로 인스턴스의 존재 여부를 확인한 이후 synchronized를 통해 동기화를 시켜 인스턴스를 생성
  • 스레드를 안전하게 만들면서, 처음 생성 이후에는 synchronized를 실행하지 않기 때문에 1번 방법보다 성능이 좋음
  • 프로그램 구조가 그만큼 복잡해지고 비용 문제가 발생
public class Car {
	private static volatile Car instance;

	private Car(){}

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

		return instance;
	}
}
  • volatile을 사용하는 이유
    • 컴파일러가 최적화라는 명목으로 연산의 순서를 변경(reordering)할 수 있기 때문
    • A 스레드에서 4번을 실행하여 객체를 위한 메모리 공간을 할당하고 참조를 instance에 저장한 후 싱글톤 객체의 생성자에서 내부 상태 초기화가 이루어지고 있다고 해보자. 이때B 스레드에서 1번을 실행할 경우 null이 아님을 확인하고 초기화 중인 객체 참조를 그대로 반환할 수도 있다. 따라서 외부에서 올바르게 초기화되지 않은 객체의 상태를 관찰할 수 있다는 것이다.
    • "객체의 초기화가 완전히 끝난 다음에 그 객체에 대한 참조가 instance에 저장되는 것이 아니라는 점"에 유의해야 한다. 실제로는 동기화 블록 내부에서 컴파일러로 인해 재배열이 일어나므로 다르게 동작할 수도 있다(단일 스레드에서는 개발자 입장에서 결과가 동일하다). volatile 키워드를 사용하면 필드에 값을 쓸 때 위와같은 재배열이 일어나지 않도록 컴파일러에게 지시하는 것이 가능하다. 
if (instance == null) { // (1)
	synchronized (Singleton.class) { // (2)
		if (instance == null) { // (3)
			instance = new Singleton(); // (4)
		} // (5)
	} // (6)
} // (7)
return instance; // (8)

 

3. 요청 시 초기화 홀더(Initialization-on-demand holder)

  • 내부에 Holder 클래스를 두어 JVM의 클래스 로더 매커니즘과 클래스가 로드되는 시점을 이용
  • JVM의 클래스 초기화 과정에서 보장되는 원자적 특성을 이용
  • final을 사용해서 다시 값이 할당되지 않도록 만듦
  • 실제로 가장 많이 사용되는 일반적인 방식이라고 함
public class Car {
	private Car() {}
 
	private static class Holder {
		public static final Car INSTANCE = new Car();
	}
 
	public static Car getInstance() {
		// Class가 로딩되며 초기화가 진행. 이 시점에서 thread-safe 보장
		return Holder.INSTANCE;
	}
}
  • Holder.INSTANCE 부분에서 thread-safe가 되는 이유 
    • 클래스나 인터페이스 타입 T는 아래 작업들이 처음 일어나기 직전에 초기화됨
      1. T가 클래스이고 T의 인스턴스가 생성될 때
      2. T에 선언된 정적 메서드가 호출될 때
      3. T에 선언된 정적 필드가 할당될 때
      4. T에 선언된 정적 필드가 사용될 때(상수 변수가 아닌 필드)
    • Holder 클래스에 선언된 정적 필드인 INSTANCE가 사용될 때 Holder 클래스의 초기화가 일어남
    • 위 예시에서는 런타임에 getInstance()를 호출하여 Holder.INSTANCE 를 사용하기 전에, 클래스로더를 통해 Holder 클래스의 초기화가 일어나게 됨
    • 그와 동시에 Holder 클래스의 초기화 단계에서 정적 필드 INSTANCE의 초기화는 단 한 번만 일어남

 

4. 열거형(Enum)

  • 열거형에 열거형 상수를 통해 정의된 인스턴스 이외의 인스턴스는 존재하지 않음
    (열거형을 명시적으로 인스턴스화하려고 시도하면 컴파일 에러가 발생)
  • 직렬화(Serialization)가 가능
  • Reflection API를 통해 인스턴스를 만드려는 시도를 무력화 가능
  • 열거형은 다른 클래스를 상속받을 수 없으므로, 클래스 상속이 필요하다면 사용 불가능
public enum Singleton {
	INSTANCE;
 
	public static Singleton getInstance() {
		return INSTANCE;
	}
}

final class Singleton extends Enum<Singleton> {
	public static final INSTANCE = new Singleton();
}

 


References

새해를 맞아서 기술 블로그를 시작해보려 하는데 

네이버 블로그조차도 운영해본 적이 없다보니 아직 많이 어색하다..

원래는 개인 노션에만 모든걸 정리했었는데 프로젝트나 공부 내용이 다 섞여서 관리가 좀 힘들더라

그래서 나도 찾기 편하고 남들한테 조금이라도 도움이 될 수 있을까 싶어서 블로그에 도전..!

+ Recent posts