[Java] Static inner class 는 어떻게 지연 초기화가 가능할까?

Rupee·2023년 8월 4일
1

삽질기록

목록 보기
22/27
post-thumbnail

이펙티브 자바 스터디와, 디자인 패턴 스터디에서 싱글턴을 생성하는 방법 중 정적 필드 초기화라는 방법이 있었다.

public class SingletonV5 {  

   private SingletonV5() { }

   static class LazySingletonHolder {
       private static final SingletonV5 INSTANCE = new SingletonV5();
   }

   public static SingletonV5 getInstance() {
       return LazySingletonHolder.INSTANCE;
   }
}

static 키워드가 붙은 모든 변수들은, 어플리케이션 실행시 이미 메모리에 올라가는걸로 알고 있는데 어떻게 지연 초기화가 가능한지 의문점이 생겨 직접 테스트해보았다. 싱글턴을 모른다면, 먼저 해당 포스팅을 보고 오자.

Q. static 키워드가 붙으면 어플리케이션 실행 시에 이미 초기화가 끝나는데, 어떻게 지연 로딩이 가능한가?

결론부터 말하자면, 실제로는 JVM은 실행될때 모든 클래스를 메모리에 올려놓지 않고 그때마다 필요한 클래스를 메모리에 올려 효율적으로 관리한다.

☁️ 클래스 초기화 단계

우선 앞서서 클래스가 어떻게 초기화 되는지 먼저 살펴보자. 총 3가지 과정으로 나뉜다.

  1. Loading(로드) : Java 컴파일러가 클래스 로더를 이용해, 바이트 코드로 컴파일된 클래스 파일을 가져와서 JVM의 메모리(Method Area)에 로드한다.

  2. Linking(링크) : 클래스 파일을 사용하기 위해 올바른 파일인지 검증하고, static 으로 선언된 클래스 변수나 상수(constant)의 메모리를 할당하고 이후에 기본값으로 초기화가 일어난다.

  3. Initialization(초기화) : JVM 메서드 영역에 올려놓은 클래스 파일을 읽으면서, 확보한 영역에 적절한 값으로 초기화를 하고 초기화 메서드(생성자)가 호출된다. 클래스의 초기화는 정적 초기화 실행과 클래스에서 선언된 정적 필드(클래스 변수) 초기화로 구성된다.

https://tecoble.techcourse.co.kr/post/2021-07-15-jvm-classloader/

☁️ 백문이 불여일견, 직접 테스트해보자!

흔히 static 으로 선언된 모든 변수는 어플리케이션 실행과 동시에 메모리에 올라간다고 생각한다. 하지만 실제로는 실행될때 모든 클래스가 메모리에 로드되지 않는다.

우선 앞서서, 정말 static 내부 클래스 안에 생성해둔 static final 객체가 getInstance 호출 전까지 메모리에 올라가지 않는지 눈으로 직접 테스트해보자. 아래 명령어를 통해, 어떤 클래스 로드되었는지 추적할 수 있다.

java -classpath [SingletonV5.class 경로] -verbose:class SingletonV5.java
  1. 외부 클래스를 new 로 생성
public class SingletonV5 {  
    static { // 클래스가 로딩되고 클래스 변수가 준비된 후 자동으로 실행되는 블록
        System.out.println("external static initialization");  // 1
    }

    private SingletonV5() { // private 생성자
        System.out.println("constructor called.. Initialization");  // 3
    }

    static class LazySingletonHolder {
        static {
            System.out.println("internal static initialization"); // 2
        }
        private static final SingletonV5 INSTANCE = new SingletonV5();
    }

    public static SingletonV5 getInstance() {
        return LazySingletonHolder.INSTANCE;
    }
}
 public static void main(String[] args) {
     System.out.println("System start...");
     SingletonV5 singletonV5 = new SingletonV5(); // 임시로 생성자 public 
 }

처음에 생각했던 것과 다르게, 어플리케이션이 실행되었는데 LazySingletonHolder static 클래스는 로드 조차 되지 않았다.

이유는 생각해보면 우리가 헷갈리고 있었을 뿐, 당연하다. 현재 LazySingletonHolderstatic 변수가 아니고 static class 이다. static inner class결국 클래스이기 때문에, 초기화해서 사용해야 한다.

하지만 현재 LazySingletonHolder 클래스를 초기화 할 수 있는 방법들 그 어떤 것도 수행되지 않았다. (ex) new, 클래스 내부 정적 필드 호출 등) 따라서 당연히 클래스 내부에 있는 INSTANCE 변수의 존재 또한 JVM 은 모르는게 당연하다.( 초기화 첫 번째 단계인 Method area 에 올라와있지도 않으니 메모리에 할당도 되지 않고 있겠다. )

🫧 헷갈리는 포인트
static 이 붙었다고 해서 static inner 클래스를 static 멤버나 static 메서드처럼 생각하면 안된다.

  1. 내부 정적 클래스의 static 필드 호출
public static void main(String[] args) {
    System.out.println("System start...");
    SingletonV5 singletonV5 = SingletonV5.getInstance();
}

SingletonV5.getInstance() 를 통해 드디어 static final 필드인 INSTANCE 를 호출하니, 그제서야 LazySingletonHolder 클래스가 로드된 것을 확인할 수 있었다.(착각하고 있었던 어플리케이션 실행 시점부터 로드되지 않는다.)

☁️ 그러면 클래스는 언제 초기화되는가?

앞서서 대충 얘기하고 지나갔던, 클래스 초기화 시점에 대해 자세히 알아보자.

  1. new객체 생성하는 경우
  2. 클래스 내부의 정적 변수 호출하는 경우(단, final 상수 제외)
  3. 클래스 내부의 정적 메서드 호출하는 경우

