[이펙티브 자바] 7. 다 쓴 객체 참조를 해제하라

 

1. 메모리 관리

자바에선 가비지 컬렉터가 다 쓴 객체를 알아서 회수해 가기에 편리하고 효율적으로 메모리를 관리할 수 있다. 하지만 메모리 관리에 신경 쓰지 않아도 된다는 말은 절대 아니다. 메모리를 적절하게 관리하지 못하면 메모리 누수가 발생하고 심하면 프로그램이 종료될 수 있다.

 

메모리를 적절하게 관리하지 못하는 경우의 예제를 살펴보자. 다음은 스택을 간단하게 구현한 자바 코드이다.

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

이대로 사용하여도 기능상으로는 전혀 문제가 없을 것이고, 어떤 테스트도 훌륭하게 통과하겠지만, 이 스택을 사용하는 프로그램을 오래 실행시킬 경우 가비지 컬렉션과 메모리 사용량이 늘어나 결국 성능이 저하될 것이다.

 

정확히 문제가 되는 부분은 pop 메서드이다. 스택이 커졌다가 줄어들었을때, 스택에서 꺼내진 객체들을 가비지 컬렉터가 회수하지 않는다. 사용하지 않는 객체라고 하더라도 스택이 객체들의 다 쓴(앞으로 사용되지 않을) 참조를 여전히 가지고 있기 때문이다.

 

이처럼 가비지 컬렉션을 지원하는 언어에서 메모리 누수를 찾기는 아주 까다롭다. 객체 참조 하나를 살려두면 가비지 컬렉터는 그 객체뿐 아니라 그 객체가 참조하는 모든 객체 (줄줄이 참조하는 객체들)를 모두 회수하지 못한다. 그렇기에 몇 개의 객체라도, 물려있는 많은 객체들을 회수하지 못하게 할 수 있고 성능에 잠재적으로 안 좋은 영향을 줄  수 있다.

2. 객체 참조 해제

객체의 참조를 해제하는 방법은 간단하다. 해당 참조를 다썼을때 null 처리를 하여 참조해제를 하면 된다. 위 예제의 스택에서 각 원소의 참조가 더 이상 필요 없어지는 지점은 스택에서 꺼내질 때(pop)이다. 사용이 종료될 때 참조 해제를 추가한 customPop메서드를 확인해 보자.

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

다음과 같이 다시 사용될 일 없는 참조를 null 처리하면 되고, 이 경우 실수로 사용하게 경우도 NullPointException으로 사전에 핸들링 가능하다. 다만 책에서는 모든 미사용 객체를 찾아서 Null 처리하는 것은 필요 없고 바람직하지 않다고 설명한다, 성능대비 프로그램을 필요이상으로 지저분하게 할 뿐이라 예외적인 경우에만 Null 처리로 객체의 참조를 해제하는 것을 권장한다.

 

책에서 설명하는 다 쓴 참조를 해제하는 가장 좋은 방법은 그 참조를 담은 변수를 범위(scope) 밖으로 밀어내는 것이다. 만약 변수의 범위를 최소가 되게 정의했다면 자연스럽게 객체가 참조해제 처리될 것이다. (Scope를 조절한다는 것은 변수가 선언된 블록(메서드, 조건문, 반복문 등)을 벗어나게 하면서 자동으로 가비지컬렉션의 대상이 되게 하는 것을 의미한다.)

public void scope() {
    {
        // SCOPE A 시작
        int a = 10; // 'a' 변수는 이 블록 안에서만 유효
        System.out.println(a); // 블록 A 끝 - 여기를 벗어나는 순간 'a'는 더 이상 접근할 수 없음
    }
    //System.out.println(a); // 여기서 'a'를 사용하려고 하면 scope에서 벗어나기에 컴파일 에러 발생
    {
        // 블록 B 시작 - 이 블록에서는 'a'를 새로 선언할 수 있지만, 위의 'a'와는 전혀 다른 변수임
        String a = "Hello";
        System.out.println(a); // 블록 B 끝 - 여기를 벗어나는 순간 새로 선언된 'a'도 접근할 수 없게 됨
    }
}

