Study/Effective Java

[아이템07] 다 쓴 객체 참조를 해제하라

개발개발개발 2022. 9. 30. 15:00

Java는 C/C++ 언어와는 달리 가비지 컬렉터가 메모리를 관리하고 있다. 하지만 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];
    }

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

 

위 코드는 스택을 구현한 코드로 얼핏 봤을 때는 별다른 문제는 없어 보인다. 하지만 위 코드에서는 메모리 누수가 발생하고 있다. 이 스택을 사용하는 프로그램을 오래 실행하다 보면 점차 가비지 컬렉션과 메모리 사용량이 늘어나 성능이 저하될 것이다. 

 

 

그렇다면, 이 코드에서 메모리 누수가 일어나는 곳은 어디일까? 

이 코드에서는 스택이 커졌다가 줄어들 때, 스택에서 꺼낸 객체들을 가비지 컬렉터가 회수하지 않는다. 이 스택이 그 객체들의 다 쓴 참조를 여전히 가지고 있기 때문이다. 여기서 다 쓴 참조란 다시 쓰지 않을 참조를 뜻한다. 


해결책

간단하다. 해당 참조를 다 썼을 때 null 처리(참조 해제) 하면 된다. 

예시의 스택 클래스에서는 스택에서 꺼내질 때, 참조가 더 이상 필요 없어지는 시점이다. 

 

 

다음은 pop 메서드를 제대로 구현한 모습이다. 

public Object pop() {
    if(size ==  0)
        throw new EmptyStackException();
    Object result = elements[--size];
    elements[size] = null; // 다 쓴 참조 해재
    return elements[--size];
}

 

다 쓴 참조를 null 처리하면 다른 이점도 따라온다. 만약 null 처리한 참조를 실수로 사용하려 하면 프로그램은 즉시 NullPointerException을 던지며 종료한다. 그렇다고해서 모든 객체를 쓰자마자 null 처리하지 않아도 된다. 코드를 지저분하게 만들 뿐이다. 

객체 참조를 null 처리하는 일은 예외적인 경우여야 한다. 다 쓴 참조를 해제하는 가장 좋은 방법은 해당 변수를 유효 범위 밖으로 밀어내는 것이다. 

 

 

일반적으로 자기 메모리를 직접 관리하는 클래스라면 프로그래머는 항시 메모리 누수에 주의해야 한다. 원소를 다 사용한 즉시 그 원소가 참조한 객체들을 다 null 처리해줘야 한다. 

 

 

메모리 누수의 또 다른 주범 : 캐시

객체 참조를 캐시에 넣고, 그 객체를 다 쓴 뒤로도 한참을 그냥 놔두는 일을 접할 수 있다. 해법은 여러가지가 있다. 운 좋게 캐시 외부에서 키를 참조하는 동안 엔트리가 살아 있는 캐시가 필요한 상황이라면 WeakHashMap을 사용해 캐시를 만들자. 다 쓴 엔트리는 그 즉시 자동으로 제거될 것이다. 

캐시를 만들 때 보통은 캐시 엔트리의 유효 기간을 정확히 정의하기 어렵다. 때문에 시간이 지날수록 엔트리의 가치를 떨어뜨리는 방식을 사용한다. 이런 방식에서는 ScheduledTreadPoolExecutor와 같이 엔트리를 청소해야 한다. 

 

메모리 누수의 세 번째 주범 : 리스너 혹은 콜백

클라이언트가 콜백을 등록만 하고 명확히 해지하지 않는다면, 콜백은 계속 쌓여갈 것이다. 이럴 때 콜백을 약한 참조로 저장하면 가비지 컬렉터가 즉시 수거해간다. 

EX) WeakHashMap

 


마무리

가바지 컬렉션 언어에서는 메모리 누수를 찾기가 아주 어렵다. 객체 참조 하나를 살려두면 가비지 컬렉터는 그 객체뿐 아니라 그 객체가 참조하는 모든 객체를 회수해가지 못한다. 

이런 누수는 철저한 코드 리뷰나 힙 프로파일러 같은 디버깅 도구를 동원해야 발견되기도 한다. 이런 종류의 문제는 예방법을 미리 익혀두는 것이 매우 중요하다.