티스토리 뷰

Java의 Garbage Collector는 다 쓴 객체를 알아서 회수해줍니다. 그래서 자칫 메모리 관리를 더 이상 신경쓰지 않아도 된다고 오해할 수 있는데, 이는 사실이 아닙니다. 스택 자료구조를 간단히 구현한 아래의 코드를 보겠습니다.

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];
    }

    /*
        원소를 위한 공간을 적어도 하나 이상 확보합니다.
        배열 크기를 늘려야 할 때마다 2배씩 증가시킵니다.
     */
    private void ensureCapacity() {
        if(elements.length == size) {
            elements = Arrays.copyOf(elements, 2 * size + 1);
        }
    }
}

특별한 문제는 없어 보이는 코드입니다. 하지만, 테스트 코드나 눈으로는 찾을 수 없는 치명적인 문제가 존재하는데요, '메모리 누수'가 발생할 수 있는 코드입니다. 따라서 이 스택을 사용하는 프로그램을 오래 실행하다 보면, 점차 가비지 컬렉션 활동과 메모리 사용량이 늘어나 성능이 저하될 수 있습니다.

그러면, 위의 코드에서 메모리 누수는 어디서 일어날까요?

public Object pop() {
    if (size == 0) throw new EmptyStackException();
    return elements[--size];
}

 

바로 이 부분입니다. 위의 코드에서 스택이 커졌다가 줄어들때, 스택에서 꺼내진 객체들은 가비지 컬렉터가 회수하지 않습니다. 
코드를 분석해보면, size라는 변수를 통해 elements 배열에 접근하고 있습니다. pop 코드는 size를 변경할 뿐, 별다른 조치를 취하지 않고 있습니다. 따라서 Stack 클래스는 프로그램에서 실제로 elements[size]를 사용하지 않더라도 객체를 가지고 있습니다. 그리고 이를 '다 쓴 참조(obsolete reference)'라고 부릅니다. 

가비지 컬렉터는 메모리 누수를 찾기가 까다롭습니다.
객체 참조 하나를 살려두면, 가비지 컬렉터는 그 객체뿐 아니라 그 객체가 참조하는 모든 객체를 회수하지 못합니다. 따라서 단 몇개의 객체로 인해 매우 많은 객체를 회수하지 못할 수 있고, 이는 성능에 영향을 줄 수 있습니다.

해법은 아주 간단합니다. 아래와 같이 해당 참조를 다 썼을때, null 처리를 하면 됩니다.

public Object pop() {
    if (size == 0) throw new EmptyStackException();
    Object result = elements[--size];
    elements[size] = null;
    return result;
}

실제로 자바에서 제공하는 Stack 클래스를 확인해봐도 위와 같은 로직을 사용하고 있습니다.

그러면, 모든 객체를 다 쓰자마자 null 처리를 하면 될까요?
음.. 물론 가능은 하겠지만, 프로그램을 필요 이상으로 지저분하게 만들 수 있기 때문에 바람직한 방법은 아닙니다. 더 개선된 방법은 참조를 담은 변수를 유효범위(scope) 밖으로 밀어내는 방법입니다. 이 내용은 Item 57에서 다시 등장하기에 나중에 만나도록 합시다.

public static void main(String[] args) {
    Map<Object, Object> cache = new HashMap<>();
    Object key = new Object();
    Object value = new Object();

    cache.put(key, value);

}

위에서 사용한 코드와 같이 HashMap을 캐시로 사용하는 것은 역시 메모리 누수를 일으키는 주범이 될 수 있습니다. 
만약 등록한 key의 사용이 끝나더라도 cache map이 아직 key reference를 참조하고 있으므로 GC의 대상이 아니고, 여전히 불필요한 메모리 영역을 차지할 수 있습니다. 이 문제를 해결하기 위해 WeakHashMap의 사용을 고려해볼 수 있습니다.

public static void main(String[] args) {
    Map<Object, Object> cache = new WeakHashMap<>();
    Object key = new Object();
    Object value = new Object();

    cache.put(key, value);

}

WeakHashMap는 key 레퍼런스를 사용하지 않으면, [key - value]를 GC 대상으로 만들어 자동으로 캐시에서 사라지도록 만들어줍니다.

메모리 누수의 세번째 주범은 '리스너(listener)' 혹은 '콜백(callback)'입니다. 
callback을 등록하고, callback의 메모리를 해제하지 않으면, 계속해서 메모리에 callback이 쌓일 것입니다. 이문제도 WeakHashMap을 사용해서 해결할 수 있는데, WeakHashMap에 callback을 저장하면 '약한 참조(weak reference)'로 저장하게 되고, GC가 이를 즉시 수거해 할당된 메모리를 해제할 수 있습니다.

그러면 방금 언급한 '약한 참조(weak reference)'는 무엇을 말하는 것일까요?

일반적으로 객체를 생성할 때 new()를 사용합니다. 이렇게 생성된 객체를 Strong Reference라 부르며 GC는 이러한 객체의 참조가 없어질 때 까지 객체의 메모리를 유지합니다. 반면 Weak Reference는 GC가 발생하면 무조건 할당된 메모리가 해제되는 특징을 가집니다.
따라서 Weak Reference를 활용하면 캐시나 callback이 할당받은 메모리를 빠르게 정리할 수 있고 메모리 누수를 방지할 수 있습니다.

Ref : 이펙티브 자바 Effective Java 3/E

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/11   »
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 30
글 보관함