Effective Java

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

takman 2022. 1. 11. 00:57

자바는 GC(가비지 컬렉터)가 다 쓴 객체를 알아서 회수해가기 때문에, 메모리 관리에 더 이상 신경 쓰지 않아도 된다고  생각할 수 있지만 이것은 오해다!

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() {
		//stack의 빈 공간에 접근했을 때, 예외처리
        if(size == 0)
        	throw new EmptyStackException();
        return elements[--size];
    }
    
    /**
     * 원소를 위한 공간을 적어도 하나 이상 확보한다.
     * 배열 크기를 늘려야 할 때마다 대략 두 배씩 늘린다.
     */
     
     private void ensureCapacity() {
     	if(elements.length == size) 
        	elements = Arrays.copyOf(elements, 2 * size + 1)
     }
}

위의 코드는 특별한 문제 없이 별의별 테스트를 수행해도 거뜬히 통과한다. 

하지만 꼭꼭 숨어있는 문제가 존재하는데, 바로 '메모리 누수'이다.

위의 Stack을 사용하는 프로그램을 오래 실행하다 보면 GC의 활동과 메모리 사용량이 늘어나 결국 성능이 저하된다.

(심한 경우, 디스크 페이징이나 OutOfMemoryError를 일으켜 프로그램을 예기치 않게 종료시킴)

 

위의 코드에서 과연 메모리 누수는 어디서 일어날까?

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

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

(cf. 다 쓴 참조(obsolete reference : 앞으로 다시 쓰지 않을 참조)

 

활성 영역 -> elemets 배열의 인덱스가 size보다 작은 원소들로 구성된 것

활성 영역 밖의 참조는 모두 다 쓴 참조 즉, 메모리 누수가 일어나는 곳이다.


가비지 컬렉션 언어(ex. 자바,..)에서는 메모리 누수를 찾기가 아주 까다롭다.

그 이유는 객체 참조 하나를 살려두면 GC는 그 객체 뿐만 아니라, 그 객체가 참조하는 모든 객체를 회수하지 못하기 때문이다.

해법은 간단하다.

 

해당 참조를 다 썼을 때 null 처리(참조 해제)를 해라

 

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

null 처리의 이점

null 처리한 참조를 실수로 사용할 때, 프로그램은 즉시 NullPointerException을 던지며 종료 -> 프로그램 오류를 조기에 발견

 

Stack 클래스는 왜 메모리 누수에 취약할까?

스택이 자기 메모리를 직접 관리하기 때문

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

 

문제: GC는 활성 영역과 비활성 영역을 알 수 없다

.( 비활성 영역이 쓸모 없다는 것은 프로그래머만 안다. GC는 영역의 구분 없이 똑같이 유효한 객체 취급)

-> 그래서 프로그래머는 비활성 영역이 되는 순간 null처리해서 해당 객체가 더 이상 안쓰이는 것을 GC에게 알려야 한다.

 

null 처리는 언제 해야 할까?

객체 참조를 null 처리하는 일은 예외적인 경우에만 해당
모든 객체를 쓰자마자 null 처리를 한다면 프로그램을 지저분하게 만든다.

 

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

 

Integer pop() {

	Integer age = 27;

	...

	age = null; // 굳이 할 필요x
}

Integer age 의 scope는 pop()  안에서만 유효하다. 해당 scope를 벗어난다면 무의미한 레퍼런스 변수가 되므로 GC에 의해 수거된다.

(굳이 null 처리를 할 필요x)

 

캐시 역시 메모리 누수의 주범

객체 참조를 캐시에 넣고 난 후, 까먹고 그 객체를 놔두는 경우 메모리 누수가 일어난다.

 

해법: 1. WeekHashMap 사용(key 값을 참조하는 동안만 엔트리가 살아있도록 한다. 다 쓴 엔트리는 그 즉시 자동으로 제거)

        2. 시간이 지날수록 엔트리의 가치를 떨어뜨리는 방식 사용 (쓰지 않는 엔트리를 청소)

 ->  (ThreadPoolExecutor와 같은) 백그라운드 스레드를 활용하거나 캐시에 새 엔트리를 추가할 때 부수 작업으로           LinkedHashMap의 removeEldestEntry 메서드 사용

 

 + 더 복잡한 캐시를 만들 때, java.lang.ref 패키지를 직접 활용

 

리스너 혹은 콜백 또한 메모리 누수의 주범

클라이언트가 콜백을 등록만 하고 명확히 해지를 안하고 조치를 취하지 않는 한, 콜백은 계속 쌓이게 된다.

-> 콜백을 약한 참조로 저장하면 GC가 즉시 수거해간다.

 

핵심정리

메모리 누수는 겉으로 잘 드러나지 않아 시스템에 수년간 잠복하는 사례도 있다.
이런 누수는 철저한 코드 리뷰나 힙 프로파일러 같은 디버깅 도구를 동원해야 발견되기도 한다.
그래서 이런 종류의 문제는 예방법을 익혀두는 것이 매우 중요하다.