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

김동훈·2023년 5월 14일
2

Effective-java

목록 보기
2/14
post-thumbnail

Stack의 메모리 누수

책에서 사용한 예제 Stack클래스에서 메모리 누수가 발생하는 부분은 pop() 메소드이다.

	public Object pop() {
        if (size == 0)
            throw new EmptyStackException();
        return elements[--size];
    }

위 코드를 사용하면 기능상 문제는 없을 것 같다. 하지만 숨겨져 있는 문제는 메모리 누수이다. 메모리 누수가 발생하는 부분은 return elements[--size];에서 발생할 것이다. 위 pop() 메소드에서는 pop() 하기 전의 가장 위에있던 객체가 여전히 stack클래스의 elements[] 배열에 담겨있으면서 살아있기 때문이다. 따라서 여전히 이 스택이 참조하고 있기 때문에 gc가 회수 하지 않는다.

가비지 컬렉터(gc)는 객체 참조 하나를 살려두면 그 객체뿐 아니라 그 객체가 참조하는 모든 객체를 회수해가지 못한다. 이는 아래에서 설명할 java의 4가지 reference type을 생각해보면 알 수 있을 것이다.

제대로 구현한 pop메소드는 다음과 같다.

	public Object pop() {
        if (size == 0)
            throw new EmptyStackException();
        Object result = elements[--size];
        elements[size] = null;
        return result;
    }

다 쓴 참조를 null처리하는 것이다. 이렇게 null 처리하면 메모리 관리와 더불어 다른 이점도 있다. 바로 null처리한 참조의 사용을 막을 수 있다. null처리한 참조를 사용하려 하면 NullPointException을 던지며 종료하게 될 것이다.

그럼 실제로 Java에 구현된 Stack 클래스에도 위와 같이 null 처리하여 메모리 누수에 주의를 기울였는지 확인해보았다. (물론 당연히도 그렇게 구현 해놓았겠지만,,,공부해야지..?!)

    /**
     * Removes the object at the top of this stack and returns that
     * object as the value of this function.
     *
     * @return  The object at the top of this stack (the last item
     *          of the {@code Vector} object).
     * @throws  EmptyStackException  if this stack is empty.
     */
    public synchronized E pop() {
        E       obj;
        int     len = size();

        obj = peek();
        removeElementAt(len - 1);

        return obj;
    }

우선 pop()메소드 안에서 removeElementAt()을 호출 하고 있는 것으로 보아 여기서 실제 로직이 이루어진다고 예상해 볼 수 있다.

    /**
     * Deletes the component at the specified index. Each component in
     * this vector with an index greater or equal to the specified
     * {@code index} is shifted downward to have an index one
     * smaller than the value it had previously. The size of this vector
     * is decreased by {@code 1}.
     *
     * <p>The index must be a value greater than or equal to {@code 0}
     * and less than the current size of the vector.
     *
     * <p>This method is identical in functionality to the {@link #remove(int)}
     * method (which is part of the {@link List} interface).  Note that the
     * {@code remove} method returns the old value that was stored at the
     * specified position.
     *
     * @param      index   the index of the object to remove
     * @throws ArrayIndexOutOfBoundsException if the index is out of range
     *         ({@code index < 0 || index >= size()})
     */
    public synchronized void removeElementAt(int index) {
        if (index >= elementCount) {
            throw new ArrayIndexOutOfBoundsException(index + " >= " +
                                                     elementCount);
        }
        else if (index < 0) {
            throw new ArrayIndexOutOfBoundsException(index);
        }
        int j = elementCount - index - 1;
        if (j > 0) {
            System.arraycopy(elementData, index + 1, elementData, index, j);
        }
        modCount++;
        elementCount--;
        elementData[elementCount] = null; /* to let gc do its work */
    }

Stack클래스는 Vector클래스를 상속하고 있다. 위 removeElementAt메소드에서 사용되는 변수에 대해 간략히 말하자면,

  • elementCount : Stack클래스에서 관리하고 있는 객체 수
  • elementData : Object타입 배열로, 실제 객체들을 관리하고 있는 배열

실제로 위 메소드의 마지막 라인을 보면 elementData[elementCount] = null;로 null 처리하여 참조해제 하고 있다. 또한 주석을 보면 gc를 실행하도록 하고 있음을 설명해준다.


앞으로 얘기할 내용을 위해서 우선 java에서 사용하는 4가지의 참조 관계 에 대해 알아야 하는데, 간략히 정리해보겠다.

4가지의 참조관계

