본문 바로가기
책/effective java

[effective java] item 7. 다 쓴 객체 참조를 해제하라

by 2245 2023. 7. 25.

결론

메모리 누수는 겉으로 잘 드러나지 않아 시스템에 수년간 잠복하는 사례도 있습니다. 
다음과 같은 경우, 다 쓴 객체 참조를 명시적으로 해제하여 메모리 누수를 방지해야 합니다.

1. 메모리를 직접 관리하는 클래스 (ex) Stack)
2. 캐시에 저장된 객체 참조
3. 리스너(listener) 혹은 콜백(callback) 의 해제 

 

 

 


설명

C, C++과 달리 자바와 같이 가비지 컬렉터가 메모리 관리를 해주는 경우, 자칫 메모리 관리에 더 이상 신경을 쓰지 않아도 된다고 오해할 수 있지만, 반드시 프로그래머가 직접적으로 참조를 해제해야 하는 경우가 있습니다.

 

예제 1:  메모리를 직접 관리하는 클래스

스택을 간단히 구현한 다음 코드를 봅시다.

 

Stack - 메모리 누수 발생 (p. 36)

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() 메서드 : '메모리 누수' 발생

  • 스택에서 객체를 꺼낼 때, 해당 객체는 프로그램에서 더 이상 사용하지 않지만 가비지 컬렉터가 자원을 회수하지 않습니다. 
  • 스택이 그 객체들의 다 쓴 참조(obsolete reference)를 여전히 가지고 있기 때문입니다. (여기서 다 쓴 참조란 문자 그대로 앞으로 다시 쓰지 않을 참조를 뜻합니다.)
  • elements 배열의 '활성 영역' 밖의 참조들이 모두 여기에 해당합니다. (활성 영역은 인덱스가 size보다 작은 원소들로 구성됩니다.)

 

'메모리 누수'의 문제점

  • 가비지 컬렉션 언어에서는 (의도치 않게 객체를 살려두는) 메모리 누수를 찾기가 아주 까다롭습니다. 
  • 객체 참조 하나를 살려두면 가비지 컬렉터는 그 객체뿐만 아니라 그 객체가 참조하는 모든 객체(그리고 또 그 객체들이 참조하는 모든 객체...)를 회수해가지 못합니다. 그래서 단 몇 개의 객체가 매우 많은 객체를 회수되지 못하게 할 수 있고 잠재적으로 성능에 악영향을 줄 수 있습니다. 
  • 이 스택을 사용하는 프로그램 또한 오래 사용하다 보면 점차 가비지 컬렉션 활동과 메모리 사용량이 늘어나 결국 성능이 저하될 것입니다. 
  • 상대적으로 드문 경우긴 하지만, 심할 때는 디스크 페이징이나 OutOfMemoryError를 일으켜 프로그램이 예기지 않게 종료되기도 합니다.

 

해결책: 다 쓴 객체 참조 null 처리 

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

Stack - 다 쓴 객체 참조 해제 (p. 37)

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

장점

만약 null 처리한 참조를 실수로 사용하려 하면, 프로그램은 즉시 NullPointerException을 던지며 종료됩니다.

미리 null 처리하지 않았다면 아무 내색없이 무언가 잘못된 일을 수행할 수 있습니다. 프로그램 오류는 가능한 한 조기에 발견하는 게 좋습니다. 

 

모든 객체는 다 쓰면 일일이 null 처리 해야 한다?

이 문제로 크게 데인 적이 있는 프로그래머는 모든 객체를 다 쓰자마자 일일이 null 처리하는 데 혈안이 되기도 합니다.

하지만 프로그램을 필요 이상으로 지저분하게 만들 뿐 그럴 필요도 없고 바람직하지도 않습니다. 

 

객체 참조를 null 처리하는 경우는 예외적인 경우여야 합니다.

 

null 처리는 예외적으로, 자기 메모리를 직접 관리하는 클래스에 실행한다.

현재 스택은 (객체 참조를 담는) elements 배열로 저장소 풀을 만들어 원소들을 관리합니다.

배열의 활성 영역에 속한 원소들이 사용되고 비활성 영역은 쓰이지 않습니다.

 

문제는 가비지 컬렉터는 이 사실을 알 길이 없다는 데 있습니다. 가비지 컬렉터가 보기에는 비활성 영역에서 참조하는 객체도 똑같이 유효한 객체입니다.

비활성 영역의 객체가 더 이상 쓸모없다는 건 프로그래머만 아는 사실입니다.

 

그러므로 프로그래머는 비활성 영역이 되는 순간 null 처리해서 해당 객체를 더는 쓰지 않을 것임을 가비지 컬렉터에게 알려야 합니다. 

 

 

예제 2: 캐시에 저장된 객체 참조

객체 참조를 캐시에 넣고 나서, 이 사실을 까맣게 잊은 채 그 객채를 다 쓴 뒤로도 한참을 그냥 놔두는 일을 자주 접할 수 있습니다.

