자바 같이 가비지 컬렉터를 갖춘 언어로 넘어오면 다 쓴 객체를 알아서 회수해가니
아 그럼 메모리 관리는 이제 신경 안써도 되겠구나~
→ 이러면 큰일남니다!!
치명적인 메모리 누수 예시
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);
}
}
Java
복사
간단한 Stack 클래스
신입 개발자(시최): 별 다른 문제가 없어 보인다..!
신입 개발자(시최): 이대로 올려야지~
화가난 부장님
부장님(사난): 오이오이 지금 장난해?! 저기 누수나자나!!!
부장님(사난): 이거 잘못하면 디스크 페이징이나 OutOfMemoryError 발생한다구!
신입 개발자(시최): 허거덩?! 뭐가 문제에요??
부장님(사난): 에헤이 딱 보니까 저거저 스택에서 pop돼도 다 쓴 참조(obsolete reference)를 가져서 가비지 컬렉터가 회수 안하겠구만~ 공부 좀 열심히 해!! 안그럼 짜른다!
신입 개발자(시최): 넴.. (다 쓴 참조가 모지…?)
여기서 다 쓴 참조란, 문자 그대로 앞으로 다시 쓰지 않을 참조를 말한다.
앞의 코드에서 elements 배열의 활성 영역 밖의 참조들을 다 쓴 참조에 해당한다.
활성 영역은 인덱스가 size보다 작은 원소들로 구성된다.
가비지 컬렉션 언어에서 이와같이 프로그래머의 의도와 다르게 객체를 살려두게 되버리는 메모리 누수를 찾기 매우 까다롭다.
객체 참조를 하나라도 살려두게 된다면 가비지 컬렉터는 그 객체 뿐만 아니라 그 객체가 참조하고 있는 모든 객체(+ 이 객체가 참조하고 있는 모든 객체 … 또 이 객체가 참조하고 있 … 또 이 객체가 참 … 또 이 객… 또 이 ㄱ … ㄸ…)를 회수하지 못한다.
public Object pop() {
if (size == 0)
throw new EmptyStackException();
Object result = elements[--size];
elements[size] = null; // 다 쓴 참조 해제
return result;
}
Java
복사
위 상황에서의 해결법
해결법은 의외로 간단하다. 다 쓴 참조를 null로 초기화하기만 하면 된다.
다 쓴 참조를 null로 초기화해주면 null 처리한 참조를 실수로 사용하려고 시도하면 NullPointerException을 던지게 되므로 프로그램의 오류를 조기에 발견할 수 있어 이런 측면에서도 이점을 갖는다.
null 처리는 언제 해야하는걸까? 왜 우리가 만든 저 Stack 클래스는 메모리 누수에 취약한 걸까?
사실 위 Stack 클래스 예제는 특이 케이스다.
객체 자체가 아니라 객체 참조를 담는 elements 배열로 저장소 풀을 만들어 원소를 관리하기 때문에 배열의 활성 영역에 속한 원소들이 사용되고 비활성 영역은 안 쓰인다.
그런데 이 사실을 가비지 컬렉터는 알 수가 없다. 가비지 컬렉터 입장에서는 비활성 영영에 속한 객체들도 똑같이 유효한 객체로 인식한다. (우리 가비지 컬렉터는 잘못 업어요 ㅠㅠ)
그렇기 때문에 자기 메모리를 직접 관리하는 클래스는 프로그래머가 메모리 누수에 주의해야 한다. 이때는 원소를 사용한 후 그 윈소가 참조한 객체를 다 null로 초기화 시켜주어야 한다.
객체 풀을 관리하려고 시도한 순간부터 이 메모리를 관리할 책임을 일정 부분 프로그래머가 갖게 되는 것이다.
메모리 누수의 주범
캐시
객체 참조를 캐시에 넣어두고 깜빡해버려서 객체를 다 쓴 후로도 한참을 그냥 놔두는 일을 자주 경험할 수 있을 것이다.
해법은 여러가진데 그 중에 하나가 외부에서 키를 참조하는 동안만, 엔트리가 살아있는 캐시가 필요한 상황이라면, WeakHashMap을 사용해 캐시를 만들어 해결할 수 있다.
엔트리를 다 쓰게되면 그 즉시 회수될 것이다.
그런데 캐시를 만들 때 캐시 엔트리의 유효 기간을 정확히 정의하는 것이 어렵다.
그래서 시간이 지날수록 엔트리의 가치를 떨어뜨리는 방식을 흔히 사용한다.
시간이 지날수록 엔트리의 가치를 떨어뜨리는 방식
이런 방식에서는 쓰지 않는 엔트리를 이따금씩 청소해줘야 하는데, 백그라운드 스레드를 활용하거나 캐시에 새 엔트리를 추가할 때 수행하는 방법이 있다.
LinkedHashMap을 사용하면 캐시에 새 엔트리가 추가될 때, 엔트리 청소를 수행하도록 할 수 있다.
콜백
클라이언트가 콜백만 등록해두고 명확히 해지하지 않는다면, 콜백이 계속 쌓일 것이다.
이럴 때 콜백이 weak reference(약한 참조)로 저장하면 가비지 컬렉터가 즉시 수거해간다.
ex. WeakHashMap에 키로 저장
Weak Reference와 Strong Reference
저자의 핵심 정리
메모리 누수는 겉으로 잘 드러나지 않아 시스템에 수년간 잠복하는 사례도 있다.
이런 누수는 절저한 코드 리뷰나 힙 프로파일러 같은 디버깅 도구를 동원해야만 발견되기도 한다.
그래서 이런 종류의 문제는 예방법을 익혀두는 것이 매우 중요하다.