🫧 헷갈리는 포인트
클래스 초기화는 static 블록static 멤버 변수의 값을 할당하는 것을 의미한다. 즉, 내부의 static 클래스는 초기화 대상이 아니다.

역시 차례차례 테스트해보자.

1. static 필드 호출하는 경우

public class Main {
    public static void main(String[] args) {
        System.out.println("System start...");
        System.out.println(SingletonV5.SINGLETON_FIELD);
    }
}

class SingletonV5 {

    static { // 클래스가 로딩되고 클래스 변수가 준비된 후 자동으로 실행되는 블록
        System.out.println("external static initialization");
    }

    public static String SINGLETON_FIELD = "SINGLETON";
    public static final String SINGLETON_FINAL_FIELD = "FINAL-SINGLETON";

    private SingletonV5() {
        System.out.println("constructor called.. Initialization");  // 3
    }

    static class LazySingletonHolder {

        static {
            System.out.println("internal static initialization"); // 2
        }
        private static final SingletonV5 INSTANCE = new SingletonV5();

    }

    public static SingletonV5 getInstance() {
        return SingletonV5.getInstance();
    }
}

내부의 static 변수를 호출하면, 클래스가 로드되고 초기화 과정이 일어난다.(생성자가 불린 것으로 확인할 수 있다.)

2. static final 필드 호출하는 경우

public class Main {
    public static void main(String[] args) {
        System.out.println("System start...");
        System.out.println(SingletonV5.SINGLETON_FINAL_FIELD);
    }
}

중요한 점은, static final 필드는 외부와 내부 클래스 모두 로드하지 않는다.

Q.그렇다면 잠깐, 여기서 클래스를 로드조차 하지 않았는데 FINAL-SINGLETON 이라는 값은 어디에서 가져와서 출력할 수 있었던 것일까?

공식문서에 따르면, 상수 변수 필드에 대한 참조는 컴파일 타임에 상수 변수의 initializer 를 통해 인라인(in-line)된다.

https://stackoverflow.com/questions/63534066/class-loading-vs-initialisation-java-static-final-variable

3. static method 호출하는 경우

public class Main {
    public static void main(String[] args) {
        System.out.println("System start...");
        SingletonV5.test();
    }
}

마찬가지로 클래스 로드가 잘 된것을 볼 수 있다.

4. static inner class 의 static 변수 호출

  static class LazySingletonHolder { 
        static {
            System.out.println("internal static initialization");
        }

        public static String INNER_SINGLETON_FIELD = "INNER_SINGLETON";   // 추가
        public static final String INNER_SINGLETON_FINAL_FIELD = "INNER_FINAL-SINGLETON"; // 추가
        // private static final SingletonV5 INSTANCE = new SingletonV5();  
    }
}

static 클래스는 일반 클래스랑 같지만, 한 가지 다른 점은 static 이기 때문에 외부 클래스와 독립적으로 작동한다.

즉, Outer 클래스의 로드 없이도 inner static class 만 로드된 것을 볼 수 있다. (final 필드 역시 위에와 똑같이 아무 클래스도 로드 되지 않는다.)

물론 위의 코드에서 new SingletonV5() 라인의 주석을 해제하면 가장 바깥 외부 클래스도 로드된다. LazySingletonHolder 가 초기화되면서 내부 필드들도 초기화가 되었고, 그러면서 SingletonV5 객체 생성으로 인해 초기화가 일어나기 때문이다.

☁️ 멀티스레딩과 초기화 과정

Q. 엇? 그러면 멀티 스레드 환경에서 getInstance() 를 통해 객체를 여러번 호출하게 될때마다 새로운 객체가 생성이 될 수도 있는거 아닌가?

해당 질문을 위해서, 앞서서 클래스 초기화 과정을 먼저 언급한 것이다. 결론부터 말하자면 일반적으로 클래스는 로딩 및 초기화가 딱 한번만 수행된다. 따라서, Thread-Safe 하기 때문에 싱글턴 다른 생성 방법들과 다르게 멀티 스레딩 환경을 신경쓰지 않아도 안전하다.

실제로 스레드 풀을 통해 멀티 스레드 환경에서 수행시켜보면, 한번만 수행된 것을 볼 수 있다.

    @Test
    void singleton5() {
        SingletonV5[] singletonArr = new SingletonV5[50];

        ExecutorService service = Executors.newCachedThreadPool();  // 스레드 풀

        for(int i = 0; i < 50; i++) {
            int finalI = i;
            service.submit(() -> {
                singletonArr[finalI] = SingletonV5.getInstance();
            });
        }

        service.shutdown();

        for(SingletonV5 s : singletonArr) {
            System.out.println(s.toString());  // 객체 해시코드 값 출력
        }
    }

☁️ 총정리

이제 다시 총정리 해보자. 아래 말들이 완벽히 이해가 된다면 성공이다!

  1. 내부클래스를 static 으로 선언하였기 때문에, 싱글톤 클래스가 초기화되어도 SingleInstanceHolder 내부 클래스는 메모리에 로드되지 않았다.

  2. 어떠한 모듈에서 getInstance() 메서드를 호출할 때, SingleInstanceHolder 내부 클래스의 static 멤버를 가져와 리턴하게 되는데, 이때 내부 클래스가 한번만 초기화되면서 싱글톤 객체를 최초로 생성 및 리턴하게 된다.

참고 자료
https://docs.oracle.com/javase/specs/jls/se8/html/jls-12.html#jls-12.4.1
클래스는 언제 로딩되고 초기화되는가? (feat. 싱글톤)
https://ssoco.tistory.com/65
자바 - 그래서 static 변수는 어디에 저장되는가? : 추후 포스팅(static vs static final)

profile
개인용으로 공부하는 공간입니다. 잘못된 부분은 피드백 부탁드립니다!

0개의 댓글