본문 바로가기
Computer Sience/Java

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

by 제우제우 2024. 10. 27.

Chapter 02: 객체 생성과 파괴

객체를 만들어야 할 때와 만들지 말아야 할 때를 구분하는 법

올바른 객체 생성 방법에 대한 설명

개요 

C, C++ 처럼 메모리를 직접 관리해야 하는 언어를 쓰다가 자바처럼 가비지 컬렉터(GC)를 

갖춘 언어로 넘어오면 프로그래머의 삶이 훨씬 평안해진다.

그래서 메모리 관리에 더 이상 신경 쓰지 않아도 된다고 오해할 수 있다. 

메모리 누수 예제 - Stack 

public class Stack<E> {
    public Object [] elements; // test 위한 public 
    private int index = 0; // size -> index
    private static final int DEFAULT_INITIAL_CAPACITY = 16;
    public Stack (){
        elements = new Object[DEFAULT_INITIAL_CAPACITY];
    }
    public E push(E e){
        ensureCapacity();
        elements[index++] = e;
        return e;
    }
    public E pop(){
        if(index == 0){
            throw new EmptyStackException();
        }
        return (E) elements[--index];
    }
    private void ensureCapacity(){
        if(elements.length == index){
            elements = Arrays.copyOf(elements, 2 * index + 1);
        }
    }
}

현재 코드는 스택을 간단하게 구현한 코드이다. 

현재 코드의 문제점은 무엇일까?

→ 메모리 누수 문제이다.

 

pop() 메소드를 보자. 

public E pop(){
    if(index == 0){
        throw new EmptyStackException();
    }
    return (E) elements[--index];
}

단순하게 인덱스를 하나 줄이고 해당 인덱스에 해당하는 배열의 객체를 반환한다.

스택에서 꺼낸 객체들은 가비지 컬렉터가 회수하지 않는다.

왜냐면 이 스택은 필요없는 객체의 래퍼런스를 그대로 가지고 있기 때문이다. 

public class StackTest {
    public static void main(String[] args) {
        Stack<Integer> stack = new Stack<>();
        IntStream.range(1, 11).forEach(i -> stack.push(i)); // 1 ~ 10
        IntStream.range(1, 5).forEach(i -> stack.pop());    // 10,9,8,7,6 pop
        System.out.println(Arrays.toString(stack.elements));
        // [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, null, null, null, null, null, null]
    }
}

 

처음 10개의 Integer를 넣고 이후에 5개의 숫자를 스택에서 제거했다.

그럼 스택의 활성 영역은 1 ~ 5 이다. 

하지만 스택 내부의 배열에는 아직 활성 영역 밖의 참조들이 남아있다. (필요 없는 메모리 차지)

 

이 스택을 프로그램에서 오래 실행하다 보면 점차 메모리 사용이 늘어나 성능이 저하될 것이다.

드문 케이스겠지만 심하면 OutOfMemoryError를 일으켜 프로그램이 종료될 가능성도 있다. 

 

이렇게 코드를 수정할 수 있다.

public E pop(){
    if(index == 0){
        throw new EmptyStackException();
    }
    Object value = elements[--index];
    elements[index] = null;
    return (E) value;
}

 

스택에서 꺼낼 때 그 위치에 있는 객체를 꺼내주고 그 자리를 null로 설정(참조 해제)하여 

GC가 발생할 때 해당 래처런스가 정리되게 한다. 

하나하나 null로 설정하지는 말자

그렇지만 객체를 쓰자마자 null로 설정하는 코드를 작성하지는 말자 

객체를 null로 설정하는 건 예외적인 케이스여야 한다. 

최선책은 참조 변수를 담은 변수를 유효 범위(scope) 안에서만 사용하는 것이다.

변수의 범위를 최소가 되게 정의했다면 이 일은 자연스럽게 이뤄진다.

그럼 언제  래퍼런스를 null 처리?

예제로 만든 Stack 클래스는 왜 메모리 누수에 취약할까?

바로 자기 메모리를 직접 관리하기 때문이다. 

배열의 활성 영역과 객체들은 사용되고 비활성 영역은 쓰이지 않는다.

가비지 컬렉터는 이 바활성 영역의 객체와 활성 영역의 객체를 구분할 방법이 없다. 

해당 비활성 영역의 객체들이 앞으로 필요없는 객체인지는 개발자만 안다.

