본문 바로가기

Programing/JVM(Java, Kotlin)

[Java] 인스턴스 변수 초기화

어떤분이 초기화 블럭을 물어본 사항이 있어서 확인한 사항을 첨가해서 기록으로 남깁니다.

질문

- 제목 : 클래스 초기화 블럭 사용하는 이유
- 내용 : 초기화 블럭 부분을 공부하면서 느낀건데 왜 굳이 클래스 초기화 블럭을 사용해야 하는지 잘 모르겠어요.
그냥 초기화 코드를 분리해서 관리하기 위함이 전부인가요?

 

개인적으로 이런 존재의 이유(raison d'être)에 대해 고민을 하는 것은 좋다고 생각한다.
문제는 혼자서 고민을 하다가 주제에서 벗어난 곳으로 빠질 위험이 있고 답을 찾기 어려울 수도 있다. 이럴 경우 친구나 커뮤니티에 물어봐서 어느정도 답을 찾는 것이 좋을 것 같다. (좋은 질문이었다는 이야기를 어렵게 하고 있음..)

접근법

일단 초기화 블럭에 대해서는 Java Tutorials 중 Classes and Object > More on Classes > Initializing Fields 에 잘 나와 있다.

여기서는 Field라고 적혀있으므로 정적 변수에 대한 내용도 포함한다. (이 글에서는 정적 변수에 대한 것은 빼고 기술한다.)

인스턴스 변수 초기화 방법

방법1 - 생성자에서 초기화

public class InitializingFields {
    private int value;

    public InitializingFields() {
        value = 1;
    }
    // ...

클래스의 이름과 동일한 메서드인 생성자에서 초기화를 하는 방법이다. 

방법 2- 변수 선언과 함께 초기화

public class InitializingFields {
    private int value = 1;
    // ...

코드 상 인스턴스 변수 선과 초깃값을 표시하는 방법이다. 프로그래머의 관점에서는 그렇지만 방법3와 마찬가지로 초기화 값은 생성자에서 수행된다. 생성자에서 해당 값을 덮어씌울 수도 있다.

방법3 - 초기화 블럭에서 초기화

public class InitializingFields {
    private int value;

    {
        value = 3;
    }
    // ...

초기화 블럭에서 인스턴스 변수를 초기화하는 방법도 있다. 튜토리얼에도 써 있듯이(The Java compiler copies initializer blocks into every constructor.) 초기화 코드는 컴파일러에 의해 생성자 코드로 복사된다. 따라서 생성자 호출 전에 수행이 되는 것과는 다르다.

바이트코드 관점

코드를 합쳐보자.

public class InitializingFields {
    private int value = 1;

    {
        value = 2;
    }

    public InitializingFields() {
        value = 3;
    }

    public static void main(String[] args) {
        InitializingFields initializingFields = new InitializingFields();
        System.out.println(initializingFields.value);
    }
}

컴파일이 되면 아래와 같은 바이트 코드가 만들어진다.

public class com/example/InitializingFields {

  private I value

  public <init>()V
   L0
    LINENUMBER 10 L0
    ALOAD 0
    INVOKESPECIAL java/lang/Object.<init> ()V
   L1
    LINENUMBER 4 L1
    ALOAD 0
    ICONST_1
    PUTFIELD com/example/InitializingFields.value : I
   L2
    LINENUMBER 7 L2
    ALOAD 0
    ICONST_2
    PUTFIELD com/example/InitializingFields.value : I
   L3
    LINENUMBER 11 L3
    ALOAD 0
    ICONST_3
    PUTFIELD com/example/InitializingFields.value : I
   L4
    LINENUMBER 12 L4
    RETURN
   L5
    LOCALVARIABLE this Lcom/example/InitializingFields; L0 L5 0
    MAXSTACK = 2
    MAXLOCALS = 1
...

public <init>()은 생성자이다.

L0: 모든 클래스의 슈퍼클래스인 Object의 생성자 호출이 제일 먼저 들어온다. 만약 상속을 받았으면 그 클래스의 생성자가 호출된다.

L1: 필드 초기화인 값 1으로 초기화하고 있다.

L2: 초기화 블럭의 값인 2로 초기화하고 있다.

L3: 생성자에서 정의한 3으로 초기화 하고 있다.

 

결국 L3에서 최종 덮어쓴 3으로 초기화가 된다.

 

만약 순서를 바꾸면 어떻게 될까?

public class InitializingFields {

    public InitializingFields() {
        value = 3;
    }

    {
        value = 2;
    }

    private int value = 1;
    // ...
public class com/example/InitializingFields {

  private I value

  public <init>()V
   L0
    LINENUMBER 5 L0
    ALOAD 0
    INVOKESPECIAL java/lang/Object.<init> ()V
   L1
    LINENUMBER 10 L1
    ALOAD 0
    ICONST_2
    PUTFIELD com/example/InitializingFields.value : I
   L2
    LINENUMBER 13 L2
    ALOAD 0
    ICONST_1
    PUTFIELD com/example/InitializingFields.value : I
   L3
    LINENUMBER 6 L3
    ALOAD 0
    ICONST_3
    PUTFIELD com/example/InitializingFields.value : I
   L4
    LINENUMBER 7 L4
    RETURN
   L5
    LOCALVARIABLE this Lcom/example/InitializingFields; L0 L5 0
    MAXSTACK = 2
    MAXLOCALS = 1
...

생성자의 코드는 항상 맨 뒤로 존재하고, 필드 초기화 및 초기화 블럭의 코드는 코드에 정의된 순서대로 복사가 됨을 알 수 있다.

소결론

1. 최적화에 의해 마지막 초기화 코드만 바이트 코드에 남는 것이 아닌, 모든 초기화가 옮겨진다.

2. 필드 초기화와 초기화 블럭은 순서에 영향을 받는다.

3. 생성자의 초기화는 항상 나중에 실행된다.

 

원래 질문으로 돌아가서 "왜 굳이 클래스 초기화 블럭을 사용해야 하나?"

카이 호스트만의 'core java se 9 for the impatient'에서는 아래와 같이 적혀있다.

p.103
초기화 블록은 자주 사용하는 기능은 아니다. 대부분의 개발자는 긴 초기화 코드를 헬퍼 메서드 안에 두고, 생성자에서 헬퍼 메서드를 호출한다.

나도 실무에서 초기화 블럭을 통한 인스턴스 변수 초기화 코드를 본적이 없다. 간혹 정적 초기화 블럭(Static Initialization Blocks)은 본적이 있다. enum 사용시 특정 값으로 찾을 경우 iterator로 loop을 하는 대신 캐싱해서 쓰는 경우였다.

코드로 예를 들어보면,,,

public enum StaticInitializationBlock {
    FIELD_INITIALIZATION("field"),
    CONSTRUCTOR_INITIALIZATION("construcor"),
    INITIALIZATION_BLOCK("block");

    @Getter
    private String location;

    StaticInitializationBlock(String location) {
        this.location = location;
    }

    public static StaticInitializationBlock findByLocation(String location) {
        for (StaticInitializationBlock block : StaticInitializationBlock.values()) {
            if (block.location.equals(location)) {
                return block;
            }
        }
        return null;
    }
}

위의 코드는 아래와 같이 사용할 수 있다. 물론 맵의 key가 되는 값이 고유해야한다는 전제가 깔려있다.

public enum StaticInitializationBlock {
    FIELD_INITIALIZATION("field"),
    CONSTRUCTOR_INITIALIZATION("construcor"),
    INITIALIZATION_BLOCK("block");

    @Getter
    private String location;

    StaticInitializationBlock(String location) {
        this.location = location;
    }

    private static final Map<String, StaticInitializationBlock> findMap = new HashMap<>();
    static {
        for (StaticInitializationBlock block : StaticInitializationBlock.values()) {
            findMap.put(block.location, block);
        }
    }

    public static StaticInitializationBlock findByLocation(String location) {
        return findMap.get(location);
    }
}

얼마나 성능의 개선이 있을까 싶지만, enum의 대상이 많아지게 되면 성능의 차이는 커지게 된다. 위의 코드에는 3개이지만 30개로 증가를 해서 전 후의 성능을 측정해보면 (지연을 위해 메서드 호출을 1억회 반복함.)

전: 9s 61ms, 7s 496ms, 7s 680ms
후: 4s 625ms, 4s 656ms, 4s 652ms

후자가 훨씬 응답속도가 빠름을 알 수 있다. (사실 미미한 차이이다)

 

여담이지만,,,

이 책에서 보면 "인스턴스 변수 초기화와 초기화 블록 수행된 순서가 생성자 실행 전"이라고 하는데, 이것은 Java 코드 관점이며 바이트 코드 관점에서는 모두 생성자에서 수행된다. (위에서 이미 확인했다.)