자바는 가비지 컬렉터를 갖춰 메모리를 직접관리 하지 않아도 된다. 하지만 이는 메모리 관리에 더 이상 신경쓰지 않아도 된다는 말은 아니다.
예제를 살펴보자.
import java.util.*;
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];
}
/**
* Ensure space for at least one more element, roughly
* doubling the capacity each time the array needs to grow.
*/
private void ensureCapacity() {
if (elements.length == size)
elements = Arrays.copyOf(elements, 2 * size + 1);
}
}
위의 예제에서 메모리 누수가 어디서 일어나는지 보이는가? pop()
을 살펴보자. 우리는 push()
를 통해 스택에 값을 넣고 ensureCapacity()
을 통해 부족한 메모리를 늘렸다. 그리곤 pop()
을 통해 스택의 최상단 값을 하나씩 빼고 있는데, 실제로는 값을 빼지 않고 최상단 index를 나타내는 size
만을 줄여 스택의 최상단 값을 반환하고 있다.
즉, 내가 데이터를 100개 push하고 50개 pop을 했다면, size는 50이지만, 배열엔 데이터가 100개 존재한다. pop
을 할 때 기존에 채워져있던 배열을 비우지 않았기 때문이다. 따라서 이 값들을 사용하지 않아도 가비지 컬렉터는 이 값들을 회수 하지 않는다.
예제를 통해 알 수 있듯이 가비지 컬렉션 언어에서는 (의도치 않게 객체를 살려두는) 메모리 누수를 찾기 아주 까다롭다. 하나를 살려두면 하나가 참조하는 모든 객체를 회수하지 못하기 때문이다.
따라서 해당 참조를 다 썼을 때 null처리 해야 한다. 메모리 누수가 일어나지 않는 pop()
연산을 살펴보자.
public Object pop() {
if (size == 0)
throw new EmptyStackException();
Object result = elements[--size];
elements[size] = null; // Eliminate obsolete reference
return result;
elements[size] = null;
을 통해 pop한 객체는 참조를 해제하고 있다. 이렇게 null처리를 하면 size값 외에 잘못된 값을 사용하려 할때 NPE가 터져 종료된다. 기존의 pop()
이었다면, 이미 pop()
된 값이지만 배열내에 살아있어 그 값이 사용돼 잡기 어려운 버그가 되었을 것이다.
하지만 이렇게 객체를 다 쓰자마자 모두 null 처리하는 것은 프로그램을 필요 이상으로 지저분하게 만들뿐이다. 따라서 객체 참조를 null 처리하는 일은 예외적인 경우여야 한다. 다 쓴 참조를 해제하는 가장 좋은 방법은 그 참조를 담은 변수를 유효 범위 밖으로 밀어내는 것이다.
그렇다면 스택 클래스는 왜 메모리 누수에 취약했을까? 스택이 자기 메모리를 직접 관리하기 때문이다. 따라서 활성영역과 비활성 영역에 대해 가비지 컬렉터는 모르므로 프로그래머는 이를 가비지 컬렉터에게 알려야한다.
따라서 자기 메모리를 직접 관리하는 클래스라면 프로그래머는 항시 메모리 누수에 주의해야한다.
캐시 역시 메모리 누수를 일으키는 주범이다. 캐시에 넣어놓고 까먹고 그냥 놔두는 경우가 자주 있기 때문이다. 따라서 보통 시간이 지날수록 캐시 엔트리의 가치를 떨어뜨리는 방식을 사용한다. 그래서 사용하지 않는 엔트리를 이따금 청소해줘야하며 이 방법엔 백그라운드 스레드를 활용하거나, 캐시에 새 엔트리를 추가할 때 부수 작업으로 수행하는 것이 있다.
Object key1 = new Object();
Object value1 = new Object();
Map<Object, List> cache = new HashMap<>();
cache.put(key1, value1);
위와 같은 캐시가 있다고 해보자. 하지만 이 캐시에서 key1이 삭제 되면 캐시는 무의미하다. 따라서 엔트리가 존재할 때 캐시가 의미있길 원한다면 아래와 같이 수정하면 된다.
Object key1 = new Object();
Object value1 = new Object();
Map<Object, List> cache = new WeakHashMap<>();
cache.put(key1, value1);
WeakHashMap을 통해 cache의 엔트리가 삭제 될 때 자동으로 캐시는 제거될 것이다.
세번째 주범은 바로 리스너 혹은 콜백이다. 클라이언트가 콜백을 등록만 하고 해지 하지 않는다면, 계속 쌓인다. 따라서 콜백을 약한 참조로 저장하면 가비지 컬렉터가 수거해간다.
이러한 문제들은 예방법을 익혀두는 것이 매우 중요하다.
이펙티브 자바 3/E
effective java - 백기선