C, C++처럼 메모리를 직접 관리해야 하는 언어를 쓰다가 자바처럼 가비지 컬렉터를 갖춘 언어로 넘어오면 다 쓴 객체를 가비지 컬렉터가 알아서 회수해가니 프로그래머의 삶이 훨씬 평안해진다. 그래서 자칫 메모리 관리에 더 이상 신경 쓰지 않아도 된다고 오해할 수 있는데, 절대 사실이 아니다. 스택을 간단히 구현한 다음 코드를 보자.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 | 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); } } | cs |
이 코드에서는 스택이 커졌다가 줄어들었을 때 스택에서 꺼내진 객체들은 프로그램에서 더 이상 사용하지 않더라도 가비지 컬렉터가 회수하지 않는다. 스택이 그 객체들의 다 쓴 참조(obsolete reference, 다시 쓰지 않을 참조)를 여전히 가지고 있기 때문이다. 앞의 코드에서는 elements 배열의 '활성 영역'(인덱스가 size보다 작은 원소들) 밖의 참조들이 모두 여기에 해당한다. 따라서 이 스택을 사용하는 프로그램은 '메모리 누수' 문제가 있으며, 오래 실행하다 보면 점차 가비지 컬렉션 활동과 메모리 사용량이 늘어나 결국 성능이 저하될 것이다.
가비지 컬렉션 언어에서는 의도치 않게 객체를 살려두는 메모리 누수를 찾기가 아주 까다롭다. 객체 참조 하나를 살려두면 가비지 컬렉터는 그 객체뿐 아니라 그 객체가 참조하는 모든 객체를 회수해가지 못한다. 그래서 단 몇 개의 객체가 매우 많은 객체를 회수되지 못하게 할 수 있고 잠재적으로 성능에 악영향을 준다.
해법은 해당 참조를 다 썼을 때 null 처리(참조 해제)하면 된다. 예시의 Stack 클래스에서는 각 원소의 참조가 더 이상 필요 없어지는 시점은 스택에서 꺼내질 때다.
1 2 3 4 5 6 7 8 | // 제대로 구현한 pop 메소드 public Object pop() { if (size == 0) throw new EmptyStackException(); Object result = elements[--size]; elements[size] = null; // 다 쓴 참조 해제 return result; } | cs |
다 쓴 참조를 null 처리하면 다른 이점도 있는데, 만약 null 처리한 참조를 실수로 사용하려 하면 프로그램은 즉시 NullPointerException을 던지며 종료된다. 하지만 모든 객체를 다 쓰자마자 일일이 null 처리할 필요는 없고, 객체 참조를 null 처리하는 일은 예외적인 경우여야 한다. 다 쓴 참조를 해제하는 가장 좋은 방법은 그 참조를 담은 변수를 유효 범위(scope) 밖으로 밀어내는 것이다.
일반적으로 자기 메모리를 직접 관리하는 클래스라면 프로그래머는 항시 메모리 누수에 주의해야 한다. Stack 클래스가 메모리 누수에 취약한 이유는 바로 스택이 자기 메모리를 직접 관리하기 때문이다. 배열의 활성 영역에 속한 원소들이 사용되고 비활성 영역은 쓰이지 않는데, 이는 프로그래머만 아는 사실이고 가비지 컬렉터는 알 길이 없다. 그러므로 프로그래머는 비활성 영역이 되는 순간 null 처리해서 해당 객체를 더는 쓰지 않을 것임을 가비지 컬렉터에 알려야 한다.
캐시 역시 메모리 누수를 일으키는 주범이다. 객체 참조를 캐시에 넣고 나서, 이 사실을 잊은 채 방치하는 경우가 많다. 엔트리가 살아 있는 캐시가 필요한 상황이라면, WeakHashMap을 사용해 캐시를 만들어 해결할 수 있다.
캐시 엔트리의 유효 기간을 정확히 정의하기 어려운 경우에는, 쓰지 안는 엔트리를 이따금 청소해줘야 한다. ThreadPoolExecutor 같은 백그라운드 스레드를 활용하는 방법이 있다. LinkedHashMap은 removeEldestEntry 메소드를 써서 캐시에 새 엔트리를 추가할 때 부수 작업으로 수행하는 방법으로 처리한다.
메모리 누수의 세 번째 주범은 바로 리스너(listener) 혹은 콜백(callback)이라 부르는 것이다. 클라이언트가 콜백을 등록만 하고 명확히 해지하지 않는다면, 뭔가 조치해주지 않는 한 콜백은 계속 쌓여갈 것이다. 이럴 때 콜백을 약한 참조(weak reference)로 저장하면 가비지 컬렉터가 즉시 수거해간다. 예를 들어 WeakHashMap에 키로 저장하면 된다.
메모리 누수는 겉으로 잘 드러나지 않아 시스템에 수년간 잠복하는 사례도 있다. 이런 누수는 철저한 코드 리뷰나 힙 프로파일러 같은 디버깅 도구를 동원해야만 발견되기도 한다. 그래서 이런 종류의 문제는 예방법을 익혀두는 것이 매우 중요하다.