그래서 개발자는 해당 객체가 비활성 영역에 포함되는 순간 null 처리해서 GC에게 필요없는 객체라고 알려줘야 한다. 

 

자기 메모리를 직접 관리하는 클래스는 개발자는 항시 메모리 누수에 주의해야 한다

캐시

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

객체 참조를 캐시에 넣고 나서, 객체를 다 쓴 뒤로도 캐시에 그대로 놔두는 일은 자주 접할 수 있다. 

 

Cache With HashMap

public class HashMapCache {
    public static void main(String[] args) throws Exception {
        Map<String, String> cache = new HashMap<>();
        String key = new String("test_key");
        String value = new String("test_value");
        cache.put(key, value);

        key = null; // 참조 제거 Strong Reference
        Thread thread = new Thread(() -> {
           System.gc(); // 가비지 컬렉션 호출
        });
        thread.start();

        thread.join();
        System.out.println(cache); // 메모리 누수 
    }
}

// 출력: {test_key=test_value}

 

HashMap에 key-value 쌍을 저장한다. 

key는 test_key 문자열을 가리키는 강한 참조를 가지고 있다. 

 

강한 참조 제거 

key = null;

이제 key 변수는 더이상 test_key를 참조하지 않지만 , cache는 여전히 이 키에 대한 참조를 유지하고 있다. 

 

가비지 컬렉션 호출 

새로운 스레드를 만들고 그 안에서 System.gc();를 호출하여 가비지 컬렉션 수집을 요청한다. 

그러나 cache는 여전히 key에 대한 참조를 가지고 있어서 가비지 컬렉션에 의해 수거되지 않는다.

따라서 메모리가 해제되지 않고 누수된다. 

 

Cache With WeakHashMap (Weak Reference)

public class WeekHashMapCache {
    public static void main(String[] args) throws Exception {
        WeakHashMap<String, String> cache = new WeakHashMap<>();
        String key = new String("test_key");
        String value = new String("test_value");
        cache.put(key, value);

        key = null; // 참조 제거 Strong Reference
        Thread thread = new Thread(() -> {
           System.gc(); // 가비지 컬렉션 호출
        });
        thread.start();

        thread.join();
        System.out.println(cache); // {}
    }
}

 

WeakHashMap

약한 참조를 사용하는 해시 맵이다.

이 맵은 키에 대한 약한 참조를 유지하므로, 다른 곳에서 키에 대한 강한 참조가 없으면 가비지 컬렉터에 의해 수거될 수 있다.

또한 WeakHashMap에 객체를 추가할 때 해당 키는 자동으로 약한 참조로 래핑(wrapping)된다.

이렇게 약한 참조로 저장되면, 그 키에 대한 강한 참조가 없는 경우 GC에 의해 수거 

 

강한 참조(Strong Reference)

일반적으로 객체를 참조하는 방식이다.

객체가 강한 참조에 의해 참조되고 있는 한, 가비지 컬렉터는 해당 객체를 수집하지 않는다.

String key = new String("test_key"); key 변수는 "test_key"라는 문자열 객체를 가리킨다.

이때의 참조는 강한 참조(Strong Reference)이다. 

 

약한 참조 (Weak Reference)

WeakHashMap과 같은 데이터 구조에서 사용되며, 객체에 대한 약한 참조는 그 객체가 더 이상 강한 참조로 가리켜지지 않을 때 가비지 컬렉션의 대상이 된다.

WeakReference를 사용하여 객체를 참조하면, 그 객체는 가비지 컬렉션의 대상이 될 수 있다.

따라서 메모리 부족 상황에서 약한 참조를 가진 객체는 더 쉽게 수거된다.

 

정리

마지막에 cache를 출력하면 WeakHashMap이 비어 있는 것을 확인할 수 있다.

이는 test_key가 가비지 컬렉션에 의해서 수거되었기 때문이다.

리스너 / 콜백 

클라이언트가 콜백을 등록만 하고 명확히 해지하지 않는다면 

뭔가 조치해주지 않는 한 콜백은 계속 쌓여간다. 

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

마찬가지로 WeakHashMap을 사용해서 해결할 수 있다. 

핵심 정리

메모리 누수는 발견하기 쉽지 않기 때문에 수년간 시스템에 머물러 있을 수도 있다. 

이런 누수는 코드 리뷰와 함께 힙 프로파일러 같은 디버깅 툴을 사용해서 찾아야 한다. 

따라서 이런 종류의 문제는 예방하는 방법을 학습하여 방지하는 것이 좋다.