맞는데 왜 틀릴까..?

Java/Effective Java

[Effective Java] Item 83. 지연 초기화는 신중히 사용하라

안도일 2023. 8. 21. 03:25

지연 초기화 (lazy initialization)는 필드의 초기화 시점을 그 값이 처음 필요할 때까지 늦추는 기법이다.

따라서 값이 전혀 쓰이지 않으면 초기화도 결코 일어나지 않는다.

 

이 기법은 정적 필드와 인스턴스 필드 모두에 사용할 수 있다.

주로 최적화 용도로 쓰이지만, 클래스와 인스턴스 초기화 때 발생하는 위험한 순환 문제를 해결하는 효과도 있다.

 

지연 초기화 특징

 

지연 초기화는 양날의 검이다.

다른 최적화처럼 필요할 때까지는 하지 않는 게 좋다.

클래스 혹은 인스턴스 생성 시의 초기화 비용은 줄지만 그 대신 지연 초기화하는 필드에 접근하는 비용은 커진다.

 

실제 성능이 느려지는 이유

  • 지연초기화하려는 필드들 중 초기화가 이뤄지는 비율
  • 실제 초기화에 드는 비용
  • 초기화된 각 필드를 얼마나 빈번히 호출하는지

 

지연 초기화가 필요한 경우

  • 해당 클래스의 인스턴스 중 그 필드를 사용하는 인스턴스의 비율이 낮은 반면, 그 필드를 초기화하는 비용이 클 때
  • 하지만 정말 그런지를 알 수 있는 유일한 방법은 지연 초기화 적용 전후 성능을 측정해 보는 것이다.

 

멀티 스레드 환경에서 지연 초기화는 까다롭다.

  • 지연 초기화하는 필드를 둘 이상의 스레드가 공유한다면 어떤 형태로든 반드시 동기화해야 한다.
  • 그렇지 않으면 심각한 버그로 이어진다.

 

 

초기화

 

 

인스턴스 필드의 일반적인 초기화

 

private final FieldType field1 = computeFieldValue();
  • 대부분의 상황에서 일반적인 초기화가 지연 초기화보다 낫다.

 

 

 

인스턴스 필드의 지연 초기화 - synchronized 접근자 방식

 

private FieldType field;

private synchronized FieldType getField() {
  if (field2 == null)
    field2 = computeFieldValue();
  return field;
}

 

  • 지연 초기화가 초기화 순환성(Initialization Circularity)을 깨뜨릴 것 같으면 synchronized를 단 접근자를 사용하자.

 

초기화 순환성 (Initialization Circularity)

초기화 순환성은 클래스나 객체 초기화 과정에서 클래스나 객체 간에 서로를 초기화하려고 할 때 발생하는 문제를 말한다.
클래스나 객체 간에 서로를 참조하고 있는 상황에서 초기화가 제대로 이루어지지 않을 수 있다.
예를 들어, 클래스 A가 클래스 B를 참조하고, 클래스 B도 클래스 A를 참조하는 경우에는 두 클래스가 서로 초기화에 의존하게 된다. 이때 어느 클래스를 먼저 초기화해야 하는지 모호해지며, 이로 인해 초기화 순환성 문제가 발생할 수 있다.

이러한 초기화 순환성 문제를 해결하기 위해 synchronized를 사용하는 접근자 메서드를 활용할 수 있다.
synchronized 키워드를 사용하면 해당 메서드 블록이 한 번에 하나의 스레드만 접근하도록 제한하므로, 초기화 순환성 문제를 예방할 수 있다. 이렇게 하면 두 개의 클래스가 서로를 참조하더라도 동시 초기화에 의한 문제가 발생하지 않는다.

 

정적 필드용 지연 초기화 홀더 클래스 관용구

 

private static class FieldHolder {
  static final FieldType field = computeFieldValue();
}

private static FieldType getField() { return FieldHolder.field; }

 

  • 성능 때문에 정적 필드를 지연 초기화해야 한다면 지연 초기화 홀더 클래스 관용구를 사용하자.
  • 클래스는 클래스가 처음 쓰일 때 초기화된다는 특성을 이용
  • getField가 처음 호출되는 순간 FileHolder.field가 처음 읽히면서 클래스 초기화
  • getField 메서드가 필드에 접근하면서 동기화를 전혀 하지 않아 성능이 느려질 거리가 전혀 없다.

 

 

인스턴스 필드 지연 초기화용 이중검사 관용구

 

private volatile FieldType field;

private FieldType getField() {
  FieldType result = field;
  if (result != null)    // 첫 번째 검사 (락 사용 안 함)
    return result;

  synchronized(this) {
    if (field == null) // 두 번째 검사 (락 사용)
      field = computeFieldValue();
    return field;
  }
}

 

  • 성능 때문에 인스턴스 필드를 지연 초기화해야 한다면 이중검사 관용구를 사용하자.
  • 초기화된 필드에 접근할 때의 동기화 비용을 없애준다.
  • 한번은 동기화 없이 검사하고, 두 번째는 동기화하여 검사한다.
  • 두 번째 검사에서도 필드가 초기화되지 않았을 때만 필드를 초기화한다.
  • 필드가 초기화된 후로는 동기화하지 않으므로 해당 필드는 반드시 volatile로 선언해야 한다.
  • result는 필드가 이미 초기화된 상황에서 그 필드를 딱 한 번만 읽도록 보장하는 역할을 한다.

 

 

단일검사 관용구 - 초기화가 중복해서 일어날 수 있다.

 

private volatile FieldType field;

private FieldType getField() {
  FieldType result = field;
  if (result == null)
    field = result = computeFieldValue();
  return result;
}

private static FieldType computeFieldValue() {
  return new FieldType();
}

 

  • 이중검사의 변종이다.
  • 가끔 반복해서 초기화해도 상관없는 인스턴스 필드를 지연 초기화해야 할 때가 있는 상황에서 두 번째 검사를 생략할 수 있다.
  • 필드는 여전히 volatile로 선언한다.
  • 모든 스레드가 필드의 값을 다시 계산해도 상관없고 필드의 타입이 long과 double을 제외한 다른 기본 타입이라면 단일검사의 volatile 한정자를 없애도 된다.
  • 어떤 환경에서는 필드 접근 속도를 높여주지만, 초기화가 스레드당 최대 한 번 더 이뤄질 수 있으므로 보통 거의 쓰지 않는다.

 

 

결론

 

  • 대부분의 필드는 지연시키지 말고 곧바로 초기화해야 한다.
  • 성능 또는 위험한 초기화 순환을 막기 위해 지연 초기화를 써야 한다면 올바른 지연 초기화 기법을 사용하자.
  • 인스턴스 필드에는 이중검사 관용구를, 정적 필드에는 지연 초기화 홀더 클래스 관용구를 사용하자.