티스토리 뷰

(Effective Java) 규칙7. 다 쓴 객체 참조를 해제하라


  • C, C++ 처럼 손수 메모리 관리를 해야 하는 언어를 쓰다 쓰레기 수집가 포함된 언어를 사용하기 시작하면 프로그래밍이 아주 쉬워진다.

    • 볼 일 없는 객체는 자동적으로 반환되기 때문에

메모리 누수가 발생하는 Stack 클래스


//"메모리 누수(memory leak)가" 어디서 생기는지 보이는가?
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);
    }
  }
}
  • 이 프로그램에는 메모리 누수 문제가 있음

    • 스택이 커졌다가 줄어들면서(pop메서드) 제거한 객체들을 쓰레기 수집기가 처리하지 못하는 문제가 발생
  • 스택이 만기 참조(obsolete reference)를 제거하지 않고 가지고 있기 때문

    • 만기 참조란, 다시 이용되지 않을 참조를 뜻함
    • elements 배열에서 실제로 사용되는 부분(active portion)을 제외한 나머지 지역에 보관됨 참조 들이 만기 참조임
  • 자동적으로 쓰레기 객체를 수집하는 언어에서 발생하는 메모리 누수 문제(의도치 않는 객체 보유)는 찾아내기 어려움

    • 실수로 객체 참조를 계속 유지하는 경우, 해당 뿐만 아니라 그 객체를 통해 참조되는 다른 객체들도 제외됨
    • 만기 참조가 몇개라도 있으면 굉장히 많은 객체가 쓰레기 수집에서 제외될 수 있음

 

null 처리를 통한 메모리 누수 해결 방안


public Object pop() {
    if (size == 0)
      throw new EmptyStackException();

    Object result =  elements[--size];
    elements[size] = null;  //만기 참조 제거
    return result;
}
  • 참조를 null로만들면 나중에 실수로 그 참조를 사용하더라도 NullPointerException이 발생하기 때문에 프로그램이 바로 종료된다는 장점이 있음

  • 위 코드의 문제는 Stack이 자체적으로 메모리를 관리 한다는 것이 문제임

    • 저장 공간 풀은 elements 배열의 원소임 (각 원소는 객체가 아닌 개체에 대한 참조)
    • 사용되는 객체는 할당된(allowcated) 객체지만, 나머지 원소가 참조하는 객체는 반환 가능한(free) 객체
      • 하지만 쓰레기 수집기에서는 elements 내의 참조들은 전부 유효(valid) 해보임
    • 사용하지 않는 객체를 null로 만들면 쓰레기 수집기는 반환해도 좋은 객체가 어떤 것인지 바로 알 수 있음
  • 단, 객체 참조를 null 처리 하는 것은 규범(norm)이라기보단 예외적인 조치가 되어야 함

  • 만기 참조를 제거하는 가장 좋은 방법은 해당 참조가 보관된 변수가 유효범위를 벗어나게 두는 것이다.

    • 변수를 정의할 때 그 유효범위를 최대한 좁게 만들면 자연스럽게 해결됨

 

메모리 누수가 흔히 발생하는 곳


  • 일반적으로, 자체적으로 관리하는 메모리가 있는 클래스를 만들때는 메모리 누수가 발생하지 않도록 주의해야 한다.

    • 더 이상 사용되지 않는 원소 객체 참조는 반드시 null로 바꿔 주어야 함

