지연 초기화(lazyinitialization): 필드의 초기화 시점을 그 값이 처음 필요할 때까지 늦추는 기법
값이 전혀 쓰이지 않으면 초기화도 결코 일어나지 않고 정적 필드와 인스턴스 필드 모두 사용할 수 있다. 최적화 용도와 클래스와 인스턴스 초기화 때 위험한 순환 문제도 해결해준다.
하지만 가능한 필요할 때 까지는 하지말자. 지연초기화도 트레이드 오프가 발생한다. 클래스 혹은 인스턴스 생성 시의 초기화 비용은 줄지만, 지연 초기화하는 필드에 접근하는 비용은 커진다. 지연 초기화 하려는 필드들 중 초기화가 이뤄지는 비율, 실제 초기화에 드는 비용, 초기화된 각 필드를 얼마나 빈번히 호출하냐 따라 지연 초기화가 실제로는 성능을 느려지게할 수 있다.
즉, 해당 클래스의 인스턴스 중 그 필드를 사용하는 인스턴스의 비율이 낮은 반면 그 필드를 초기화하는 비용이 크다면 지연 초기화가 제 역할을 잘 하겠다고 생각되겠지만, 이 것도 실제로 그렇다는 것을 알려면 지연 초기화 적용 전후 성능을 측정해보는 방법밖에 없다.
그리고 멀티스레드 환경에선 지연 초기화 하기 더 까다롭다. 지연 초기화 필드를 둘 이상의 스레드가 공유한다면 반드시 동기화해야 한다.
일반적인 인스턴스 필드 초기화
대부분의 상황에서 일반적인 초기화가 지연 초기화보다 낫다. 다시 초기화할 수 없게 final 한정자를 붙혔다.
private final FieldType field = computeFieldValue();
인스턴스 필드 지연 초기화 - synchronized 접근자
자바의 초기화 순서가 불명확해서 초기화 순환성(initialization circularity) 문제가 발생할 수 있다.[ 참조1 / 참조2 ]
지연 초기화가 초기화 순환성을 깨뜨려야 한다면 synchronized를 사용하자. 가장 간단하고 명확한 방법이다.
private FieldType field;
private synchronized FieldType getField() {
if (field == null)
field = computeFieldValue();
return field;
}
정적 필드 지연 초기화 - 홀더 클래스 관용구
성능 때문에 정적 필드를 지연 초기화해야 한다면 지연 초기화 홀더 클래스(lazy initialization holder class) 관용구를 사용하자.
클래스가 처음 쓰일 때 비로소 초기화된다는 특성을 이용한 관용구다. 홀더 클래스 없이 구현을 한다면 다음과 같다.
static final FieldType field = computeFieldValue();
private static FieldType getField() {
System.out.println("getField 실행");
return field;
}
private static FieldType computeFieldValue() {
System.out.println("field 최초 초기화");
return new FieldType();
}
public static void main(String[] args) {
System.out.println("Main 시작");
getField();
}
이 경우 출력은 ( field 최초 초기화 / Main 시작 / getField 실행 )이 된다. 클래스가 로드되면서 static 필드도 바로 초기화되는 것.
홀더 클래스 관용구를 사용한다면 다음과 같다.
private static class FieldHolder {
static final FieldType field = computeFieldValue();
}
private static FieldType getField() {
System.out.println("getField 실행");
return FieldHolder.field;
}
private static FieldType computeFieldValue() {
System.out.println("field 최초 초기화");
return new FieldType();
}
public static void main(String[] args) {
System.out.println("Main 시작");
getField();
}
이 경우 출력은 기대했던 대로, ( Main 시작 / getField 실행 / field 최초 초기화 )이 된다. getField가 처음 호출되는 순간, FieldHolder 내부 클래스를 사용하는 처음 사용하는 순간이기 때문에 클래스 초기화를 촉발하게 되며 field를 초기화 하기 때문이다. 그리고 getField 메소드가 필드에 접근하며 동기화를 전혀 하지 않으니 성능이 느려질 거리가 전혀 없다.
일반적인 VM은 오직 클래스를 초기화할 때만 필드 접근을 동기화할 것이다. 클래스 초기화가 끝난 후엔 VM이 동시화 코드를 제거하여, 그 다음부터는 아무런 검사나 동기화 없이 필드에 접근하게 된다.
인스턴스 필드 지연 초기화 - 이중검사 관용구
성능 때문에 인스턴스 필드를 지연 초기화해야 한다면 이중검사(double-check) 관용구를 사용하자.
synchronized 메소드를 사용하는 것과 달리, 이 관용구는 초기화된 필드에 접근할 때 동기화 비용을 없애준다. 필드의 값을 한 번은 도익화 없이 검사하고, 필드가 초기화되지 않았다면 동기화하여 검사한다. 두 번째검사에서도 필드가 초기화되지 않았을 때만 필드를 초기화한다.
그리고, 필드가 초기화된 후로는 동기화하지 않으니 해당 필드는 반드시 volatile로 선언하자.
private volatile FieldType field;
private FieldType getField() {
FieldType result = field;
if (result != null) // 첫 번째 검사 (락 사용 안 함)
return result;
synchronized(this) {
if (field == null) // 두 번째 검사 (락 사용)
field = computeFieldValue();
return field;
}
}
여기서 두 가지 특이점이 잇다.
1. 필드 초기화 이후 동기화하지 않는데 왜 volatile로 선언하는가?
field가 final로 선언되지 않았으니 field 내부가 수정될 수도 있다. 다른 메소드에서 synchronized 메소드나 블록을 사용하지 않는다면, volatile을 사용하는 게 맞다. 책에서는 다른 메소드에서 field를 수정할 경우에 대비하여 volatile을 선언하는 것 같다.
2. 왜 굳이 result 지역 변수를 사용했는가?
필드가 이미 초기화된 상황에선 그 필드를 딱 한 번만 읽도록 보장하는 역할이다. 반드시 필요하진 않지만 성능을 높여주고 저수준 동시성 프로그래밍에 표준적으로 적용되는 방법이다. 지역변수를 사용하지 않을 때보다 사용할 때가 더 빠르다.
만약 result 변수를 사용하지 않앗다면 field가 null인지 체크할 때, 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();
}
짜릿한 단일 검사 관용구
모든 스레드가 필드의 값을 다시 계산해도 상관없고 이와 더불어 필드 타입이 long과 double을 제외한 다른 기본 타입(atomic 성질을 가지고 있지 않은)이라면 단일검사의 필드 선언에서 volatile 한정자를 없애도 된다. 이는 어떤 환경에서 필드 접근 속도를 높여주지만, 초기화가 스레드당 최대 한 번 더 이뤄질 수 있어 잘 사용되지 않는다.
정리
- 설명한 모든 초기화 기법은 기본 타입, 객체 참조 필드 모두에 적용할 수 있고 이중 검사와 단일 검사 관용구를 수치 기본 타입 필드에 적용하면 필드의 값 null 대신 0과 비교하면 된다.
- 대부분의 필드는 지연시키지 말고 곧바로 초기화하자.
- 성능이나 위험한 초기화 순환을 막기위해 써야한다면 올바른 지연 초기화 기법을 사용하자.
- 인스턴스 필드에는 이중검사 관용구를, 정적 필드에는 홀더 클래스 관용구를 사용하자
- 반복해 초기화해도 괜찮은 인스턴스 필드는 단일검사 관용구도 고려 대상이다.
* 위 글은 EffectiveJava 3/E 책 내용을 정리한 글로, 저작권 관련 문제가 있다면 댓글로 남겨주시면 즉각 삭제조치 하겠습니다.
'Reading > Effective Java' 카테고리의 다른 글
[Effective-Java] Item 84. 프로그램의 동작을 스레드 스케줄러에 기대지 말라 (0) | 2022.01.05 |
---|---|
[Effective-Java] Item 82. 스레드 안전성 수준을 문서화하라 (0) | 2022.01.05 |
[Effective-Java] Item 81. wait와 notify보다는 동시성 유틸리티를 애용하라 (0) | 2022.01.05 |
[Effective-Java] Item 80. 스레드보다는 실행자, 태스크, 스트림을 애용하라 (0) | 2022.01.05 |
[Effective-Java] Item 79. 과도한 동기화는 피하라 (1) | 2022.01.05 |