Item7 - 다 쓴 객체 참조를 해제하라

2023. 11. 18. 23:00Book/이펙티브 자바

 

 

C, C++와 달리 자바는 가비지 컬렉터가 다 쓴 객체를 알아서 회수한다 그래서 자칫 메모르 관리에 더 이상 신경 쓰지 않아도 된다고 생각할 수 있지만 그렇지 않다.

 

 Stack을 간단히 구현한 코드이다.

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


    /**
     * 원소를 위한 공간을 적어도 하나 이상 확보한다.
     * 배열 크기를 늘려야 할 때마다 두 배씩 늘린다.
     */
    private void ensureCapacity() {
        if (elements.length == size) {
            elements = Arrays.copyOf(elements, 2 * size + 1);
        }
    }
    
    
    public Object pop() {
        if(size == 0) {
            throw new EmptyStackException();
        }
        return elements[--size];
    }
}

 

특별한 문제는 없어 보인다. 별의별 테스트를 수행해도 거뜬히 통과할 것이다. 하지만 큰 문제가 있다. 이는 바로 메모리 누수이다.

 이 스택을 사용하는 프로그램을 오래 실행하다 보면 점차 가비지 컬렉션 활동과 메모리 사용량이 늘어나 결국 성능이 저하될 것이다.

 

 문제는 스택이 커졌다가 줄어들었을 때 스택에서 꺼내진 객체들을 가비지 컬렉터가 회수하지 않는다. 프로그램에서 그 객체들을 더 이상 사용하지 않더라도 말이다. 이 스택이 그 객체들의 다 쓴 참조를 여전히 가지고 있기 때문이다.

 

 가비지 컬렉션 언어세서 메모리 누수를 찾기가 아주 까다롭다. 객체 참조 하나를 살려두면 가비지 컬렉터는 그 객체뿐 아니라 그 객체가 참조하는 모든 객체를 회수해가지 못한다. 그래서 단 몇 개의 객체가 매우 많은 객체를 회수되지 못하게 할 수 있고 잠재적으로 성능에 악영향을 줄 수 있다.

 

 해법은 간단하다. 해당 참조를 다 썻을 때 null 처리하면 된다.

    /**
     *제대로 구현한 pop 메서드
     */

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

 다 쓴 참조를 null 처리하면 다른 이점도 따라온다. 만약 null 처리한 참조를 실수로 사용하려 하면 프로그램은 즉시 NPE를 던지며 종료된다.

 그렇다고 개발자가 모든 객체를 다 쓰자마자 일일이 null처리 할 필요는 없다. 프로그램을 필요 이상으로 지저분하게 만들 뿐이다.

 

null 처리 하는 일은 예외적인 경우여야 한다. 다 쓴 참조를 해제하는 가장 좋은 방법은 그 참조를 담은 변수를 유효 범위 밖으로 밀어내는 것이다. 변수의 범위를 최소가 되게 정의했다면 이 일은 자연스럽게 이뤄진다.

 

그렇다면 null 처리는 언제 해야 할까?

 일반적으로 자기 메모리를 직접 관리하는 클래스라면 메모리 누수에 주의해야 한다. 스택은 (객체 자체가 아닌 객체 참조를 담는) elements 배열로 저장소 풀을 만들어 원소들을 관리한다. 배열의 활성 영역에 속한 원소들이 사용되고 비활성 영역은 쓰이지 않는다. (*활성 영역 => 사이즈보다 작은 인덱스를 가진 영역)

 하지만 가비지 컬렉터는 이 사실을 알 수가 없기 때문에 프로그래머가 직접 null 처리 하는 것이다.

 

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

 1. WeakHashMap => 캐시 외부에서 키를 참조하는 동안만 엔트리가 살아 있는 캐시가 필요한 상황

 2. SheduledThreadPoolExecutor => 백그라운드 스레드를 활용하거나 캐시에 새 엔트리를 추가할 때 부수 작업으로 수행

 

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

 

 

 

 

 

 

 

*WeakHashmap - 더이상 사용하지 않는 객체를 GC할 때 자동으로 삭제해주는 Map

 1. Key가 더이상 강하게 레퍼런스되는 곳이 없다면 해당 엔트리를 제거한다.

 2. 레퍼런스 종류 Strong, Soft, Weak, Phantom

  - Strong : 일반적 참조 유형 => 참조가 되는 한 GC 대상이 되지 않는다.

  - Soft     : 부드러운 참조 => 참조가 SoftReference만 남은 상태라면 GC 대상이 된다(단, 메모리가 필요한 상황에서만)

public class SoftReferenceExample {

    public static void main(String[] args) throws InterruptedException {
        Object strong = new Object();
        SoftReference<Object> soft = new SoftReference<>(strong);
        
        strong = null;

        System.gc();
        Thread.sleep(3000L);

        /**
         * TODO 대부분 안 없어짐 => 메모리가 충분하니까 굳이 제거하지 않음
         */
        System.out.println(soft.get());
    }
}

 

 - Weak    : 약한 참조 => 참조가 WeakReference만 남은 상태라면 GC 대상이다(메모리가 충분하건 말건)

public class WeakReferenceExample {

    public static void main(String[] args) throws InterruptedException {
        Object strong = new Object();
        WeakReference<Object> soft = new WeakReference<>(strong);
        strong = null;

        System.gc();
        Thread.sleep(3000L);

        /**
         * TODO 대부분 없어짐
         */
        System.out.println(soft.get());
    }
}

 

 - Phantom :  유령 참조 => 참조가 PhantomReference만 남은 상태라면 GC 대상이 되고 RefereceQueue에 들어간다.

public class PhantomReferenceExample {
    
        public static void main(String[] args) throws InterruptedException {
            BigObject strong = new BigObject();
            ReferenceQueue<BigObject> rq = new ReferenceQueue<>();
    
            BigObjectReference<BigObject> phantom = new BigObjectReference<>(strong, rq);
            strong = null;
    
            System.gc();
            Thread.sleep(3000L);
    
            /**
             * TODO 대부분 없어지고 Queue에 들어감
             */
            System.out.println(phantom.isEnqueued());
    
            Reference<? extends BigObject> reference = rq.poll();
            BigObjectReference bigObjectReference = (BigObjectReference) reference;
            bigObjectReference.cleanUp();
            reference.clear();
        }
    }

 

 3. 맵의 엔트리를 맵의 Value가 아니라 Key에 의존해야 하는 경우에 사용할 수 있다.

 4. 캐시를 구현하는데 사용할 수 있지만, 캐시를 직접 구현하는 것은 권장하지 않는다.