참고 객체 참조 (Object Reference)
프로그래밍 언어에서 객체는 메모리에 할당된 데이터를 의미합니다. 객체 참조는 해당 객체가 저장된 메모리 주소를 가리키는 값이며, 이를 통해 프로그램은 객체 접근하고 조작할 수 있습니다. 
참고 캐시에 객체 참조를 넣는다?
자주 사용되는 객체를 캐시에 저장하여 빠른 접근이 가능하도록 하는 것을 의미합니다. 이는 프로그램 실행 중에 같은 객체에 여러 번 접근해야할 때 유용합니다. 객체를 캐시에 넣으면 메모리나 데이터베이스 등과 같이 느린 데이터 저장소로부터 데이터를 다시 불러오는 시간을 절약할 수 있습니다. 

 

해결책 1. Weak Reference 캐시 

만약, 캐시 외부에서 키(key)를 참조하는 동안만 엔트리가 살아있는 Weak Reference(약한 참조) 캐시가 필요한 상황이라면,  weakHashMap을 사용해 캐시를 만듦으로써 해결할 수 있습니다.

더 이상 외부에서 참조를 하지 않는 (Strong Reference (강한 참조)를 가지고 있지 않는) 엔트리는 그 즉시 자동으로 GC(Garbage Collector)가 해당 객체를 캐시에서 제거합니다. 

 

단, WeakHashMap 은 이러한 메모리 관리 측면에서만 유용합니다.이로 인해 캐시에 저장된 데이터가 임시적이고 빈번하게 변경되는 경우에 주로 사용됩니다. 

Weak Reference 캐시의 단점은 캐시에 접근할 때마다 해당 객체의 유효성을 확인해야 하는 오버헤드가 발생할 수 있다는 점과 Weak Reference로 인해 데이터를 잃어버릴 가능성이 있다는 단점이 있습니다. 

 

참고 엔트리(Entry)
캐시의 엔트리는 캐시 메모리에 저장되는 개별적인 데이터 단위를 말합니다. 캐시에 저장되는 정보들은 이러한 엔트리들의 집합으로 구성됩니다. 

캐시는 일반적으로 특정 크기와 구조를 갖는 고정된 메모리 영역이며, 그 크기에 따라 저장 가능한 엔트리의 개수가 결정됩니다. 각 엔트리는 고유한 식별자(주로 메모리 주소 또는 키)를 갖고 있으며, 이를 통해 해당 엔트리를 식별하고 접근할 수 있습니다. 

 

 

해결책 2. 엔트리 청소

캐시를 만들 때 보통은 캐시 엔트리의 유효 기간을 정확히 정의하기 어렵기 때문에 시간이 지날수록 엔트리의 가치를 떨어트리는 방식을 흔히 사용합니다. 이런 방식에서는 쓰지 않는 엔트리를 이따금 청소해줘야 합니다.

(Scheduled ThreadPoolExecutor 같은) 백그라운드 스레드를 활용하거나, 캐시에 새 엔트리를 추가할 때 부수 작업으로 수행하는 방법이 있습니다.

LinkedHashMap은 removeEldestEntry 메서드를 써서 후자의 방식으로 처리합니다.

더 복잡한 캐시를 만들고 싶다면 java.lang.ref 패키지를 직접 활용해야 할 것입니다. 

 

 

예제 3: 리스너(listenr) 혹은 콜백(callback) 의 해제

클라이언트는 콜백(callback)을 등록하여 프로그래밍에서 특정 이벤트가 발생했을 때, 시스템이나 프레임워크가 미리 정의한 함수나 메서드를 호출하도록 정의할 수 있습니다. 

참고 자바스크립트에서 버튼 클릭 이벤트에 대한 콜백 함수
// 콜백 함수 정의
function buttonClickCallback() {
  console.log("버튼이 클릭되었습니다!");
}

// 버튼 요소 가져오기
const buttonElement = document.getElementById("myButton");

// 버튼 클릭 이벤트에 콜백 함수 등록
buttonElement.addEventListener("click", buttonClickCallback);​

위 코드에서 'buttonClickCallback' 함수가 버튼이 클릭되었을 때 호출될 콜백 함수입니다. 

 

클라이언트가 콜백을 등록만 하고 명확히 해지하지 않는다면, 무언가 조치해주지 않는 한 콜백은 계속 쌓여갑니다. 

 

해결책: 약한 참조(Weak reference)

이럴 때 콜백을 약한 참조(weak reference) 로 저장하면 가비지 컬렉터가 즉시 수거합니다.

일반적으로 콜백 함수는 강한 참조(Strong Reference) 형태로 저장되는데, 이는 콜백 함수를 호출하는 객체나 컴포넌트에 의해 계속 참조되어 콜백 함수가 메모리에서 해제되지 않습니다.

하지만, 콜백을 약한 참조로 저장하면, 해당 콜백 함수가 더 이상 다른 곳에서 참조되지 않으면 GC에 의해 메모리에서 자동으로 해제됩니다. 

예를 들어, WeakHashMap에 키로 저장하여 해결할 수 있습니다.

 

 


출처

https://www.yes24.com/Product/Goods/65551284

 

이펙티브 자바 Effective Java 3/E - YES24

자바 플랫폼 모범 사례 완벽 가이드 - Java 7, 8, 9 대응자바 6 출시 직후 출간된 『이펙티브 자바 2판』 이후로 자바는 커다란 변화를 겪었다. 그래서 졸트상에 빛나는 이 책도 자바 언어와 라이브

www.yes24.com