-
[이펙티브 자바] item 7 - 다 쓴 객체 참조를 해제하라개발서적읽기/Effective Java 3E 2020. 6. 23. 22:42
■GC가 메모리에서 회수하기 힘든 '다 쓴 참조' 객체
GC가 다 쓴 객체를 알아서 회수해간다고 해서 메모리 관리에 더 이상
신경쓰지 않아도 된다고 오해할 수 있는데, 절대 사실이 아니다.
아래 Stack을 사용하는 프로그램을 오래 실행하다 보면 점차 GC 활동과
메모리 사용량이 늘어나 결국 성능이 저하될 것이다.
상대적으로 드문 경우긴 하지만 심할 때는 디스크 페이징이나
OutOfMemoryError를 일으켜 프로그램이 예기치 않게 종료되기도 한다.
과연 메모리 누수가 일어나는 위치는 어디일까?
public class Stack {
private Object[] elements;
private int size = 0;
private static final int DEFAULT_INITIAL_CAPACITY = 16;
public Stack() {
elements = new Object[DEFAULT_INITIAL_CAPACITY];
}
public void push(Object e) {
ensureCapacity();
elements[size++] = e;
}
public Object pop() {
if (size == 0)
throw new EmptyStackException();
return elements[--size];
}
/**
* 원소를 위한 공간을 적어도 하나 이상 확보한다.
* 배열 크기를 늘려야 할 때마다 대략 두 배씩 늘린다.
*/
private void ensureCapacity() {
if (elements.length == size)
elements = Arrays.copyOf(elements, 2 * size + 1);
}
}이 Stack은 커졌다가 줄어들었을 때 Stack에서 꺼내진 객체들을 GC가 회수하지 않는다.
프로그램에서 그 객체들을 더 이상 사용하지 않더라도 말이다.
왜냐하면 Stack이 그 객체들의 다 쓴 참조(obsolete reference)를 여전히 가지고
있기 때문이다. 여기서 다 쓴 참조란 문자 그대로 앞으로 다시 쓰지 않을 참조를 뜻한다.
elements 배열의 '활성 영역' 밖의 참조들이 모두 '다 쓴 참조'에 해당한다.
GC 언어에서는 (의도치 않게 객체를 살려두는) 메모리 누수를 찾기가
아주 까다롭다. 객체 참조 하나를 살려두면, GC는 그 객체뿐 아니라 그 객체가
참조하는 모든 객체와 그 객체들이 참조하는 객체들 모두를 회수하지 못한다.
그래서 단 몇 개의 객체가 매우 많은 객체를 회수되지 못하게 할 수 있고
잠재적으로 성능에 악영향을 줄 수 있다.
■'다 쓴 참조' 객체를 회수하는 방법
해당 참조의 사용이 완료됐을 때 null 처리(참조 해제)하면 된다.
위 Stack에선, 각 원소의 참조가 더 이상 필요 없어지는 시점은 Stack에서 꺼내질 때다.
다음은 제대로 구현된 pop 메서드이다.
public Object pop() {
if (size == 0)
throw new EmptyStackException();
Object result = elements[--size];
elements[size] = null; // 다 쓴 참조 해제
return result;
}다 쓴 참조를 null 처리하면 또 다른 장점도 있다.
만약 null 처리한 참조를 실수로 사용하려 하면 프로그램은 즉시 NPE를 던지며 종료된다.
미리 null 처리하지 않았다면 아무 내색 없이 무언가 잘못된 일을 수행할 것이다.
프로그램 오류는 가능한 한 조기에 발견하는게 좋다. (이왕이면 컴파일 타임)
주의할 점이 있다.
'다 쓴 참조' 객체로 크게 데인 적이 있는 개발자는 모든 객체를 다 쓰자마자
일일이 null 처리하는데 혈안이 되기도 한다.
하지만 그럴 필요도 없고 바람직하지도 않다.
오히려 프로그램을 필요 이상으로 지저분하게 만들 뿐이다.
객체 참조를 null 처리하는 일은 예외적인 경우여야 한다.
다 쓴 참조를 해제하는 가장 좋은 방법은 그 참조를 담은 변수를
유효 범위(scope) 밖으로 밀어내는 것이다.
변수의 범위를 최소가 되게 정의했다면(item 57) 이 일은 자연스럽게 이뤄진다.
■그렇다면 null 처리는 언제 해야 할까?
위 Stack 클래스는 왜 메모리 누수에 취약한 걸까?
왜냐하면 Stack이 자기 메모리를 직접 관리하기 때문이다.
이 Stack은 (객체 자체가 아니라 객체 참조를 담는) elements 배열로 저장소 풀을
만들어 원소들을 관리한다. (그런데 객체 자체를 담는 케이스는 어떤 케이스일까?)
배열의 활성 영역에 속한 원소들이 사용되고 비활성 영역은 쓰이지 않는다.
문제는 GC는 이 사실을 알 길이 없다는 데 있다.
GC가 보기에는 비활성 영역에서 참조하는 객체도 똑같이 유효한 객체다.
비활성 영역의 객체가 더 이상 쓸모없다는 건 개발자만 아는 사실이다.
그렇기 때문에 개발자는 비활성 영역이 되는 순간 null 처리해서
해당 객체를 더는 쓰지 않을 것임을 GC에 알려야 한다.
일반적으로 자기 메모리를 직접 관리하는 클래스라면, 개발자는 항시
메모리 누수(memory leak)에 주의해야 한다. 원소를 다 사용한 즉시 그 원소가
참조한 객체들을 다 null 처리해줘야 한다.
■메모리 누수를 일으키는 두번째 주범 :: 캐시
객체 참조를 캐시에 넣고 나서, 이 사실을 까맣게 잊은 채
그 객체를 다 쓴 뒤로도 한참을 그냥 놔두는 일을 자주 접할 수 있다.
해법은 여러 가지다.
운 좋게 캐시 외부에서 키(key)를 참조하는 동안만 (value가 아니다)
엔트리가 살아 있는 캐시가 필요한 상황이라면 WeakHashMap을 사용해
캐시를 만들자. 다 쓴 엔트리는 그 즉시 자동으로 제거될 것이다.
단, WeakHashMap은 이러한 상황에서만 유용하다는 사실을 기억하자.
한편 캐시를 만들 때 보통은 캐시 엔트리의 유효 기간을 정확히 정의하기 어렵기 때문에
시간이 지날수록 엔트리의 가치를 떨어뜨리는 방식을 흔히 사용한다.
이런 방식에선 쓰지 않는 엔트리를 이따금 청소해줘야 한다.
(ScheduledThreadPoolExecutor 같은) 백그라운드 Thread를 활용하거나
캐시에 새 엔트리를 추가할 때 부수 작업으로 수행하는 방법이 있다.
LinkedHashMap은 removeEldestEntry 메서드를 써서 후자의 방식으로 처리한다.
protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
return false;
}(removeEldestEntry 메서드 내부를 살펴보려고 했는데 별다른게 보이지 않는다... 뭐지??)
더 복잡한 캐시를 만들고 싶다면 java.lang.ref 패키지를 직접 활용해야 할 것이다.
■메모리 누수를 일으키는 세번째 주범 :: 리스너(listener) 혹은 콜백(callback)
클라이언트가 콜백을 등록만 하고 명확히 해지하지 않는다면, 뭔가 조치해주지 않는 한
콜백은 계속 쌓여갈 것이다. 이럴 때 콜백을 약한 참조(weak reference)로
저장하면 CG가 즉시 수거해간다. 예를 들어 WeakHashMap에 키로 저장하면 된다.
'개발서적읽기 > Effective Java 3E' 카테고리의 다른 글
[이펙티브 자바] item 9 - try-finally보다는 try-with-resources를 사용하라 (0) 2020.06.29 [이펙티브 자바] item 8 - finalizer와 cleaner 사용을 피하라 (0) 2020.06.25 [이펙티브 자바] item 6 - 불필요한 객체 생성을 피하라 (0) 2020.06.22 [이펙티브 자바] item 5 - 자원을 직접 명시하지 말고 의존 객체 주입을 사용하라 (0) 2020.06.22 [이펙티브 자바] item 4 - 인스턴스화를 막으려거든 private 생성자를 사용하라 (0) 2020.06.10 댓글