아이템 7. 다 쓴 객체 참조를 해제하라. - ksw6169/effective-java GitHub Wiki

개요

  • 가비지 컬렉터를 갖춘 언어를 사용하다보면 자칫 메모리 관리에 신경쓰지 않아도 된다고 오해할 수 있는데 이는 절대 사실이 아니다.

예제 - 메모리 누수가 발생하는 스택

  • 아래의 스택을 사용하는 프로그램을 오래 실행하다보면 메모리 누수로 인해 점차 가비지 컬렉션 활동과 메모리 사용량이 늘어나 성능이 저하될 것이다. 심한 경우에는 디스크 페이징이나 OutOfMemoryError 가 발생해 프로그램이 예기치 않게 종료될 수도 있다.
  • 이 코드에서 메모리 누수가 발생하는 이유는 스택이 커졌다가 줄어들었을 때 스택에서 꺼내진 객체들을 가비지 컬렉터가 회수하지 않기 때문이다. 아래 코드에서는 객체들을 더이상 사용하지 않더라도 스택에서 객체들의 다 쓴 참조(obsolete reference)를 여전히 가지고 있다. (여기서 다 쓴 참조란 앞으로 다시 쓰지 않을 참조를 뜻한다.)
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;
    }
    
    // 스택의 크기가 줄어들 때 객체들의 다 쓴 참조(element)를 여전히 가지고 있다.
    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 처리(참조 해제) 하면 된다.
public Object pop() {
    if (size == 0)
        throw new EmptyStackException();
    Object result = elements[--size];
    elements[size] = null;    // 다 쓴 참조 해제
    return result;
}
  • 다 쓴 참조를 null 처리하면 null 처리한 참조를 실수로 사용할 때 NPE가 발생하므로 오류를 조기에 발견할 수 있다는 장점도 생긴다.

주의사항

  • 객체 참조를 null 처리하는 일은 예외적인 경우여야 한다. 모든 객체를 다 쓰자마자 일일이 null 처리하는 것은 프로그램을 필요 이상으로 지저분하게 만든다.
  • 다 쓴 참조를 해제하는 가장 좋은 방법은 변수의 범위를 최소로 정의해 그 참조를 담은 변수가 유효 범위(scope) 밖에서 자연스럽게 가비지 컬렉션의 대상이 되도록 만드는 것이다.
  • 일반적으로 자기 메모리를 직접 관리하는 클래스라면 프로그래머는 항시 메모리 누수에 주의해야 한다. 원소를 다 사용한 즉시 그 원소가 참조한 객체들을 다 null 처리해줘야 한다.

메모리 누수를 일으키는 주범 - (1) 캐시

  • 객체 참조를 캐시에 넣고 나서 그 객체를 다 쓴 뒤로도 그냥 놔두는 경우 메모리 누수가 발생한다. (HashMap은 key와 value가 put 되면 사용 여부와 관계 없이 해당 내용을 삭제하지 않는다.)
public class CacheService {

    private static Map<String, Object> cache = new HashMap<>();

    public static void put(String key, Object object) {
        cache.put(key, object);
    }
}
  • 캐시 외부에서 키를 참조하는 동안만 엔트리가 유지되도록 하고자 한다면 *WeakHashMap 을 사용해 캐시를 만들자. 다 쓴 엔트리는 그 즉시 자동으로 제거될 것이다.

  • 캐시를 만들 때 보통은 캐시 엔트리의 유효 기간을 정확히 정의하기 어렵기 때문에 시간이 지날수록 엔트리의 가치를 떨어뜨리는 방식을 흔히 사용한다. 이런 방식에서는 쓰지 않는 엔트리를 이따금 청소해줘야 한다. ScheduledThreadPoolExecutor 같은 백그라운드 스레드를 활용하거나 캐시에 새 엔트리를 추가할 때 부수 작업으로 수행하는 방법이 있다.

  • LinkedHashMapremoveEldestEntry() 를 사용해서 후자의 방식으로 처리한다.

WeakHashMap

약한 참조의 특성을 이용하여 key에 해당하는 객체가 더이상 사용되지 않는다고 판단되면 HashMap의 Element를 자동으로 제거(GC) 해버린다.

LinkedHashMap.removeEldestEntry()

순서가 있는 HashMap으로 LinkedHashMap은 가장 먼저 들어온 Entry를 알고 있다. Map에 Entry가 쌓여서 일정 size를 경과하려고 하면 새로운 entry를 put하려고 할 때 해당 entry를 제거하고, 그 위치에 새로운 entry를 put 하는 방식으로 동작한다.

메모리 누수를 일으키는 주범 - (2) 리스너 혹은 콜백

  • 클라이언트가 콜백을 등록만 하고 명확히 해지하지 않는다면 콜백은 계속 쌓여갈 것이다.
  • 이럴 때 콜백을 약한 참조(weak reference)로 저장하면 가비지 컬렉터가 즉시 수거해간다.
  • 예를 들어 WeakHashMap 에 키로 저장하면 된다.

참고 자료