[교재 EffectiveJava] 아이템 7. 다 쓴 객체 참조를 해제하라.

반응형
728x90
반응형

예제로 보는 메모리 누수

자바는 가비지 컬렉터를 갖춘 언어이기 때문에 다 쓴 객체를 알아서 해제해준다. 이는 메모리 관리에 더이상 신경쓰지 않아도 된다고 오해할 수 있는데, 절대 사실이 아니다.

 

메모리 누수가 일어나는 위치는 어디인가?
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);
    }

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

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

 

스택을 간단히 구현한 코드다. 특별한 문제는 없어보이고, 코드를 실행하더라도 정상적으로 수행된다. 하지만 여기에 숨어있는 문제가 하나 있는데, 이는 바로 '메모리 누수'다.

이 스택을 사용하는 프로그램을 오래 실행하다보면 점차 가비지 컬렉션 활동과 메모리 사용량이 늘어나 결국 성능이 저하된다. 심각할 경우 OutOfMemoryError이 발생하여 프로그램이 예키지 않게 종료될 수도 있다.

 

▶ 그렇다면, 메모리 누수는 어디서 발생한다는 것일까? 

1) 스택이 커졌다가 줄어들었을때 스택에서 꺼내진 객체들을 가비지 컬렉터가 회수하지 않는다. 프로그램에서 더이상 그 객체들을 사용하지 않더라도 말이다.

private Object[] elements; // 데이터를 쌓아놓는 경우, 언제 참조해제를 해야하는가?를 염두해야한다.

 

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

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

pop() 메서드가 수행되더라도 elements에 이미 들어간 객체들에 어떠한 행위도 하지 않고있다.

 

다 쓴 참조

문자 그대로 앞으로 다시 쓰지 않을 참조를 뜻한다. elements 배열의 '활성 영역' 밖의 참조들이 모두 여기에 해당한다. 활성 영역은 인덱스가 size보다 작은 원소들로 구성된다. 

 

가비지 컬렉션 언어에서는 메모리 누수를 찾기가 아주 까다롭다. 객체 참조 하나를 살려두면 가비지 컬렉터는 그 객체뿐 아니라 그 객체가 참조하는 모든 객체, 그리고 또 그 객체들이 참조하는 모든객체 등을 회수하지 못한다. 그래서 단 몇개의 객체가 매우 많은 객체를 회수되지 못하게 할 수 있고, 잠재적으로 성능에 악영향을 줄 수 있다.

 

 

해결 - 참조 해제

해당 참조를 다 썼을때 null 처리(참조 해제)를 하면 된다. 

 

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

 

1) 참조 해제

스택 클래스에서는 각 원소의 참조가 더이상 필요없어지는 시점은 스택에서 꺼내질때다. 꺼낼때 null 처리로 참조 해제를 한다.

 

하지만 이 방법은 코드가 지저분해진다. 객체 참조를 null 처리하는 일은 예외적인 경우여야 한다. 

 

참조 해제의 가장 좋은 방법

그 참조를 담은 변수를 유효 범위(scope) 밖으로 밀어내는 것이다. 

참조를 변수 유효범위 밖으로 밀어내는 일은 변수의 범위를 최소로 정의했다면 자연스럽게 이루어진다. 

ex) 메서드의 지역변수는 메서드가 종료되면 참조 해제된다.

 

2) 왜 메모리 누수에 취약한걸까? 스택은 위의 null 처리로 해결해야하는 예외적인 상황이다.

스택이 자기 메모리를 직접 관리하기 때문이다. 이 스택은 elements 배열로 저장소 풀을 만들어 원소들을 관리한다. 배열의 활성 영역에 속한 원소들이 사용되고 비활성 영역은 쓰이지 않는다.

 

문제는 가비지 컬렉터는 이 사실을 모른다는 것이다. 가비지 컬렉터가 보기에는 비활성 영역에서 참조하는 객체도 똑같이 유효 객체다. 비활성 영역의 객체가 더이상 쓸모없다는 건 프로그래머만 안다. 따라서 프로그래머는 비활성 영역이 되는 순간 null 처리를 해서 가비지 컬렉터에게 알려야한다.

 

일반적으로 자기 메모리를 직접 관리하는 클래스라면 프로그래머는 항시 메모리 누수에 주의해야한다. 원소를 다 사용한 즉시 그 원소가 참조한 객체를 다 null 처리해줘야 한다.

 

 

WeakHashMap

캐시

