[Effective Java] 아이템7 - 다 쓴 객체 참조를 해제하라

Note: 이 글은 Effective Java 책을 읽으면서 중요한 내용 및 알아야 하는 지식들을 정리한 글입니다.

자바에서는 C, C++과는 달리 Garbage Collector가 사용완료된 자원을 알아서 해제해주고, 삭제해준다. 하지만 Garbage Colector도 메모리 누수를 찾기 쉽지 않다.

왜냐하면 참조하고 있는 객체가 하나라도 있으면, 그것이 모두 사용됬다 하더라도 Garbage Collector가 회수해가지 못하기 때문이다.

자기 메모리를 직접 관리하기 위해 저장소 pool을 만들어 사용하는 클래스

책에서는 스택을 구현한 코드를 예제로 들었다.

public class Stack {
    private Object[] elements; // (0) 직접 관리하려는 저장소 pool
    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) {...}

    public Object pop() {
        if (size == 0)
            throw new EmptyStackException();
        return elements[--size]; // (1) 메모리 누수 발생
    }
}

pop() 메소드에서 메모리 누수가 발생하고 있다.

pop()을 하고, 해당 값을 리턴만 해주고, 그 공간은 삭제되고 있지 않아서 obsolete reference를 여전히 가지고 있게 된다.

obsolete reference : 앞으로 다시 쓰지 않을 참조

따라서 다 쓴 원소의 공간을 null 처리하여 참조를 해제하면 된다.

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

이렇게 개발자가 철저하게 유의하고, 코드리뷰를 해야만 메모리 누수가 저 블럭에서 발생할거라고 예측할 수 있다.

캐시 (WeakHashMap을 고려하자)

객체 참조를 캐시에 넣고 나서, 이 사실을 까맣게 잊은 채 그 객체를 다 쓴 뒤로도 한참을 그냥 놔둘 경우에도 메모리 누수가 일어난다.

Key-Value 값을 저장하기 위해 HashMap 이 자주 사용된다. 하지만 HashMap은 GC에 의해 수집되지 않아서 많은 메모리를 소유하게 될 수 있다.

Using a simple HashMap will not be a good choice because the value objects may occupy a lot of memory. What’s more, they’ll never be reclaimed from the cache by a GC process, even when they are not in use in our application anymore.

즉, HashMap에 Key가 null 이어도 이 객체가 GC에 의해서 사라지지 않고 계속 가지고 있다는 뜻이다.

@DisplayName("HashMap 테스트")
  @Test
  void HashMapTest() throws InterruptedException {
      //given
      Map<Foo, String> map = new HashMap<>();

      Foo key = new Foo();
      map.put(key, "1");

      //when
      key = null;
      System.gc();

      TimeUnit.SECONDS.sleep(5);

      assertFalse("HashMap 은 참조를 끊어도 Map이 비어있지 않다.", map.isEmpty());
  }

  @DisplayName("WeakHashMap 테스트")
  @Test
  void test2() throws InterruptedException {
      //given
      Map<Foo, String> map = new WeakHashMap<>();

      Foo key = new Foo();
      map.put(key, "1");

      //when
      key = null;
      System.gc();

      TimeUnit.SECONDS.sleep(5);

      assertTrue("WeakHashMap은 참조를 끊으면 GC에 의해 MAP이 비어진다", map.isEmpty());
  }

위 테스트 코드는 해당 포스팅을 참고하였다.

TimeUnit.SECONDS.sleep(5) : 5초를 쉬고, map 이 비어있는지 확인했는데, 그 이유는 System.gc()로 강제로 GC를 발동시켜도 언제 GC가 Unreferenced 객체를 지우는지 guarantee 할 수 없어서다. 5초를 안쉬면 Test가 Fail난다 :)

HashMap이 메모리 누수의 주범이 될수도 있다는 새로운 사실에 굉장히 놀랐으며, 생각해보니 Key를 null 로 만드는 케이스는 실무에서 많이 없고 그래서 여태까지 문제가 안되었나 싶다 (정말 놀랍다)

그래서 나는 아래 2가지를 더 자세하게 알아봤다.

Weak Hash Map의 Key 가 String 일 경우

Class WeakHashMap<K,V> <- 포스트에서 아래 내용이 이해하기 어려웠다.

This class is intended primarily for use with key objects whose equals methods test for object identity using the == operator. Once such a key is discarded it can never be recreated, so it is impossible to do a lookup of that key in a WeakHashMap at some later time and be surprised that its entry has been removed. This class will work perfectly well with key objects whose equals methods are not based upon object identity, such as String instances. With such recreatable key objects, however, the automatic removal of WeakHashMap entries whose keys have been discarded may prove to be confusing.

무슨 말인지 몰라서 String 으로 테스트 해보다가 희안한 걸 발견했다. new 연산자를 통해 생성된 String 객체는 힙 영역에 만들어져서 GC가 작동하는데, String value = ““로 만들어지면 Strong reference를 가져서 GC에서 파악하지 못한다. (Integer prime = 1; 도 마찬가지다.)

즉, String 이 HashMap의 Key라면 null 로 설정해도, GC로 사라지지 않는다는 것이다.

@DisplayName("WeakHashMap String 테스트")
@Test
void test3() throws InterruptedException {

  Map<String, String> map = new WeakHashMap<>();

    // 아래처럼 선언할 경우, GC가 작동해서 ITEM 이 사라진다.
   	String s1 = new String("ITEM");
   	String s2 = new String("SITE_ITEM");

    // 아래 처럼 선언할 경우, GC가 작동하지 않는다.
//  String s1 = "ITEM";
//  String s2 = "SITE_ITEM";

  map.put(s1, "123");
  map.put(s2, "987");

  s1 = null;
  System.gc();

  TimeUnit.SECONDS.sleep(5);

  assertTrue(map.get("ITEM") == null);

}

리스너(listener) 혹은 콜백(callback)

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

이럴 때 콜백을 weak reference로 저장하면 GC가 즉시 수거해간다.

댓글남기기