Strong(Hard) Reference

참조 타입의 dafault타입이다. (우리가 가장 자주 사용하는 참조이지 않을까 싶다.)
어떤 객체에 대한 참조가 살아있는 한, gc는 이를 처리하지 않는다.

Soft Reference

gc의 재량에 따라 처리된다.
즉, JVM의 메모리가 부족하다고 판단 될 떄 gc가 이를 처리할 것이다.
(이 떄, 오래된 soft참조 부터 처리한다...)공식문서를 제대로 이해했는지는 잘 모르겠다...혹시 이에 대해 잘 아시는 분이 있으시면 댓글로 알려주시면 감사하겠습니다
따라서, 메모리에 민감한 캐시를 구현하는데에 많이 사용된다.
해당 객체에 강한 참조가 있다면 gc는 이를 처리하지 않는다.

Weak Reference

strong, soft reference가 없어지면 그때 gc가 처리한다.
WeakReference를 사용한 대표적인 예시는 weakHashMap이다.

Phantom Reference

phantom reference는 get메소드로 참조를 찾을 수 없다. 따라서 PhantomReference 클래스를 확장하여 사용하는 것이 일반적이다.
PhantomReference는 finaliztion 로직을 구현할 때 유용하다.

캐시 사용에서의 메모리 누수

HashMap<>

우선 일반적인 HashMap<>을 사용하여 메모리 누수를 확인해보았다. 테스트에 사용된 코드는 다음과 같다. 예시 코드에서는 gc처리 여부를 isEmpty() 메소드를 통해 확인해보고 있다. 이렇게 테스트해봄으로써 삽질을 하게 되었는데 이는 삽질기 시리즈에 담아보겠다.

    Object key = new Object();
    Object value = new Object();
    Map<Object, Object> cache = new HashMap<>();
    cache.put(key, value);
    key = null;
    System.gc();
    TimeUnit.SECONDS.sleep(5);
    System.out.println("cache.isEmpty() = " + cache.isEmpty());

실행결과는 cache.isEmpty() = false 이다. HashMap에서 어떻게 key, value를 삽입하는지 알아야 한다. putVal메소드에서는 2가지의 방식으로 삽입하고 있다.

  • tab[i] = newNode(hash, key, value, null);
  • p.next = newNode(hash, key, value, null);

두 삽입하는 방식을 보면 key에 의해 value가 gc에 수집될지가 결정되는 형태가 아니기 때문에 key = null; 하더라도 아무 의미가 없을 것 이라는 것을 생각 해 볼 수 있다.

심지어 key = null; 을 하더라도 cache에 있는 key가 null로 참조해제가 되는 것은 아니다. 이 이유는 위에서 말한 Strong Reference도 고려해 생각해 볼만 하다. 현재 key = new Object() 로 생성한 Object 객체를 참조하고 있는 변수들은 main의 keycache내부에서 사용하는 key가 있을 것 이다. 그러므로 여전히 cache내부의 key가 이 객체를 Strong Reference타입으로 참조하고 있기 때문에, cache 내부의 key가 null이 되는것은 아니다.

결국엔, cache내부에는 key, value 객체가 그대로 살아있게된다.

System.gc(); 이후에 values.stream().forEach(val -> System.out.println(val)); 로 출력해보면 진짜 value객체가 출력됨을 볼 수 있다.

WeakHashMap<>

그럼 WeakHashMap에서는 gc가 객체를 수집할지, 그리고 어떻게 메모리 누수를 방지하는지 알아보자.
우선 테스트에 사용될 코드는 다음과 같다.

    Object key2 = new Object();
    Object value2 = new Object();
    Map<Object, Object> cache2 = new WeakHashMap<>();
    cache2.put(key2, value2);
    key2 = null;
    System.gc();
    TimeUnit.SECONDS.sleep(5);
    System.out.println("cache2.isEmpty() = " + cache2.isEmpty());

실행결과는 cache2.isEmpty() = true 이다.

isEmpty() 메소드를 확인해보면 내부에서 expungeStaleEntries() 메소드를 호출하고 있는데 이는 위에서 말한 삽질기 에서 정리해보도록 하겠다.

