[ 다 쓴 (쓸모없는) 객체 참조를 해제하라 ]
C나 C++ 처럼 개발자가 메모리를 신경써야 하는 언어에서 자동으로 메모리 관리를 해주는 JAVA 같은 언어로 변경 하게 되면 메모리 관리를 더 편하게 할 수 있는 것은 사실이다. (JAVA의 가비지 콜렉터로 관리)
그러나 이게 모든 걸 해결해주진 않는다.
예시 코드를 보자
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);
}
}
다음 코드에서 메모리 누수가 되는 부분이 어디일까?
바로 스택의 사이즈가 변화할 때 꺼내진 객체들이다.
이 코드에서는 스택이 커졌다가 줄어들었을 때 스택에서 꺼내진 객체들을 가비지 컬렉터가 회수하지 않는다.
이 스택이 그 객체들의 다 쓴 참조(obsolete reference)를 여전히 가지고 있기 때문이다.
스택에 계속 쌓다가 많이 빼내도 스택이 차지하고 있는 메모리는 줄어들지 않는다.
가용한 범위(유의미한 값들을 갖고 있는 부분)는 elements배열의 인덱스가 size 보다 작은 부분이고, 그 값보다 큰 부분에 있는 값들은 필요없이 메모리를 차지하고 있는 부분이다.
<해결방법>
public Object pop() {
if (size == 0)
throw new EmptyStackException();
Object result = elements[--size];
elements[size] = null; // 해당 참조를 다 썼을 때 참조 해제 (null 처리) 하기
return result;
}
각 해당 원소의 참조가 더이상 필요 없어지는 시점(Stack에서 꺼낼 때, 사용이 완료됨)에 null로 설정하여 다음 GC가 발생할 때 레퍼런스가 정리되게 한다.
만약 null 처리한 참조를 실수로 사용하려 할 때 프로그램이 NullPointerException을 던지며 종료할 수 있다.
(그 자리에 있는 객체를 비우지 않고 실수로 잘못된 객체를 돌려주는 것보다는 차라리 괜찮다. null 처리 하지 않았다면 잘못된 일을 수행할 것이다.)
자기 메모리를 직접 관리 하는 객체는 늘 메모리 누수에 신경 써야 한다.
(element를 다 쓴 시점에 꼭 참조한 객체를 null 처리 해야 함)
위 예시에서도 스택은 자기 메모리를 직접 관리 하기 때문에 가비지 컬렉터는 스택에서 어떻게 메모리를 쓰고 있는 지 모른다.이로 인해 비활성 영역의 객체 값이 무의미한 메모리 사용이라는 것을 사람만 알뿐 GC는 몰라서 누수가 발생하는 것이다.
따라서 해결방법의 예시 처럼 프로그래머가 GC에게 직접 해당 객체를 사용하지 않는다고 알려야 한다.
그렇다고 필요 없는 객체를 볼 때마다 null 처리하면, 오히려 프로그램을 필요 이상으로 지저분하게 만든다.
객체 참조를 null처리하는 일은 예외적인 상황에서나 하는 것이지 평범한 일이 아니다.
Object pop() {
Object age = 24;
...
age = null; // X
}
위 코드 처럼 레퍼런스를 가리키는 변수를 특정한 범위(scope)안에서만 사용한다면 필요없는 객체 레퍼런스 정리는 자연스럽게 진행된다. (item 57 참조)
첫 예시처럼 null 처리를 해주지 않아도 해당 scope를 벗어나면 무의미한 레퍼런스 변수가 되기 때문에 GC에 의해 자동정리된다.
캐시를 사용할 때도 메모리 누수에 신경 써야 한다.
객체의 레퍼런스를 캐시에 넣어 놓고, 캐시를 비우는 것을 잊기 쉽다. 여러 가지 해결책이 있지만, 캐시의 키에 대한 레퍼런스가 캐시 밖에서 필요 없어지면 해당 엔트리를 캐시에서 자동으로 비워주는 WeakHashMap을 쓸 수 있다.
<문제상황 예시>
//객체를 다 쓴 뒤에도 key를 정리 하지 않음
public class CacheSample {
public static void main(String[] args) {
Object key = new Object();
Object value = new Object();
Map<Object, List> cache = new HashMap<>();
cache.put(key, value);
...
}
}
위의 문제 코드에서는 key의 사용이 없어지더라도 cache가 key의 레퍼런스를 가지고 있으므로, GC의 대상이 될 수 없다.
<해결방법>
//WeakHaspMap 사용하기
public class CacheSample {
public static void main(String[] args) {
Object key = new Object();
Object value = new Object();
Map<Object, List> cache = new WeakHashMap<>();
cache.put(key, value);
...
}
}
캐시 외부에서 key를 참조하는 동안만 엔트리가 살아있는 캐시가 필요하다면 WeakHashMap을 이용한다.
다 쓴 엔트리는 그 즉시 자동으로 제거된다. 단, WeakHashMap은 이런 상황에서만 유용하다.
캐시 값이 무의미해진다면 자동으로 처리해주는 WeakHashMap은 key 값을 모두 Weak 레퍼런스로 감싸 hard reference가 없어지면 GC의 대상이 된다.
즉, WeakHashMap을 사용할 때 key 레퍼런스가 쓸모 없어졌다면, (key - value) 엔트리를 GC의 대상이 되도록해 캐시에서 자동으로 비워준다.
콜백 역시 메모리 누수가 흔하게 발생할 수 있는 부분이다.
클라이언트 코드가 콜백을 등록할 수 있는 API를 만들고 콜백을 뺄 수 있는 방법을 제공하지 않는다면, 계속해서 콜백이 쌓이기만 할 것이다.
이것 역시 WeakHashMap을 사용해서 콜백을 Weak 레퍼런스로 저장하면 GC가 이를 즉시 수거해 해결할 수 있다.
ex) WeakHashMap에 키로 저장하기
🚩 결론
메모리 누수는 겉으로 잘 들어나지 않기 때문에 시스템에 오래도록 숨어있는 경우가 있다.
이런 누수는 철저한 코드 리뷰나 힙 프로파일러 같은 디버깅 도구를 동원해야만 발견되기도 한다.
발견이 어렵기 때문에 관련 예방법을 익혀두는 것이 좋다.