일반 메모리 유출과 약한 참조

황제낙엽 2010.01.26 09:56 조회 수 : 616 추천:197

sitelink1  
sitelink2  
sitelink3  
sitelink4  
sitelink5  
sitelink6  
http://openframework.or.kr/Wiki.jsp?page=MemoryDischargeAndWeakReference

http://www-128.ibm.com/developerworks/kr/library/j-jtp11225/sidefile1.html

메모리 유출과 약한 참조#

<div class="note"> 저자 : Brian Goetz, Principal Consultant, Quiotix
작성일자 : 2005 년 11 월 22 일 </div>

<div class="note" style="margin:10px; background-color:white"> 자바 언어의 프로그램은 이론적으로는 "메모리 유출"에 대한 면역성이 있지만, 어떤 경우에는 프로그램의 논리적 상태의 일부가 더 이상 아닌데도 객체가 가비지 컬렉팅(garbage collected)이 되지 않은 상황이 있을 수 있다. 이번 달, Brian Goetz는 “의도하지 않은 객체유지에 대해 설명한다. </div>

가비지 컬렉터가 프로그램에서 더 이상 사용되지 않는 객체들을 처리하려면 객체의 논리적 수명(애플리케이션이 객체를 사용하는 시간)과 그 객체에 대한 레퍼런스의 실제 수명이 같아야 한다. 대부분의 경우 이 일은 자동으로 처리되기 때문에 우리가 객체의 수명 문제까지 신경 쓰지 않아도 된다. 하지만 가끔씩은 우리가 예상했던 것 보다 훨씬 긴 시간동안 메모리에 객체를 보유하고 있는 레퍼런스를 만들 때가 있다. 이 상황을 의도하지 않은 객체유지(unintentional object retention)라고 한다.

메모리 유출과 글로벌 맵#

의도하지 않은 객체 유지의 가장 일반적인 원인은 Map을 사용하여 메타데이터와 임시 객체들을 연결하는데 있다. 예를 들어, 클라이언트로부터의 소켓 연결 같은 중간 수명(메소드 호출 보다는 길지만 애플리케이션 보다는 짧은 수명)을 가진 객체가 있다고 가정해 보자. 몇 개의 메타데이터를 그 소켓과 연결해야 한다 그 당시에는 Socket이 만들어진다는 사실을 모르고, Socket 클래스나 인스턴스를 제어할 수 없기 때문에 Socket 객체로 데이터를 추가할 수 없다. 이 경우, 전형적인 방식은 그와 같은 정보를 글로벌 Map에 저장하는 것이다. Listing 1의 SocketManager 클래스를 보자.

Listing 1. 글로벌 Map을 사용하여 메타데이터와 객체 제휴하기

public class SocketManager {
    private Map<Socket,User> m = new HashMap<Socket,User>();
    
    public void setUser(Socket s, User u) {
        m.put(s, u);
    }
    public User getUser(Socket s) {
        return m.get(s);
    }
    public void removeUser(Socket s) {
        m.remove(s);
    }
}

SocketManager socketManager;
socketManager.setUser(socket, user);
= END of automatically generated HTML code = ========================================================

 

이 방식의 문제점은 메타데이터의 수명은 소켓의 수명과 연관되어있지만, 언제 소켓이 더 이상 프로그램에서 필요하지 않게 되는지를 알고 Map에서 상응 매핑을 제거해야 한다는 것을 깨닫지 않는 한, Socket과 User 객체들은 Map에 영원히 머무르게 된다. 요청이 다 처리되고 소켓이 닫힌 후에도 계속 머물게 된다. 이것 때문에 Socket과 User 객체들이 가비지 컬렉팅이 되지 못한다. 바로 이것이 꽤 오랫동안 메모리에서 프로그램이 실행되는 원인이 된다. 프로그램에서 더 이상 Socket이 사용되지 않을 때를 규명하는 기술은 수동 메모리 관리에 필요한 짜증나면서도 에러를 일으킬 소지가 있는 기술이다.


