C, C++ 처럼 메모리를 직접 관리해야 하는 언어를 쓰다가

자바 처럼 가비지 컬렉터를 갖춘 언어로 넘어오면 프로그래머의 삶이 평안해진다고 한다.

다 쓴 객체를 알아서 회수해가기 때문이다.

이 때문에 메모리 관리에 더 이상 신경 쓰지 않아도 된다고 생각할 수 있는데, 절대 아니다!

 

스택을 구현한 코드를 보자.

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 o){
    	ensureCapatity();
        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);
    }
}

특별한 문제가 없는 코드로 보이지만,

해당 코드는 메모리 누수의 문제가 있다.

이 스택을 사용하는 프로그램을 오래 실행하다 보면 점차 가비지 컬렉션 활동과 메모리 사용량이 늘어나 결국 성능이 저하 될 것이다. 상대적으로 드문 경우긴 하지만 심할 때는 디스크 페이징이나 OutOfMemoryError를 일으켜 종료될 수도 있다.

 

그럼, 위 코드에서 메모리 누수는 어디서 발생할 까?

이 코드에서는 스택이 커졌다가 줄어들었을 때 스택에서 꺼내진 객체들을 가비지 컬렉터가 회수 하지 않는다.

프로그램에서 그 객체를 사용하지 않더라도....!

왜냐면, 스택이 그 객체들의 다 쓴 참조를 여전히 가지고 있기 때문이다.

다 쓴 참조란, 말그대로 다시 쓰지 않을 참조를 뜻한다.

 

위의 코드에서 elements배열의 활성 영역 밖의 참조들이 모두 여기에 해당한다.

활성 영역은 인덱스가 size보다 작은 값들로 볼 수 있다.

 

가비지 컬렉션은 이런 경우 메모리 누수를 찾기가 힘들다.

객체 참조 하나를 살려두면 가비지 컬렉터는 그 객체뿐만 아니라 그 객체가 참조하는 모든 객체를 회수 하지 못한다.

그래서 단 몇개의 객체가 매우 많은 객체를 회수되지 못하게 할 수 있고, 잠재적으로 성능에 악영향을 끼친다.

 

이런 경우 해법은 간단하다.

해당 참조를 다 쓰고나면 null 처리 하면 된다.

수정된 코드는 아래와 같다.

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

 

그렇다고 매번 객체를 다쓰자마자 null 처리 하는 것은 바람직하지 않다.

코드를 지저분하게 만들뿐이다. 객체 참조를 null 처리하는 일은 예외적으로 처리해야 한다.

 

그럼 예외적으로 null 처리할 때는 어떤 상황일까?

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

캐시 역시 메모리 누수를 일으키는 주범이다.

객체 참조를 캐시에 넣고 까먹게 되는 경우를 자주볼 수 있다.

캐시 외부에서 키를 참조하는 동안만 엔트리가 살아있는 캐시가 필요한 상황이라면 WeakHashMap을 사용하도록 하자.

다 쓴 엔트리는 그 즉시 자동으로 제거될것이다.

다만~~WeakHashMap 는 이런 경우에만 유용하다!

 

캐시를 만들 때 보통은 캐시 엔트리의 유효 시간을 정확히 정의하기 어렵기 때문에 시간이 지날수록 엔트리의 가치를 떨어뜨리는 방식을 흔히 사용한다.

 

이런 방식에서는 쓰지 않는 엔트리를 청소해주도록 하자(ScheduledThreadPoolExecutor 같은..) 백그라운드 스레드를 활용하거나 캐시에 새 엔트리를 추가할 때 부수 작업으로 수행하는 방법이 있다.

LinkedHashMap은 removeEldestEntry 메서드를 써서 후자의 방식으로 처리한다.

 

또다른 메모리 누수의 주범은 리스너, 콜백이다.

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

이럴때 콜백을 약한 참조(weak reference)로 저장하면 컬렉터가 즉시 수거해간다. 이때 사용하는 것이  WeakHashMap 이라고 보면 된다.

 

 

메모리 누수는 겉으로 잘 드러나지 않아서 시스템에 수년간 잠복하는 사례도 있다.

이런 누수는 철저한 코드 리뷰나 힙 프로파일러 같은 디버깅 도구를 동원해야만 발견되기도 하기 때문에

이런 종류의 문제는 예방법을 익혀두도록 하자!

 

참고 : 책, 이펙티브 자바 3/E

 

+ Recent posts