Effective Java - Item 7

Updated:

Effective Java

Item 7. 다 쓴 객체 참조를 해제하라

메모리 누수

자바는 C, C++와 달리 메모리를 직접 관리하지 않아도 Garbage Collection를 통해 손쉽게 메모리를 관리할 수 있다.
하지만, 자칫 메모리 관리에 더 이상 신경 쓰지 않아도 된다고 오해할 수 있는데, 오산이다.

직접 구현한 Stack의 메모리 누수

import java.util.Arrays;
import java.util.EmptyStackException;

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

특별한 문제는 없어보이지만, 숨어있는 문제가 존재한다.
바로 ‘메모리 누수’로, 이 스택을 사용하는 프로그램을 오래 실행하다 보면 점차 GC활동과 메모리 사용량이 늘어나 결국 성능이 저하될 것이다.
심할 때는 Disk Paging이나 OutOfMemoryError를 일으켜 프로그램이 예기치 않게 종료되기도 한다.

이 코드에서는 스택이 커졌다가 줄어들었을 때 스택에서 꺼내진 객체들을 GC가 회수하지 않는다.
프로그램에서 그 객체들을 더 이상 사용하지 않더라도 말이다. 이 스택이 그 객체들의 다 쓴 참조(obsolete reference)를 여전히 가지고 있기 때문이다.
여기서 다 쓴 참조란 문자 그대로 앞으로 다시 쓰지 않을 참조를 뜻한다.
앞의 코드에서는 elements 배열의 ‘활성 영역’밖의 참조들이 모두 여기에 해당한다. 활성 영역은 인덱스가 size보다 작은 원소들로 구성된다.

GC 언어에서는 (의도치 않게 객체를 살려두는)메모리 누수를 찾기가 매우 까다롭다.
객체 참조 하나를 살려두면 GC는 그 객체뿐이 아니라 그 객체가 참조하는 모든 객체 를 회수해가지 못한다.
따라서, 단 몇개의 객체가 매우 많은 객체를 회수되지 못하게 할 수 있고 잠재적으로 성능에 악영향을 줄 수 있다.

메모리 누수를 방지하자

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

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

실수로 사용하려 하면, 프로그램은 즉시 NullPointerException을 던지며 종료한다.

이 문제로 크게 데인 적이 있는 프로그래머는 모든 객체를 다 쓰자마자 일일히 null 처리를 하려 한다.
하지만 그럴 필요도 없고 바람직하지도 않다. 프로그램을 필요 이상으로 지저분하게 만들 뿐이다.
객체 참조를 null 처리하는 일은 예외적인 경우여야 한다.
다 쓴 참조를 해제하는 가장 좋은 방법은 그 참조를 담은 변수를 유효 범위(scope) 밖으로 밀어내는 것이다.
여러분이 변수의 범위를 최소가 되게 정의했다면(item 57) 이 일은 자연스럽게 이뤄진다.

Null 처리를 해야할 경우

위 Stack 클래스는 왜 null 처리를 해야할까?
이 스택은 자기 메모리를 직접 관리하는데, elements 배열의 비활성 영역은 현재 쓰이지 않는다.(size 밖의 영역)
문제는 GC는 이 사실을 알 길이 없다. GC가 볼 때에는 비활성 영역에서 참조하는 객체도 똑같이 유효한 객체라고 생각하므로,
프로그래머가 비활성 영역이 되는 순간 null 처리하여 해당 객체는 더는 쓰지 않을 것임을 가비지 컬렉터에 알려야 한다.

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

Cache의 메모리 누수

캐시 역시 메모리 누수를 일으키는 주법이다.
객체 잠조를 캐시에 넣고 나서, 이 사실을 까맣게 잊은 채 그 객체를 다 쓴 뒤로도 한참을 놔두는 일을 자주 접할 수 있다.
해법은 여러가지다.

  1. WeakHashMap 사용 캐시 외부에서 Key를 참조하는 동안만 엔트리가 살아 있는 캐시가 필요한 상황이라면 WeakHashMap을 사용한다.
    다 쓴 엔트리는 그 즉시 자동으로 제거된다.
  2. 엔트리의 가치를 떨어뜨리기 캐시를 만들 때 보통은 캐시 엔트리의 유효 기간을 정확히 정의하기 어렵기 때문에 시간이 지날수록 엔트리의 가치를 떨어뜨리는 방식을 사용한다.
    단, 이 방식에서는 쓰지 않는 엔트리를 이따금 청소해줘야 한다.
  3. Scheduled ThreadPoolExecutor와 같은 백그라운드 스레드 활용
  4. LinkedHashMap 위 클래스는 캐시에 새 엔트리를 추가할 때 부수 작업으로 removeEldestEntry 메서드를 사용하여 처리한다.

listener / callBack의 메모리 누수

클라이언트가 콜백을 등록만 하고 명확히 해지하지 않는다면, 뭔가 조치해주지 않는 한 콜백은 계속 쌓인다.
이럴때 콜백을 약한 참조(weak reference)로 저장하면 GC가 즉시 수거해간다.
WeakhashMap에 키로 저장하는 경우도 위와 같다.

정리

메모리 누수는 겉으로 잘 드러나지 않아 시스템에 수년간 잠복하는 사례도 있다.
이런 누수는 철저한 코드 리뷰나 힙 프로파일러 같은 디버깅 도구를 동원해야만 발견된다.
따라서 이런 종류의 문제는 예방법을 익혀두는 것이 매우 중요하다.

출처

Joshua Bloch. Effective Java 3/E. n.p.: 인사이트, 2018년 11월 1일.