HashMap은 key, value를 Node형태로 사용했다면 WeakHashMap은 Entry를 사용하고있다. 이 Entry클래스가 WeakReference를 상속받고 있어, gc가 객체를 수집할 수 있게 된다. 그럼 Entry클래스를 간단하게 확인해보자.

    /**
     * The entries in this hash table extend WeakReference, using its main ref
     * field as the key.
     */
    private static class Entry<K,V> extends WeakReference<Object> implements Map.Entry<K,V> {
        V value;
        final int hash;
        Entry<K,V> next;

        /**
         * Creates new entry.
         */
        Entry(Object key, V value,
              ReferenceQueue<Object> queue,
              int hash, Entry<K,V> next) {
            super(key, queue);
            this.value = value;
            this.hash  = hash;
            this.next  = next;
        }
        ```중략```
    }

Entry의 생성자를 보면 super(key, queue)로 WeakReference생성자에 key를 넘기는 것을 볼 수 있다. WeakReference에 대한 좀 더 자세한 내용은 삽질기에서 작성하겠다. 이렇게 WeaKHashMap에서 key는 WeakReference타입으로 되어있다.

이제 처음에 말한 java에서 사용하는 4가지 참조관계 중, Weak Reference을 알아야 했던 이유가 나온다. Weak Reference에 다시 말하자면, Strong, Soft Reference가 사라지면 gc가 수집한다고 하였는데, 이는 곧 Weak Reference만 남아있을 때 gc가 수집하는 것이다.

여기서 key는 이제 main메소드에서의 key는 Strong Reference참조이고, WeakHashMap내에서는 Weak Reference참조관계를 가진다.
그러면 main메소드에서 key = null; 로 인해 Strong Reference참조가 해제 되므로 WeakHashMap내부의 Weak Reference타입 key만 남게된다. 이는 곧 gc가 수집하여 WeakHashMap에서 key가 삭제된다.

그럼 key는 gc가 처리할 수 있다는 것은 알았는데, isEmpty()의 반환값이 왜 true일까?

WeakHashMap에서는 key = null; 을 하게되면 key뿐만 아니라 Entry까지 삭제된다. Entry까지 삭제되는 이유에 대한 설명은 공식문서에서 찾아 볼 수 있었다.
Because the garbage collector may discard keys at any time, a WeakHashMap may behave as though an unknown thread is silently removing entries. 즉,알려지지 않은 쓰레드가 그냥 entries를 지운다는 것인데, 이에 대해 알고 계신 분이 있으시면 댓글달아주시면 감사드립니다.


리스너 | 콜백 사용에서의 메모리 누수

클라이언트가 콜백을 등록만하고 명확히 해지 하지 않는다면, 콜백은 계속 쌓여간다.
이럴때 WeakHashMap을 사용하면 쉽게 관리 할 수 있다.
이또한 간단한 예제를 통해 이해해보는게 쉬울 것 같다.

interface Listener {
    void event();
}
public class Callback {
    public static void main(String[] args) {
        ApiUser user = new ApiUser();
        user.registerListener(); // 리스너 등록
        user= null; // ApiUser객체 삭제 
        System.gc(); // gc처리
        API.fireListeners();

    }
}
class ApiUser{
    Listener listener = null;
    public void registerListener() {
        listener = new Listener() {
            @Override
            public void event() {
                System.out.println("Some task was working~~");
            }
        };
        API.registerListener(listener);
    }
}

class API {
    static WeakHashMap<Listener, Object> listeners = new WeakHashMap();

    static void registerListener(Listener listener) {
        listeners.put(listener, null);
    }
    static void fireListeners() {
        for (Listener listener : listeners.keySet()) {
            if (listener != null) {
                listener.event();

            }
        }
    }
}

위 코드대로 실행시키게 되면 여태까지 설명했듯이 WeakReference로 감싸진 key는 gc가 처리할 것 이므로 main메소드에서 fireListeners()호출 시 listeners라는 WeakHashMap에는 key가 존재하지 않기 때문에 아무런 출력문이 확인할 수 없을 것이다. 이에 대한 설명은 바로 위 WeakHashMap에 대해 설명하면서 설명했으므로 넘어가겠다. 한번 실행시켜보면서 다시 확인해보면 좋을 것 같다.


결론

이번 item의 주제는 다 쓴 객체 참조를 해제하라 이다. 결국 메모리를 관리해야한다 인데, 이 책에서 언급한 메모리 누수의 원인은 다음과 같다.

  • 캐시의 사용
  • 리스너 혹은 콜백의 사용

    자기 메모리를 직접 관리하는 클래스 라면 프로그래머는 항시 메모리 누수에 주의해야 한다.


참고

effective-java스터디에서 공유하고 있는 전체 item에 대한 정리글

profile
董訓은 영어로 mentor

0개의 댓글