캐시(cache) 메모리

  • 문제

    • 객체 참조를 캐시 안에 넣어 놓고 잊어버리는 일이 많기 이다.
  • 해결방법

    • WeakHashMap을 가지고 캐시를 구현하는 것
      • 키에 대한 참조가 만기 참조가 되는 순간 캐시 안에 보관된 키-값 쌍은 자동으로 삭제됨
      • 단, 캐시 안에 보관되는 항목의 수명이 키에 대한 외부 참조 수명에 따라 결정되는 상황에만 적용 가능
        • 간단한 WeakHashMap 예제
        public class TestMain {
            public static void main(String[] args) {
                Map<Key, String> map = new WeakHashMap<>();
        
                Key key = new Key("name");
                // Map 에 새로운 엔트리 추가
                map.put(key, "jayden-lee");
                // Key[name]=jayden-lee
                mapPrint(map);
                // Key 객체 참조 null 처리
                key = null;
                // 강제 GC
                System.gc();
                // 빈 값 출력
                mapPrint(map);
            }
        
            private static void mapPrint(Map<?, ?> map) {
                map.entrySet().stream().forEach(System.out::println);
            }
        }
        
        class Key {
            private String name;
        
            public Key(String name) {
                this.name = name;
            }
        
            @Override
            public String toString() {
                return "Key[" + name + "]";
            }
        }
    • 후면 스레드(background thread)를 사용하여 처리 하는 방법
      • Timer, ScheduledThreaPoolExecutor 사용
    • 새로운 엔트리를 츠기힐 떼 부수 작업으로 수행하는 방법
      • LinkedHashMapremoveEldestEntry 메서드 사용
        • 간단한 LRU(leat-recently-used) 캐쉬 구현 예제
        public class LRUCache {   
            private static final float hashTableLoadFactor = 0.75f;   
            private LinkedHashMap map;   
            private int cacheSize;   
        
            public LRUCache(int cacheSize) {   
                this.cacheSize = cacheSize;   
                int hashTableCapacity = (int) Math.ceil(cacheSize / hashTableLoadFactor) + 1;   
                map = new LinkedHashMap(hashTableCapacity, hashTableLoadFactor, true) { 
                    private static final long serialVersionUID = 1;   
        
                    @Override   
                    protected boolean removeEldestEntry(Map.Entry eldest) {   
                        return size() > LRUCache.this.cacheSize;   
                    }   
                };   
            }   
        
            /**  
             * The retrieved entry becomes the MRU (most recently used) entry.  
             */  
            public synchronized V get(K key) {   
                return map.get(key);   
            }   
        
            /**  
                 * If the cache is full, the LRU (least recently used) entry is dropped.  
                 */  
            public synchronized void put(K key, V value) {   
                map.put(key, value);   
            }   
        
            public synchronized void clear() {   
                map.clear();   
            }   
        
            public synchronized int usedEntries() {   
                return map.size();   
            }   
        
            public synchronized <Collection> getAll() {   
                return new ArrayList<>(map.entrySet());   
            }   
        
            public static void main(String[] args) {   
                LRUCache c = new LRUCache(3);   
                c.put("1", "one");         // 1   
                c.put("2", "two");         // 2 1   
                c.put("3", "three");       // 3 2 1   
                c.put("4", "four");        // 4 3 2   
                if (c.get("2") == null)   
                    throw new Error();     // 2 4 3   
                c.put("5", "five");        // 5 2 4   
                c.put("4", "second four"); // 4 5 2   
                // Verify cache content.   
                if (c.usedEntries() != 3)   
                    throw new Error();   
                if (!c.get("4").equals("second four"))   
                    throw new Error();   
                if (!c.get("5").equals("five"))   
                    throw new Error();   
                if (!c.get("2").equals("two"))   
                    throw new Error();   
                // List cache content.   
                for (Map.Entry e : c.getAll())   
                    System.out.println(e.getKey() + " : " + e.getValue());   
            }   
        }

리스너(listener) 등의 역호출자(callback)

  • callback을 사용하는 클라이언트가 callback을 명시적으로 제거하지 않을 경우, 적절한 조치를 취하기 전까지 메모리는 점유된 상태로 남아 있게 된다.

  • 쓰레기 수집기가 역호출자를 즉시 처리하도록 할 가장 좋은 방법은, 역호출자에 대한 약한 참조만 저장하도록 하는 것

    • WeakHashMap의 키로 저장하는 것이 예임

 

결론


  • 메모리 누수는 보통 뚜렷한 오류로 이어지지 않기 때문에, 수년간 시스템에 남아 있는 경우도 있음

  • 따라서 문제가 생길 수 있다는 것을 사전에 인지하고 방지 대책을 세우는 것이 바람직함

     


참고


끝으로

이 글이 도움이 되었다면, Google 광고 한번씩 클릭 부탁 드립니다. 🙏🙏🙏

광고 클릭은 많은 힘이 됩니다!

반응형
댓글