Study/이펙티브 자바 / / 2023. 8. 20. 13:14

[Effective Java 3E] 다 쓴 객체 참조를 해제하라

💥 개요

C나 C++처럼 메모리를 직접 관리해야 하는 언어를 쓰다, 자바를 사용하면 가비지 컬렉터로 인해 훨씬 편안하게 메모리를 관리 할 수 있습니다. 다 쓴 객체를 GC가 알아서 회수해가니까 이런일이 가능합니다. 하지만 메모리 관리를 더 이상 하지 않아도 된다고 오해할 수 있는데, 절대 사실이 아닙니다.

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

특별히 문제는 없어 보입니다. 테스트도 잘 동작합니다. 하지만 문제가 하나 있습니다. 바로 '메모리 누수'입니다.

이 스택을 사용하는 프로그램을 오래 실행하다 보면 점차 GC의 활동과 메모리 사용량이 늘어나 성능이 저하됩니다. 

심하면 디스크 페이징이나 OOM을 일으켜 종료되기도 합니다.

그럼 어디서 메모리 누수가 일어날까요? 이 코드에서 스택이 커졌다 줄어들 때 스택에서 꺼내진 객체들을 GC가 회수하지 않습니다. 프로그램에서 더 해당 객체가 사용되지 않더라도 동일합니다. 이 스택이 그 객체의 다 쓴 참조(obsolete reference)를 여전히 가지고 있기 때문입니다.

 

🩻 문제

가비지 컬렉션을 지원하는 언어에서는 메모리 누수를 찾기가 까다롭습니다.
객체 참조(reference)를 하나 살려두면, 가비지 컬렉터는 그 객체 뿐만 아니라 그 객체 내에서 참조하고 있는 객체까지 회수 하지 못하는 문제가 발생하고 잠재적으로 성능에 악영향을 줄 수 있습니다.

 

💡 해결방법

    // 제대로 구현한 pop 메서드
    public Object pop() {
        if (size == 0)
            throw new EmptyStackException();
        Object result = elements[--size];
        elements[size] = null; // 다 쓴 참조 해제
        return result;
    }

다 쓴 참조를 null 처리하면 다른 이점도 따라옵니다. 만약 null 처리한 참조를 실수로 사용하려 하면 프로그램은 즉시 NPE를 던지게 됩니다. 하지만 모든 객체를 쓰자마자 null처리 할 필요는 없습니다. 바람직하지 않고 지저분한 코드가 되기 때문입니다. 객체 참조를 null처리하는 일은 예외적인 경우여야만 합니다.

다 쓴 참조를 해제하는 가장 좋은 방법은 그 참조를 담은 변수를 유효 범위(scope) 밖으로 밀어내는 것 입니다.

 

🎆 메모리 누수의 주범

class내 instance에 대한 참조를 관리하는 객체

클래스 내에서 자기 메모리를 관리 하는 객체라면(Stack 등) 항시 메모리 누수에 주의해야 하고, 이 방법을 통해 다 쓴 객체는 할당을 해제 하는 것이 옳습니다. 

 

캐시 역시 메모리 누수를 일으키는 주범입니다.

객체 참조를 클래스에 넣고, 이 사실을 잊은 채 그 객체를 다 쓴 뒤로도 한참을 놔두는 일을 자주 접할 수 있습니다. 해법은 여러가지인데 운 좋게 캐시 외부에서 키를 참조하는 동안만 엔트리가 살아있는 캐시가 필요한 상황이라면 WeakHashMap을 사용해 캐시를 만들면 됩니다. 다 쓴 엔트리는 그 즉시 자동으로 제거될 것 입니다. (이럴 때만 WeakHashMap가 유용)

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

 

리스너 혹은 콜백

클라이언트가 콜백을 등록만하고 해지하지 않으면 계속 쌓여만 갈 것입니다. 이럴때 콜백을 약한 참조(weak reference)로 저장하면 CG가 즉시 수거해 갑니다. 예를 들어 WeakHashMap의 키로 저장하면 됩니다.

 

 

 

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

 

❤️ 스터디 질답 정리

Q. WeakHashMap이 정확히 어떻게 동작하나요?
A. WeakReference인 객체를 key로 설정하고, value를 키로 주게되면 해당 key에 대한 참조가 사라지게 되면 바로 CG의 대상이 되고, weakHashMap에서 객체가 소멸하게 됩니다.
하지만 이 방법은 언제 참조가 사라지게 될 지 예측하기 힘든 경우가 많기 때문에 특정 상황에서 사용됩니다. 가장 좋은 메모리 누수 방지 방법은 
범위를 작게 잡고 범위 밖으로 밀어내게 되면 자연스럽게 해제되도록 하는 것 입니다.

Q. 엔트리가 정확히 뭘 말하는 건가요?
A. 해쉬맵에 관련된 것, 키벨류 한 쌍을 엔트리라고 한다.

Tip. 백기선님은 weakHashMap을 쓰지말라고 했다. 자원이 언제 반환해야 할지 애매한 경우가 많으니 차라리 null처리를 해라.

 

참고하면 좋은 글

https://recordsoflife.tistory.com/1304 

https://dejavuhyo.github.io/posts/java-weakhashmap/

 

  • 네이버 블로그 공유
  • 네이버 밴드 공유
  • 페이스북 공유
  • 카카오스토리 공유