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

Loopy·2022년 5월 21일
0

이펙티브 자바

목록 보기
7/76
post-thumbnail

메모리를 직접 관리해야 하는 C, C++와 달리, 자바는 다 쓴 객체를 알아서 회수해가는 가비지 컬렉터를 가지고 있다. 그렇다고 해서, 메모리 관리에 더 이상 신경 쓰지 않아도 된다는 말은 아니다.

☁️ 가비지 컬렉터 동작 과정

자바의 가비지 컬렉터 대상은, 힙 영역에 참조되지 않는 객체들이다. 따라서 힙 Method Area 내부 정적 변수, StackNative Stack에서 참조되고 있는 경우 가비지 컬렉터의 대상에서 제외된다.

힙 영역 구조

객체의 생존 기간에 따라 물리적으로 Young / Old 영역 두 가지로 구분이 되어있다.

  1. Young : 새롭게 생성되는 객체가 할당되는 영역으로, 해당 영역에 대한 가비지 컬렉터는 Minor GC이다.
    1-1. Eden : 새롭게 생성된 객체가 존재
    1-2. Survival : Eden 에서의 Reachable 객체가 이동하는 영역
  1. Old : Young 영역에서 참조가 유지되어 살아남는 객체가 복사되는 영역으로, 해당 영역에 대한 가비지 컬렉터는 Major GC 혹은 Full GC 이다.

가비지 컬렉터 동작 과정

쉽게 말하면 Young 영역을 주기적으로 청소하고, 살아남은 애들은 정말 오래 참조될 객체라고 판단해 Old 영역으로 내보내 관리하는 방식이다.

자바 8 이후 변경사항

참고로 자바 8 이후부터는 static 객체가 GC 의 관리 대상으로 변경되었다.

Class, Method의 메타 정보, static 객체 등이 존재하던 permanent 영역이 JVM 이 메모리를 필요에 따라 리사이징 가능한 metaspace 로 영역으로 변경되고, static object 와 상수화된 String objectheap 영역으로 이동된 것이다.

따라서 자연스럽게 permanent generation 영역이 꽉 차서 나는 메모리 누수 문제 OutofMemoryError 는 자바 8 이후로부터는 발생하지 않는다.

https://blogs.oracle.com/javamagazine/post/java-garbage-collectors-evolution
https://code-factory.tistory.com/48

☁️ 메모리 누수 주범 1 : 배열

메모리 누수 예제

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

겉으로 보기에는 문제 없어보이지만, 해당 코드는 메모리 누수 가 발생하고 있어 가비지 컬렉션 활동과 메모리 사용량이 늘어나 결국 성능 저하로 이어질 것이다.

return elements[--size] 에서 스택에서 원소를 꺼낸다 해도, 스택이 객체들의 참조를 그대로 가지고 있기 때문에 가비지 컬렉터가 회수를 하지 않기 때문이다.

가비지 컬렉터는 객체 참조 하나만 살려둬도 해당 객체가 참조하는 모든 객체를 회수해가지 못한다. 따라서, 잠재적으로 성능에 악영향을 줄 수 있다.

해결 방법

다 쓴 참조를 null 처리(참조 해제) 하면, 메모리 누수를 방지할 수 있다. 아래의 예시는 비활성 영역이 되는 순간 null 처리를 통해 해당 객체를 쓰지 않을 것임을 가비지 컬렉터에 알리는 코드이다.

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

하지만, nullpointerexception 등을 고려해야 하기 때문에 자기 메모리를 직접 관리하는 스택에 경우를 제외하고는 객체 참조를 null 처리하는 일은 예외적인 경우여야 한다.

따라서, 다 쓴 객체의 참조를 해제하는 가장 좋은 방법은 아래 코드와 같이 참조를 담은 변수를 유효 범위 밖으로 밀어내는 것이라 할 수 있다.

public static void main(String[] args) {
     Stack stack = new Stack();
     for (String arg : args)
         stack.push(arg);

     while (true)
          System.err.println(stack.pop());
}

☁️ 메모리 누수 주범 2: 캐시

객체 참조를 캐시에 넣고, 까먹어서 객체를 다 쓴 이후로도 놔두는 일은 메모리 누수로 이어진다.

WeakHashMap

하지만 캐시 외부에서 키를 참조하는 동안만(값 X) 엔트리가 살아 있는 캐시가 필요하다면, WeakHashMap 을 사용해 캐시를 만들어 다 쓴 엔트리가 그 즉시 자동으로 제거되게 할 수 있다.

LinkedHashMap

또한, 캐시를 만들때 주로 캐시 엔트리의 유효 기간을 정의하기 어려워 시간이 지날수록 엔트리의 가치를 떨어뜨리는 방식을 사용한다. 이런 방식에서 쓰지 않는 엔트리를 청소하는 방법에는 1)백그라운드 스레드를 활용하거나, 2)캐시에 새 엔트리 추가 시 부수 작업으로 수행하는 방법이 있다.

예를 들어, LinkedHashMapremoveEldestEntry() 메서드를 통해 2번 방법으로 제거를 수행한다.

protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
        return false;
}

☁️ 메모리 누수 주범 3: 리스너(콜백)

클라이언트가 콜백을 등록만 하고 해지하지 않는다면 계속 쌓여 간다.

이럴 때, 콜백을 약한 참조(weak refrence)로 저장하면 가비지 컬렉터가 즉시 수거해갈 수 있다.WeakHashMap에 키로 저장하는 것이 그 예가 될 것이다.

profile
개인용으로 공부하는 공간입니다. 잘못된 부분은 피드백 부탁드립니다!

0개의 댓글