꾸준한 스터디

메모리 누수가 발생하는 이유

Java는 GC( = Garbage Collection)를 해주는 언어로, 다 쓴 객체를 회수해 간다.

그렇기 때문에 더 이상 메모리 관리를 하지 않아도 된다고 착각할 수 있다.

// 코드 7-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);
    }
}

위의 코드에서 Stack이 커지고 줄어들 때 Stack에서 꺼내진 객체들을 가비지 컬렉터가 회수하지 않아 메모리 누수가 일어나고 있다.

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

이 프로그램을 오래 실행하다 보면 점차 가비지 컬렉터 활동과 메모리 사용량이 늘어나 결국 성능 저하가 일어나게 된다.

심할 경우 디스크 페이징이나 OutOfMemoryError를 일으켜 프로그램이 예기치 않게 종료될 수도 있다.

 

그렇다면, 프로그램에서 해당 객체들을 더 이상 사용하지 않아도 가비지 컬렉터가 회수하지 않는 이유는 무엇일까?

Stack이 그 객체들의 다 쓴 참조(obsolete reference)를 계속 가지고 있기 때문이다.

 

다 쓴 참조

다 쓴 참조란 앞으로 다시 쓰지 않을 참조를 뜻하는데 해당 코드에서는 elements 배열의 '활성 영역'( = 인덱스가 size보다 작은 원소들로 구성) 밖의 참조들이 모두 다 쓴 참조에 해당한다.

 

가비지 컬렉터는 객체 참조 하나를 살려두면 그 객체뿐만 아니라 그 객체가 참조하는 모든 객체(참조하는 객체가 참조하는 객체 모두 포함)를 회수해가지 못하여 몇 개의 객체가 많은 객체를 회수하지 못하게 할 수 있고 잠재적으로 성능에 악영향을 줄 수 있다. (강한 참조로 이루어져 참조가 끊기지 않는다.)

 

해결 방안

이와 같이 다 쓴 객체의 참조를 해제하려면 어떻게 해야할까?

null 처리(참조 해제)하기

해당 참조를 다 사용하였을 때 null(참조 해제)를 하면 해결된다.

위의 예시 코드에 적용을 해보자면, 각 원소의 참조가 더 이상 필요 없어지는 시점인 Stack에서 꺼내지는 pop 메서드에 적용할 수 있다.

//제대로 구현한 pop 메서드
public Object pop() {
	if (size == 0) 
		throw new EmptyStackException();
	Object result = elements[--size];
	elements[size] = null //다 쓴 참조 해제
	return result;
}

다 쓴 참조를 null 처리 시 만약 null 처리한 참조를 실수로 사용하려 하면 프로그램은 즉시 NullPointerException을 던지는 이점도 존재한다.

 

단, 객체 참조를 null 처리하는 일은 예외적인 경우여야 한다.

다 쓴 참조를 해제하는 가장 좋은 방법은 그 참조를 담은 변수를 유효 범위(scope) 밖으로 밀어내는 것이다. 

변수의 범위를 최소가 되게 정의하면 된다.

 

null 처리를 해야하는 시기

Stack 클래스는 스택이 자기 메모리를 직접 관리하기 때문에 메모리 누수에 취약하다.

객체 자체가 아닌 객체 참조를 담는 Stack은 elements 배열로 저장소 pool을 만들어 원소들을 관리하며 배열의 활성 영역에 속한 원소들은 사용되고 비활성 영역은 쓰이지 않는다.

문제는 가비지 컬렉터가 보기에는 비활성 영역에서 참조하는 객체도 똑같이 유효한 객체이므로 프로그래머는 비활성 영역이 되는 순간 null 처리해서 해당 객체를 더는 쓰지 않을 것임을 가비지 컬렉터에 알려야 한다. 

 

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

원소를 다 사용한 즉시 그 원소가 참조한 객체들을 다 null 처리한다.

 

메모리 누수를 일으키는 또 다른 원인들

캐시

참조 객체 뿐만 아니라 캐시 또한 메모리 누수를 일으키는 주범으로 객체 참조를 캐시에 넣고 나서, 잊은 채 그 객체를 다 쓴 뒤로도 그냥 놔두는 경우가 있다.

 

해법은 다양하다. WeakHashMap을 사용해 캐시를 만들면 된다. 

캐시 외부에서 key를 참조하는 동안만(값이 아님) 엔트리가 살아 있는 캐시가 필요한 상황이라면 WeakHashMap을 사용해 캐시를 만든다. 

WeakHashMap은 다 쓴 엔트리를 즉시 자동으로 제거하는데, 이러한 상황에서만 유용하다.

 

캐시를 만들 때 보통은 캐시 엔트리의 유효 기간을 정확히 정의하기 어려운데 이러한 경우, 쓰지 않는 엔트리를 이따금 청소해줘야 한다. 

ScheduledThreadPoolExecutor 같은 백그라운드 스레드를 활용하거나 캐시에 새 엔트리를 추가할 때 부수 작업으로 수행하는 방법이 있다. (=> LinkedHashMap의 경우 removeEldestEntry 메서드를 써서 처리한다.)

 

※ 더 복잡한 캐시를 만들고 싶다면 java.lang.ref 패키지를 직접 활용하자!

 

Listener(리스너) or Callback(콜백)

이들도 메모리 누수의 주범인데 클라이언트가 콜백을 등록만 하고 명확히 해지하지 않는다면, 무언가 조치해주지 않는 한 콜백은 계속 쌓인다.

 

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

//weak reference 예제 코드
WeakHashMap<Integer, String> map = new WeakHashMap<>();

Integer key1 = 1000;
Integer key2 = 2000;

map.put(key1, "TEST1");
map.put(key2, "TEST2");
        
key1 = null;

System.gc();  //강제 Garbage Collection
map.entrSet().stream().forEach(el -> System.out.println(el));

 

마무리

메모리 누수는 잘 드러나지 않아 코드 리뷰나 힙 프로파일러 같은 디버깅 도구를 동원해야만 발견되기도 하기 때문에 예방법을 익혀두는 것이 중요하다.

profile

꾸준한 스터디

@StudyRecord

포스팅이 유익하셨다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!