아이템 83. 지연 초기화는 신중히 사용하라. - ksw6169/effective-java GitHub Wiki

지연 초기화(lazy initialization)

  • 지연 초기화란 필드의 초기화 시점을 그 값이 처음 필요할 때까지 늦추는 기법이다.
  • 그래서 값이 전혀 쓰이지 않으면 초기화도 결코 일어나지 않는다.
  • 이 기법은 정적 필드와 인스턴스 필드 모두에 사용할 수 있다.
  • 지연 초기화는 주로 최적화 용도로 쓰이지만, 클래스가 인스턴스 초기화 때 발생하는 위험한 순환 문제를 해결하는 효과도 있다.

지연 초기화는 필요할 때까지는 하지 말라.

  • 지연 초기화는 양날의 검이다. 클래스 혹은 인스턴스 생성 시의 초기화 비용은 줄지만 그 대신 지연 초기화하는 필드에 접근하는 비용은 커진다.
  • 지연 초기화하려는 필드들 중 결국 초기화가 이뤄지는 비율에 따라, 실제 초기화에 드는 비용에 따라, 초기화된 각 필드를 얼마나 빈번히 호출하느냐에 따라 지연 초기화가 실제로는 성능을 느려지게 할 수도 있다.

지연 초기화가 필요한 상황

  • 해당 클래스의 인스턴스 중 그 필드를 사용하는 인스턴스의 비율이 낮은 반면, 그 필드를 초기화하는 비용이 크다면 지연 초기화가 제 역할을 해줄 것이다.
  • 멀티스레드 환경에서는 지연 초기화를 하기가 까다롭다. 지연 초기화하는 필드를 둘 이상의 스레드가 공유한다면 어떤 형태로든 반드시 동기화해야 한다. 그렇지 않으면 심각한 버그로 이어질 것이다.
  • 대부분의 상황에서 일반적인 초기화가 지연 초기화보다 낫다.
// 인스턴스 필드를 초기화하는 일반적인 방법
private final FieldType field = computeFieldValue();

// 인스턴스 필드의 지연 초기화 - synchronized 접근자 방식
private final FieldType field;

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

정적 필드를 지연 초기화하는 올바른 방법

  • 성능 때문에 정적 필드를 지연 초기화해야 한다면 지연 초기화 홀더 클래스(lazy initialization holder class) 관용구를 사용하자.
  • 클래스는 클래스가 처음 쓰일 때 비로소 초기화된다는 특성을 이용한 관용구다.
// 정적 필드용 지연 초기화 홀더 클래스 관용구
private static class FieldHolder {
    static final FieldType field = computeFieldValue();
}

private static FieldType getField() {
    return FieldHolder.field;
}
  • getField() 가 처음 호출되는 순간 FieldHolder.field 가 처음 읽히면서 비로소 FieldHolder 클래스 초기화를 촉발한다.
  • 일반적인 VM은 오직 클래스를 초기화할 때만 필드 접근을 동기화하는데, 클래스 초기화가 끝난 후에는 VM이 동기화 코드를 제거하여 그 다음부터는 아무런 검사나 동기화 없이 필드에 접근할 수 있게 된다. 따라서 이 관용구는 getField() 가 필드에 접근하면서 동기화를 전혀 하지 않으니 성능 저하가 발생하지 않는다.

인스턴스 필드를 지연 초기화하는 올바른 방법

  • 성능 때문에 인스턴스 필드를 지연 초기화한다면 이중 검사(double-check) 관용구를 사용하라.
  • 이 관용구는 초기화된 필드에 접근할 때의 동기화 비용을 없애준다.
  • 이 방식은 필드의 값을 두 번 검사하는 방식으로, 한 번은 동기화 없이 검사하고, 필드가 아직 초기화되지 않았다면 두 번째는 동기화하여 검사한다. 두 번째 검사에서도 필드가 초기화되지 않았을 때만 필드를 초기화한다. 필드가 초기화된 후로는 동기화하지 않으므로 해당 필드는 반드시 volatile 로 선언해야 한다.
// 인스턴스 필드 지연 초기화용 이중검사 관용구
private volatile FieldType field;

private FieldType getField() {
    // 가장 최근에 기록된 값을 읽게 됨을 보장(volatile), 다른 Thread에서 초기화한 값을 읽어들이기 위해 사용(Main memory에서 읽어 들임)
    FieldType result = field;
    if (result != null)  // 첫 번째 검사 (락 사용 안함)
        return result;
    
    // 앞에서 필드 초기화 여부를 확인했으므로 동기화하여 field를 초기화한다.
    synchronized(this) {
        if (field == null)  // 두 번째 검사 (락 사용)
            field = computeFieldValue();
        return field;
    }
}

위 코드에서 result 지역변수가 필요한 이유

이 변수는 필드가 이미 초기화된 상황에서는 그 필드를 딱 한 번만 읽도록 보장하는 역할을 한다. 반드시 필요하지는 않지만 성능을 높여주고 저수준 동시성 프로그래밍에 표준적으로 적용되는 더 우아한 방법이다. (필자의 PC에서는 volatile 지역변수를 사용할 때가 1.4배 더 빠르게 동작했다고 한다.)

반복 초기화해도 상관없는 인스턴스 필드를 지연 초기화하는 올바른 방법

  • 반복해서 초기화해도 상관없는 인스턴스 필드를 지연 초기화한다면 단일 검사(single-check) 관용구를 사용하라.
// 단일 검사 관용구 - 초기화가 중복해서 일어날 수 있다.
private volatile FieldType field;

private FieldType getField() {
    FieldType result = field;
    if (result == null)
        field = result = computeFieldValue();
    return result;
}
  • 모든 스레드가 필드의 값을 다시 계산해도 상관없고 필드의 타입이 long과 double을 제외한 다른 기본 타입이라면, 단일검사의 필드 선언에서 volatile 한정자를 없애도 된다. 이 변종은 짜릿한 단일검사(razy single-check) 관용구라 불린다.
  • 이 관용구는 어떤 환경에서든 필드 접근 속도를 높여주지만, 초기화가 스레드당 최대 한 번 더 이뤄질 수 있다. 아주 이례적인 기법으로 보통은 거의 쓰지 않는다.

핵심 정리

  • 대부분의 필드는 지연시키지 말고 곧바로 초기화해야 한다.
  • 성능 때문에 혹은 위험한 초기화 순환을 막기 위해 꼭 지연 초기화를 써야 한다면 올바른 지연 초기화 기법을 사용하자.
  • 인스턴스 필드에는 이중검사 관용구를, 정적 필드에는 지연 초기화 홀더 클래스 관용구를 사용하자.
  • 반복해 초기화해도 괜찮은 인스턴스 필드에는 단일검사 관용구도 고려 대상이다.

참고 자료

  • Effective Java 3/E