메모리 유출 규명하기#

프로그램이 메모리 유출 현상을 겪고 있다는 첫 번째 신호는 시스템이 OutOfMemoryError를 던지거나 빈번한 가비지 컬렉션으로 인해서 퍼포먼스가 나빠지기 시작하는 것이다. 다행히도 가비지 컬렉터는 메모리 유출을 진단할 때 사용할 수 있는 많은 정보들을 공유하고 있다. -verbose:gc 또는 -Xloggc 옵션으로 JVM을 호출하면 GC(garbage collection)가 실행하면서 진단 메시지를 콘솔 또는 로그 파일에 프린트 한다. 걸리는 시간, 현재 힙 사용, 복구된 메모리 등을 프린트한다. GC 사용을 기록하는 것은 그렇게 매력적인 일은 아니기 때문에 메모리 문제를 분석하거나 가비지 컬렉터를 튜닝해야 할 경우 제품에서 기본적으로 GC 로깅을 실행하는 것이 바람직하다.

툴들은 GC 로그 아웃풋을 취해 이를 그래픽으로 디스플레이 한다. 그 툴 중 하나가 프리 JTune(참고자료)이다. GC 후에 힙 사이즈의 그래프를 보면 프로그램의 메모리 사용 내용을 볼 수 있다. 대부분의 프로그램의 경우 메모리 사용을 두 개의 컴포넌트(기본(baseline) 사용과 현재 로드(current load) 사용)로 나눌 수 있다. . 서버 애플리케이션의 경우, 기본(baseline) 사용은 어떤 부하에도 종속되지는 않지만 요청을 받아들일 준비가 될 때 애플리케이션이 기본적으로 사용하는 것이다. 현재 로드 사용은 요청을 처리하는 프로세스에 사용되지만 요청 프로세싱이 완료되면 사라지는 것이다. 부하가 일정하면 애플리케이션은 일정하게 메모리를 사용한다. 애플리케이션이 초기화를 완료하고 부하가 증가하지 않는데도 메모리 사용 추세가 계속 상승되면, 이 프로그램은 아마도 이전 요청을 처리하는 과정에서 만들어진 객체를 보유하고 있는 것이 분명하다.

Listing 2는 메모리 유출이 있는 프로그램이다. MapLeaker는 쓰레드 풀에서 태스크룰 처리하고 각 태스크의 상태를 Map에 기록한다. 안타깝게도 태스크가 완료될 때 엔트리를 삭제하지 않기 때문에 상태 엔트리와 태스크 객체가(내부 상태와 함께) 영원히 축적된다.

Listing 2. Map 기반 메모리 유출을 가진 프로그램

public class MapLeaker {
    public ExecutorService exec = Executors.newFixedThreadPool(5);
    public Map<Task, TaskStatus> taskStatus 
        = Collections.synchronizedMap(new HashMap<Task, TaskStatus>());
    private Random random = new Random();

    private enum TaskStatus NOT_STARTED, STARTED, FINISHED };

    private class Task implements Runnable {
        private int[] numbers = new int[random.nextInt(200)];

        public void run() {
            int[] temp = new int[random.nextInt(10000)];
            taskStatus.put(this, TaskStatus.STARTED);
            doSomeWork();
            taskStatus.put(this, TaskStatus.FINISHED);
        }
    }

    public Task newTask() {
        Task t = new Task();
        taskStatus.put(t, TaskStatus.NOT_STARTED);
        exec.execute(t);
        return t;
    }
}
= END of automatically generated HTML code = ========================================================

 

그림 1은 GC 후에 MapLeaker에 대한 애플리케이션 힙 사이즈를 나타낸 그래프이다. 그래프가 계속 올라가고 있다는 것은 메모리 유출의 증거이다. (실제 애플리케이션에서, 이 슬로프는 이런 양상을 띄지는 않는다. 하지만 GC 데이터를 오랫동안 모으면 이런 모습이 된다.)