그럼 Null처리를 해야 하는 예외적은 상황은 언제일까? 위 예제의 Stack 클래스는 왜 메모리 누수에 취약할지를 생각해 보면, 바로 스택이 자기 메모리를 직접 관리하기 때문이다. 예제의 스택은 elements 배열의 자체 저장소 풀을 만들어 원소를 관리하기에 가비지 컬렉터가 알 수 없는 행위들이 일어난다. 즉 자기 메모리를 직접 관리하는 클래스라면 메모리 누수를 항상 주의해야 하고 원소를 다 쓴 즉시 참조한 객체들을 모두 null 처리해줘야 한다.

3. 메모리 누수

3-1. 캐싱

캐시 역시 메모리 누수를 일으키는 주범이다. 객체 참조를 캐시에 넣고 객체를 다 쓴 뒤에도 객체 참조를 캐시에 보관하고 있을 때, 캐시가 제거되지 않으면 메모리를 계속 점유하여 메모리 누수가 발생할 수 있다. 만약 캐시외부에서 키를 참조하는 동안만 앤트리가 살아있는 캐시가 필요한 상황이면 WeakHashMap 사용해서 캐시를 만드는 것이 좋다.

WeakHashMap - java.util의 Map 인터페이스 구현체 중 하나로, 약한 참조로 저장되어 가비지 컬렉터가 해당 키에 다른 참조가 없을 때 언제든지 회수를 진행한다. 즉, 저장된 앤트리는 키에 대한 강한 참조가 캐시 외부에서 사라지면 자동으로 제거될 수 있다.
public void weakHashMap() {
    WeakHashMap<Object, String> cache = new WeakHashMap<>();
    Object key = new Object(); // 이 객체는 키로 사용됨
    cache.put(key, "Value"); // 키와 값 쌍을 캐시에 저장

    key = null; // 이제 'key' 객체에 대한 강한 참조가 없음
}

보통 캐시를 생성 시에 캐시의 유효기간을 정확히 정의하기 힘들기에 시간이 갈수록 앤트리의 가치를 낮추는 방식을 흔히 사용한다. 그렇기에 주기적으로 안 쓰는 앤트리를 제거해 주어야 한다. 백그라운드 스레드를 활용하여 캐시에 새 엔트리를 추가할 때마다 부수작업으로 진행하기도 하고 LinkedHashMap을 사용할 경우 removeEldestEntry 메서드를 써서 처리하기도 한다. 만약 더 복잡한 캐시를 만들고 싶다면 java.lang.ref 패키지를 활용하여 직접 생성도 할 수 있다. java.lang.ref 패키지의 Reference 유형을 확인해 보면 다음과 같다.

  • SoftReference : 메모리가 부족한 시점까지 GC에 의해 회수되지 않지만 메모리가 부족하면 회수된다. 메모리에 민감하지 않은 캐시에 적합
  • WeakReference : 강한 참조가 없을 때 언제든지 GC에 회수될 수 있다 (WeakHashMap에 사용됨)
  • PhantomReference : GC가 해당 객체를 처리하기 직전까지는 프로그램 코드에서 직접 참조할 수 없다. 보통 리소스를 안전하게 해제하거나 객체가 가비지컬렉션 되기 전에 특별한 작업을 수행할 때 사용된다.

3-2. 리스너(listener)와 콜백(callback)

메모리 누수의 세 번째는 리스터, 콜백이다. 클라이언트가 콜백을 등록만 하고 명확히 해지하지 않는다면 콜백은 쌓여만 갈 것이다. 이럴 때 WeakHashMap 같은 약한 참조를 사용하여 콜백을 저장하면 가비지 컬렉터가 즉시 수거해 가기에 메모리 누수를 방지할 수 있다..

4. 정리

 메모리 누수는 겉으로 잘 드러나지 않지 않아서 철저한 코드리뷰나 힙 프로파일러 같은 디버깅 도구를 동원해야 하기에 예방법을 알아두는 것이 좋다

 

메모리를 직접 관리하지 않아도 어느 정도 가비지 컬렉터가 메모리를 관리해 주지만, 분명 한계인 부분이 있다. 문제없이 돌아가는 프로그램도 메모리 누수 현상이 숨어 있을 수 있고, 오래 실행할 경우 치명적인 문제로 이어질 수 있음을 인지하고 객체의 참조를 해제하는 올바른 예방법을 고려하며 개발하는 것이 중요하다.

 

 

 

예제 코드

https://github.com/junhkang/effective-java-summary/tree/master/src/main/java/org/example/ch01/item07/codes