Language/Java

[이펙티브자바] 아이템 7, 8, 9 요약 정리

anxi 2024. 5. 30. 23:26

아이템 7. 다 쓴 객체 참조를 해제하라

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() 메서드에서 메모리 누수가 발생한다.

 

만약 스택 size가 10이라고 하자.

만약 내가 pop()을 통해 스택 size를 1 줄인다고 가정하면,

스택 size는 9가 될 것이고, pop()을 통해 elements[9] 값을 반환할 것이다. (index 0부터 시작)

만약 pop()을 계속 실행해서 size가 1이 된다고 치면 elements[0]의 값을 반환할 것이다.

그럼 size가 10부터 1이 되는 동안 스택에서 꺼내진 객체들은 다 어디로 갈까? 

여기서 스택은 꺼내진 객체들의 다 쓴 참조(obsolete reference)를 여전히 가지고 있기 때문에 활성 영역(size 내 존재하는 elements) 밖의 참조들이 GC에 처리되지 않고 남아있는다.

 

그렇다면 다 쓴 객체의 참조를 해제하려면 어떻게 하면 될까?

바로 null 처리를 하면 된다. null 처리를 하게 되면 참조 해제가 된다.

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

 

null 처리를 하게되면 실수로 해제된 참조를 사용할 때 NPE가 발생한다.

하지만 객체를 다 쓰자마자 null 처리를 하는 것은 바람직하지 않다. null 처리는 예외적인 경우의 참조를 처리하는 것이고

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

 

1. 자기 메모리를 직접 관리하는 클래스일 경우, 메모리 누수에 조심해야 한다. (위 스택 같은 경우)

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

-> 캐시 외부에서 키를 참조하는 동안만 엔트리가 살아있는 캐시가 필요한 상황이라면 WeakHashMap을 사용해 캐시를 만들자

(다 쓴 엔트리는 그 즉시 자동 제거된다.)

-> 시간이 지날수록 엔트리의 가치를 떨어뜨리는 방식을 흔히 사용한다. (쓰지 않는 엔트리를 청소해줘야 한다.)

 

3. 리스너 혹은 콜백

클라이언트가 콜백을 등록만 하고 명확히 해지하지 않을 경우 콜백은 계속 쌓여갈 것이다.

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

 

아이템 8. finalizer와 cleaner 사용을 피하라

자바는 두 가지 객체 소멸자를 제공한다 : finalizer, cleaner

단점(3)

1. 예측할 수 없다. (즉시 수행된다는 보장 X)

2. 느리다. (성능 문제)

- GC :12ns <-> filalizer : 550ns, cleaner : 500ns

3. 상황에 따라 위험할 수 있다. (finalizer 공격 존재)

활용 예 (2)

1. 안전망

자원의 소유자가 close 메서드를 호출하지 않는 것에 대비한 안전망 역할

즉시 호출된다는 보장은 없지만 클라이언트가 하지 못한 자원 회수를 늦게라도 해주는 역할을 한다.

ex) FileInputStream, FileOutputStream, ThreadPoolExecutor

 

2. 네이티브 피어와 연결된 객체

네이티브 피어 : 일반 자바 객체가 네이티브 메서드를 통해 기능을 위임한 네이티브 객체 (자바 객체 X)

네이티브 피어는 자바 객체가 아니기 때문에 GC에서 존재를 알지 못한다. 이때 회수하기 위해 위 소멸자들을 사용한다.

 

하지만! 위와 같이 소멸자를 사용하지 말고 try-with-resource와 try-finally를 사용해 해결한다.

 

아이템 9. try-finally 보다는 try-with-resources를 사용하라

InputStream, OutputStream, java.sql.Connection 등 close 메서드를 통해서 자원을 직접 닫아줘야 하는 경우가 있다.

이런 자원들은 클라이언트가 자원 닫는 것을 놓칠 수 있기 때문에 finalizer가 존재하지만 위 단점들 때문에 믿을 수 없다.

 

전통적으로 자원이 제대로 닫힘을 보장하기 위해 try-finally를 사용했다.

 

try {
   작업1
   try {
   		작업2
        	try {
               작업3
   ...} finally {
   			작업3.close
   } finally {
   		작업2.close
  }
} finally {
	작업1.close
}

 

하지만 작업이 많아질수록 코드가 너무 지저분해지고, 각 작업마다 예외가 발생하면 예외가 그 다음 예외를 집어삼킬 수 있어 디버깅에 용이하지 못하다. 또한 close 하는 것도 놓칠 수 있다.

 

이를 방지하기 위해 try-with-resources가 자바 7 이후 나왔다.

 

// try-with-resources
try (변수1 = new 자원객체1();
     변수2 = new 자원객체2()) {
    ...
}

 

위와 같이 사용하는데 괄호 안에 자원 객체를 선언하면 try가 종료될 때 자동으로 close를 호출하여 자원을 해제한다.

 

 

 

선언되는 자원은 AutoCloseable 인터페이스를 구현해야 한다. 

따라서 자바 7 이상이고 AutoCloseable를 구현할 수 있다면 try-with-resources를 사용하길 바란다.