캐시도 메모리 누수를 일으키는 주범이다. 객체 참조를 캐시에 넣고나서, 이 사실을 잊은 채 그 객체를 다쓴 뒤로도 한참을 그냥 놔두는 일을 자주 접할 수 있다.

 

캐시 외부에서 키(key)를 참조하는 동안만 엔트리가 살아있는 캐시가 필요한 상황일 경우 사용하기 적합하다. WeakHashMap을 사용하여 캐시를 만들면, 다 쓴 엔트리는 그 즉시 자동으로 제거된다.

  • weakReference를 key로 가진다.
  • Reference에는 soft, strong 등이 있다.
  • key가 더이상 참조가 안되면 그 key로 가지고있는 데이터를 map에서 삭제한다.  (GC; 가비지 컬렉션의 대상이 된다.)

 

 

예제 - '이펙티브 자바 1부' 강의 참고

 

1. WekHashMap의 Key를 메서드 내부에서 선언

public class PostRepository {

    private Map<CacheKey, Post> cache;

    public PostRepository() {
        this.cache = new WeakHashMap<>();
    }
    
    public Post getPostById() {
        CacheKey key = new CacheKey(1);
        
        if (cache.containsKey(key)) {
            return cache.get(key);
        } else {
            // TODO DB에서 읽어오거나 REST API를 통해 읽어올 수 있습니다.
            Post post = new Post();
            cache.put(key, post);
            return post;
        }
    }

    public Map<CacheKey, Post> getCache() {
        return cache;
    }
}

 

호출
class PostRepositoryTest {

    @Test
    void cache() throws InterruptedException {
        PostRepository postRepository = new PostRepository();
        postRepository.getPostById();

        System.out.println("run gc");
        System.gc();
        System.out.println("wait");
        Thread.sleep(3000L);
        
        assertTrue(postRepository.getCache().isEmpty());
    }
}

PostRepository 클래스의  getPostById() 메서드 내부에 CacheKey 객체를 선언하여 Map에 넣었다. 이 경우에는 WeakHashMap의 특성에 따라 GC의 대상이 된다.

 

 

2. WekHashMap의 Key를 메서드 매개변수로 전달

public class PostRepository {

    private Map<CacheKey, Post> cache;

    public PostRepository() {
        this.cache = new WeakHashMap<>();
    }
    
    public Post getPostById(CacheKey key) {
        if (cache.containsKey(key)) {
            return cache.get(key);
        } else {
            // TODO DB에서 읽어오거나 REST API를 통해 읽어올 수 있습니다.
            Post post = new Post();
            cache.put(key, post);
            return post;
        }
    }

    public Map<CacheKey, Post> getCache() {
        return cache;
    }
}

 

호출
class PostRepositoryTest {

    @Test
    void cache() throws InterruptedException {
        PostRepository postRepository = new PostRepository();
        CacheKey key1 = new CacheKey(1);
        postRepository.getPostById(key1);

        key1 = null;
        
        // TODO run gc
        System.out.println("run gc");
        System.gc();
        System.out.println("wait");
        Thread.sleep(3000L);
        
        assertTrue(postRepository.getCache().isEmpty());
    }
}

PostRepository 클래스의  getPostById() 메서드의 매개변수로 CacheKey 객체를 받아오므로, CacheKey를 생성하여 메서드 호출과 함께 넘기고있는 PostRepositoryTest 클래스의 cache() 메서드 내부에서 참조 해제를 수행해야한다. 그렇지 않으면 GC의 대상으로 잡히지 않는다.

key1 = null;

매개변수로 넘긴 CacheKey를 null 처리 함으로써 참조 해제한다. 이후 WeakHashMap의 특성에 따라 GC의 대상이 된다.

 

3. WekHashMap의 Key의 타입을 Integer로 선언

public Post getPostById(Integer key) {
    if (cache.containsKey(key)) {
        return cache.get(key);
    } else {
        // TODO DB에서 읽어오거나 REST API를 통해 읽어올 수 있습니다.
        Post post = new Post();
        cache.put(key, post);
        return post;
    } // LRU (가장 최근에 사용된 캐시) 자료구조
    return new Post();
}

Integer로 선언하였을 경우, WeakHashMap의 해당 key가 GC의 대상이 될 수 없다. WeakHashMap을 쓸때 Custom한 레퍼런스 타입을 쓸 때만 GC의 대상이 될 수 있고, Integer과 같이 Wrapper 타입, 원시 타입(Map의 key가 될 수 없지만) 등의 경우에는 JVM 내에서 캐싱을 하기 때문에 GC의 대상이 될 수 없다. 

 

 

반응형

Designed by JB FACTORY