그림 1. 지속적으로 상승하는 메모리 사용

heapsize.gif

메모리 유출을 감지했다면 어떤 유형의 객체들이 문제를 일으키는지를 찾아낸다. 어떤 메모리 프로파일러로 객체 클래스로 나뉘어진 힙의 스냅샷을 만들 수 있다. 상용 힙 프로파일링 툴도 사용할 수 있지만 메모리 유출을 찾는데 돈까지 들일 필요는 없다. 빌트인 hprof 툴이 이 트릭을 수행한다. hprof를 사용하여 메모리 사용을 트래킹하도록 하려면 -Xrunhprof:heap=sites 옵션으로 JVM을 호출한다.

Listing 3은 애플리케이션의 메모리 사용 부분을 나타내는 hprof 아웃풋이다. 애플리케이션이 종료한 후 또는, Windows상에서 Ctrl+Break을 누르거나 kill -3으로 애플리케이션에 신호를 보낼 때 hprof 툴은 아웃풋을 만든다.

Listing 3. Map.Entry와 Task 객체의 성장률을 보여주는 HPROF 아웃풋

SITES BEGIN (ordered by live bytesFri Oct 28 16:30:48 2005
          percent          live          alloc'ed  stack class
 rank   self  accum     bytes objs     bytes  objs trace name
    1 70.1370.13%   5694888 13909   5694888 13909 300305 int[]
    2 18.2788.40%   1483976   68 278273632 13908 300321 int[]
    3  4.1192.51%    333816 13909    333816 13909 300310 java.util.HashMap$Entry
    4  2.7495.25%    222544 13909    222544 13909 300304 com.quiotix.dummy.MapLeaker$Task
    5  2.4297.67%    196640    2    262192    11 300325 java.util.HashMap$Entry[]
    6  0.6698.33%     53680 3355    222464 13904 300324 java.util.concurrent.LinkedBlockingQueue$Node
SITES END

SITES BEGIN (ordered by live bytesFri Oct 28 16:31:32 2005
          percent          live          alloc'ed  stack class
 rank   self  accum     bytes objs     bytes  objs trace name
    1 77.0777.07%  41176024 100020  41176024 100020 301069 int[]
    2 12.9890.05%   6933768  359 2001885688 100020 301093 int[]
    3  4.4994.55%   2400480 100020   2400480 100020 301082 java.util.HashMap$Entry
    4  3.0097.54%   1600320 100020   1600320 100020 301068 com.quiotix.dummy.MapLeaker$Task
    5  1.9699.50%   1048592    1   2097248    14 301104 java.util.HashMap$Entry[]
    6  0.0599.55%     25936 1621   1600240 100015 301101 java.util.concurrent.LinkedBlockingQueue$Node
SITES END
= END of automatically generated HTML code = ========================================================

 

Listing 4는 hprof 아웃풋의 또 다른 부분을 보여준다. Map.Entry 객체용 할당 사이트용 콜 스택 정보를 제공한다. 이 아웃풋은 어떤 호출 체인이 Map.Entry 객체들을 생성하고 있는지를 말해준다. 프로그램 분석과 더불어 이것은 매우 쉽게 메모리 유출의 원인을 알아낼 수 있는 방법이다.

Listing 4. Map.Entry 객체용 할당 사이트를 보여주는 HPROF 아웃풋

TRACE 300446:
  java.util.HashMap$Entry.<init>(<Unknown Source>:Unknown line)
  java.util.HashMap.addEntry(<Unknown Source>:Unknown line)
  java.util.HashMap.put(<Unknown Source>:Unknown line)
  java.util.Collections$SynchronizedMap.put(<Unknown Source>:Unknown line)
  com.quiotix.dummy.MapLeaker.newTask(MapLeaker.java:48)
  com.quiotix.dummy.MapLeaker.main(MapLeaker.java:64)
= END of automatically generated HTML code = ========================================================

 


약한 참조(weak reference)#

SocketManager의 문제는 Socket-User 매핑의 수명이 Socket의 수명과 매치해야 하는데 이 규칙을 실행할 쉬운 방식을 제공하는 언어가 없다는 점이다. 프로그램은 수동 메모리 관리와 비슷한 기술에 의존하게 된다. 다행히도 JDK 1.2 부터, 가비지 컬렉터가 약한 참조(weak references)를 통해 이러한 유형의 메모리 유출을 방지하도록 한다.

약한 참조는 레퍼런트(referent)라고 하는 객체 레퍼런스를 위한 홀더(holder)이다. 약한 참조를 사용하여 레퍼런트에 대한 참조를 관리할 수 있다.(가비지 컬렉팅이 가능하다.) 가비지 컬렉터가 힙을 트레이스 할 때, 객체에 대한 대표적인 레퍼런스가 약한 참조라면 레퍼런트는 GC의 대상이 된다. (약한 참조에 의해 유일하게 참조되는 객체를 weakly reachable이라고 한다.)

약한 참조의 레퍼런트는 구현 시 설정되고 이 값이 제거되지 않았다면 이 값은 get()으로 검색된다. (레퍼런트가 이미 가비지 컬렉팅 되었기 때문이거나 누군가가 WeakReference.clear()를 호출하여) 약한 참조가 제거되면 get()은 null을 리턴한다. 따라서 get()이 비 null 값을 리턴하는지 언제나 검사해야 한다. 레퍼런트는 결국 가비지 컬렉팅 될 것이기 때문이다.

일반 참조(strong reference)를 사용하여 객체 레퍼런스를 복사할 때 레퍼런트의 수명이 최소 복사된 레퍼런스의 수명만큼 되도록 제한한다. 객체에 대해 약한 참조를 구현하면 레퍼런트의 수명을 확장하지 않는다. 그저 이것이 살아있는 동안 만 관리하면 된다.

약한 참조는 약한 컬렉션(weak collection)들을 구현할 때 가장 유용하다. 이것은 정확히 SocketManager 클래스가 해야 할 일이다. 일반적인 약한 참조 사용이기 때문에, 키에 약한 참조를 사용하는 WeakHashMap도 역시 JDK 1.2에 있는 클래스 라이브러리에 추가되었다. 일반 HashMap에 키로서 객체를 사용하면 그 객체는 Map에서 매핑이 제거될 때 까지 컬렉팅 되지 않는다. WeakHashMap을 사용하여 객체가 가비지 컬렉팅 되는 것을 방지하지 않고도 Map 키로서 객체를 사용할 수 있다. Listing 5는 WeakHashMap에서의 get() 메소드의 가능한 구현이다. (약한 참조를 보여준다.)

Listing 5. WeakReference.get()의 구현

public class WeakHashMap<K,V> implements Map<K,V> {

    private static class Entry<K,V> extends WeakReference<K> 
      implements Map.Entry<K,V> {
        private V value;
        private final int hash;
        private Entry<K,V> next;
        ...
    }

    public V get(Object key) {
        int hash = getHash(key);
        Entry<K,V> e = getChain(hash);
        while (e != null) {
            K eKey= e.get();
            if (e.hash == hash && (key == eKey || key.equals(eKey)))
                return e.value;
            e = e.next;
        }
        return null;
    }
= END of automatically generated HTML code = ========================================================

 

WeakReference.get()이 호출되면(여전히 살아있다면) 레퍼런트에 대한 일반 참조를 리턴하기 때문에 while 루프의 바디에서 사라지는 매핑에 대해 걱정할 필요가 없다. 일반 참조는 가비지 컬렉팅 되는 것으로부터 이를 방지한다. WeakHashMap의 구현은 약한 참조를 가진 일반 이디엄을 의미한다. 몇몇 내부 객체는 WeakReference를 확장한다.

WeakHashMap에 매핑을 추가하면 키가 가비지 컬렉팅 되기 때문에 나중에 이 매핑은 빠질 수 있다. 결과적으로 get()은 null을 리턴하면서 null용 get()의 리턴 값을 테스트하는 것이 더 중요해진다.

유출과 WeakHashMap 연결하기#

SocketManager 에서의 유출을 픽스하는 것은 쉽다. HashMap을 WeakHashMap으로 대체하면 된다.(Listing 6) (SocketManager가 쓰레드 보안이 되어야 한다면 WeakHashMap을 Collections.synchronizedMap()으로 래핑할 수 있다.) 매핑의 수명이 키의 수명과 연결되어야 할 때마다 이 방식을 사용할 수 있다. 하지만 이 기술을 남용하지 않도록 조심한다. 대부분의 경우에는 일반 HashMap은 올바른 Map 구현으로서 사용하는 것이 옳기 마련이다.

Listing 6. SocketManager를 WeakHashMap으로 픽스하기

public class SocketManager {
    private Map<Socket,User> m = new WeakHashMap<Socket,User>();
    
    public void setUser(Socket s, User u) {
        m.put(s, u);
    }
    public User getUser(Socket s) {
        return m.get(s);
    }
}
= END of automatically generated HTML code = ========================================================

 

레퍼런스 큐 (Reference queues)#

WeakHashMap은 맵 키를 보유하기 위해 약한 참조를 사용한다. 따라서 키 객체들이 애플리케이션에서 더 이상 사용되지 않을 때 이것을 가비지 컬렉팅 되도록 하고, WeakReference.get()이 null을 리턴하는지의 여부에 따라 get() 구현은 죽은 매핑과 살아있는 매핑을 구별한다. 하지만 이것은 get()의 메모리 사용이 애플리케이션의 수명 동안 늘어나는 것을 방지하는데 필요한 것의 절반 정도에 지나지 않는다. 어떤 것은 키 객체가 컬렉팅 된 후 Map에서 죽은 엔트리를 없애는데도 수행될 수 있다. Map은 상응하는 죽은 키들에 대한 엔트리들로 채운다. 그리고 이것이 애플리케이션에 보이지 않는 동안 Map.Entry와 값 객체가 컬렉팅되지 않도록 하기 때문에 메모리 밖에서 애플리케이션을 실행시키는 원인이 된다.

주기적으로 Map을 스캐닝하고, 각각의 약한 참조에 get()을 호출하고 get()이 null을 리턴하면 그 매핑을 제거하는 방식을 통해 죽은 매핑이 제거될 수 있다. 약한 참조의 레퍼런트가 가비지 컬렉팅될 때 공지가 될 수 있는 방법이 있다면 좋을 것이다. 바로 이 일을 레퍼런스 큐(reference queues)가 수행한다.

레퍼런스 큐는 정보를 객체의 라이프 사이클 정보를 애플리케이션에 피드백하는 가비지 컬렉터의 기본적인 수단이다. 약한 참조에는 두 개의 구조체가 있다. 하나는 인자로서 레퍼런트만 취하고 다른 하나는 레퍼런스 큐를 취한다. 약한 참조는 관련 레퍼런스 큐와 함께 생성되고 레퍼런트가 GC의 후보가 되면 레퍼런스 객체(레퍼런트가 아님)는 레퍼런스가 깨끗해진 후에 레퍼런스 큐에 인큐(enqueue) 된다. 이 애플리케이션은 레퍼런스 큐에서 레퍼런스를 검색하여 레퍼런트가 컬렉팅 되었다는 것을 알면 관련 클린업 액티비티를 수행할 수 있다. 약한 컬렉션에서 제거된 객체들의 엔트리를 삭제한다. (레퍼런스 큐는 BlockingQueue와 같은 큐 해제 모드를 제공한다.)

WeakHashMap 은 대부분의 Map 연산 중에 호출되는 expungeStaleEntries()이라고 하는 프라이빗 메소드를 갖고 있다. 이것은 종료된 레퍼런스용 레퍼런스 큐를 폴링하고 관련 매핑을 제거한다. Listing 7에 expungeStaleEntries()의 구현이 나와있다. 키-값 매핑을 저장하는데 사용되는 Entry 유형은 WeakReference를 확장하여 expungeStaleEntries()가 그 다음의 종료된 약한 참조를 요청할 때 Entry를 가져온다. 콘텐트를 주기적으로 트롤링 하는 대신 Map을 청소하기 위해 레퍼런스 큐를 사용하는 것이 더 효율적이다. 살아있는 엔트리들이 클린업 프로세스에서 절대 건드리지 않기 때문이다. 실제로 인큐(enqueued) 레퍼런스가 있을 때만 수행된다.

Listing 7. WeakHashMap.expungeStaleEntries() 구현

    private void expungeStaleEntries() {
  Entry<K,V> e;
        while ( (e = (Entry<K,V>queue.poll()) != null) {
            int hash = e.hash;

            Entry<K,V> prev = getChain(hash);
            Entry<K,V> cur = prev;
            while (cur != null) {
                Entry<K,V> next = cur.next;
                if (cur == e) {
                    if (prev == e)
                        setChain(hash, next);
                    else
                        prev.next = next;
                    break;
                }
                prev = cur;
                cur = next;
            }
        }
    }
= END of automatically generated HTML code = ========================================================

 


결론#

약한 참조와 약한 컬렉션은 힙 관리에 있어 강력한 툴이다. 애플리케이션이 일반 참조의 "모 아니면 도" 식의 접근이 아닌 보다 세련된 개념의 접근 방식을 사용할 수 있기 때문이다. 다음 달에는 소프트 레퍼런스(soft references)를 연구해 보자.


참고자료#

교육

제품 및 기술 얻기

토론


번호 제목 글쓴이 날짜 조회 수
» 메모리 유출과 약한 참조 황제낙엽 2010.01.26 616
170 Methods of the Matcher Class 황제낙엽 2010.01.19 120
169 Pattern.matches() , Matcher.matches() , Matcher.find() file 황제낙엽 2010.01.19 105
168 java.lang.IllegalArgumentException 황제낙엽 2010.01.18 130512
167 org.apache.commons.fileupload.servlet.ServletFileUpload 를 이용한 파일 업로드 file 황제낙엽 2009.11.19 129
166 Error reading tld listeners java.lang.NullPointerException 황제낙엽 2009.10.14 67
165 Cannot find the tag library descriptor for “http://java.sun.com/jsp/jstl/core 황제낙엽 2009.10.14 1006
164 Transfer-Encoding: chunked VS Content-Length 황제낙엽 2009.09.17 154
163 서블릿 응답 헤더(Response Header) 황제낙엽 2009.09.17 80
162 같은 문자열인데도 정규식에서 해당 문자열을 파싱하지 못하는 경우 황제낙엽 2009.08.08 39
161 MultipartRequest (cos.jar)와 서블릿을 이용한 업로드 file 황제낙엽 2009.06.19 384
160 [대용량 파일 업로드] multipart form parser - http file upload, database 저장 java class 연재2 file 황제낙엽 2009.06.19 1831
159 [대용량 파일 업로드] multipart form parser - http file upload 기능 java class 연재1 file 황제낙엽 2009.06.19 1436
158 [reflection/리플렉션] Class.forName 황제낙엽 2009.05.27 101
157 문자열 내의 공백을 제거하는 간단한 정규식 황제낙엽 2009.05.20 88
156 문자열에서 특수 문자 (Escape Sequence) 처리 file 황제낙엽 2009.02.20 1322
155 정규표현식을 사용하는 String클래스의 replaceAll() 함수 개량 황제낙엽 2009.02.09 219
154 File 복사 함수 황제낙엽 2009.02.08 31
153 JSP session 정보 얻기 황제낙엽 2009.01.21 127
152 서버상의 로컬경로 (실제경로) 관련 환경변수 황제낙엽 2009.01.21 339