item 7
다 쓴 객체 참조를 해제하라.
- C 와 C++은 메모리를 직접 관리해야 하는 언어이다.
- JAVA 는 이와 달리 GC(가비지 컬렉터)를 갖춘 언어로 메모리 관리를 안해도 된다고 생각하지만 이는 큰 오산이다.
다음 코드를 살펴보자.
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];
}
/* note:
* 원소를 위한 공간을 적어도 하나 이상 확보한다. -> +1
* 배열 크기를 늘려야 할 때마다 대략 두 배씩 늘린다. -> 2*size
*/
private void ensureCapacity(){
if(size == elements.length ){
elements = Arrays.copyOf(elements, 2*size + 1);
}
}
}
=> 위의 코드는 문제가 없어보이지만 문제가 확실히 있다.
바로 메모리 누수로, 위 코드에서 스택이 커졌다가 줄어들었을 때 스택에서 꺼내진 객체들을
가비지 컬렉터가 회수하지 않는다. 프로그램에서 그 객체들을 더 이상 사용하지 않더라도 말이다.
바로 이 스택이 그 객체들의 다 쓴 참조(obsolete reference) 를 여전히 가지고 있기 때문인데,
pop() 메소드를 보면 이 스택이 저장된 객체들을 빼내도 여전히 그 객체들을 참조함을 알 수 있다.
가령 스택의 현 size 가 2이고 elements[0] = ‘a’, elements[1] = ‘b’일때 pop 해서 ‘b’를 빼낸다 치면,
스택의 size 는 1이 되고 인덱스 1인 곳에는 ‘b’가 없을거라 생각할 수 있지만, 애초에 스택의 크기가 16이기 때문에
스택은 여전히 b를 참조하고 있으므로 가비지 컬렉터는 이 다 쓴 참조 를 회수할 수 없는 것이다.
위의 코드에서는 elements 배열의 ‘활성 영역’ 밖의 참조들이 모두 다 쓴 참조에 해당한다.
활성 영역은 인덱스가 size 보다 작은 원소들로 구성된다.
- GC(가비지 컬렉터) 언어에서는 (의도치 않게 객체를 살려두는) 메모리 누수(memory leak)를 찾기가 아주 까다롭다. 객체 참조 하나를 살려두면 GC는 그 객체뿐 아니라 그 객체가 참조하는 모든 객체 (그리고 또 그 객체들이 참조하는 모든 객체..)를 회수해가지 못한다. 그래서 단 몇 개의 객체가 매우 많은 객체를 회수되지 못하게 할 수 있고 잠재적으로 성능에 악영향을 줄 수 있다.
null 처리 (참조 해제), 제대로 구현한 pop 메서드
public Object pop(){
if(size == 0){
throw new EmptyStackException();
}
Object result = elements[--size];
elements[size] = null; //null 처리
return result;
}
=> 해법은 간단하다. 해당 참조를 다 썼을 때 null(참조 해제)하면 된다.
예시의 스택 클래스에서는 각 원소의 참조가 더 이상 필요 없어지는 시점은 스택에서 꺼내질 때다.
따라서 위와 같이 pop()의 코드를 재수정하였다.
-
다 쓴 참조를 null 처리하면 다른 이점도 따라온다. 만약 null 처리한 참조를 실수로 사용하려 하면 프로그램은 즉시 NullPointerException 을 던지며 종료된다. 미리 null 처리하지 않았다면 아무 내색 없이 무언가 잘못된 일이 수행될 것이고, 그런 것보다 예외를 통해 프로그램 오류를 가능한 한 조기에 발견하는 것이 좋다.
-
그렇다고 필요없는 객체를 볼 때 마다 null 로 설정하는 코드를 작성하지는 말자. 객체를 null로 설정하는 건 예외적인 상황에서나 하는 것이지 보통의 경우는 코드를 지저분하게 만들 뿐이다.
-
필요없는 객체 참조를 정리하는 최선책은 그 참조를 가리키는 변수를 특정한 범위(스코프) 안에서만 사용하는 것이다. (로컬 변수는 그 영역 넘어가면 쓸모없어져서 정리된다.) 변수를 가능한 가장 최소의 스콥으로 사용하면 자연스럽게 그렇게 될 것이다. (하지만 위에 코드처럼 size라는 멤버 변수와 elements 를 쓰는 경우엔 역시 자연스럽게 그렇게 되지 않으니까.. 즉 예외적인 상황이라 그래서 명시적으로 null로 설정하는 코드를 써야 했던 것이다.)
-
그럼 null 처리는 언제 해야 할까? 위 코드의 Stack처럼 메모리를 직접 관리 할 때, elements 라는 배열을 직접 관리하는 경우에 GC는 어떤 객체가 필요없는 객체인지 알 수 가 없다. GC가 보기에는 비활성 영역에서 참조하는 객체도 똑같이 유효한 객체이기 때문이다. 오직 프로그래머만이 비활성 영역의 객체가 더이상 쓸모없다는 것을 알고 있으므로, 따라서 프로그래머는 비활성 영역이 되는 순간 null 처리해서 해당 객체를 더는 쓰지 않을 것임을 GC에 알려야 한다.
메모리를 직접 관리하는 클래스는 프로그래머가 메모리 누수를 조심해야 한다.(null 처리해야 한다.)
캐시
캐시를 사용할 때도 메모리 누수 문제를 조심해야 한다. 객체의 레퍼런스를 캐시에 넣어 놓고 캐시를 비우는 것을 잊기 쉽다.
여러 가지 해결책이 있지만, 캐시의 키에 대한 레퍼런스가 캐시 밖에서 필요 없어지면
해당 엔트리를 캐시에서 자동으로 비워주는 WeakHashMap을 쓸 수 있다.
또는 특정 시간이 지나면 캐시값이 의미가 없어지는 경우에 백그라운드 쓰레드를 사용하거나 (아마도 ScheduledThreadPoolExecutor),
새로운 엔트리를 추가할 때 부가적인 작업으로 기존 캐시를 비우는 일을 할 것이다.
(LinkedHashMap 클래스는 removeEldestEntry라는 메서드를 제공한다.)
콜백
세번째로 흔하게 메모리 누수가 발생할 수 있는 지점으로 리스너와 콜백이 있다.
클라이언트 코드가 콜백을 등록할 수 있는 API를 만들고 콜백을 뺄 수 있는 방법을 제공하지 않는다면,
계속해서 콜백이 쌓이기 할 것이다. 이것 역시 WeahHashMap을 사용해서 해결 할 수 있다.
메모리 누수는 발견하기 쉽지 않기 때문에 수년간 시스템에 머물러 있을 수도 있다.
코드 인스택션이나 heap profiler 같은 디버깅 툴을 사용해서 찾아야 한다.
따라서 이런 문제를 예방하는 방법을 학습하여 미연에 방지하는 것이 좋다.