자바는 가비지 컬렉터가 있으므로, 개발자는 메모리 관리에 신경쓰지 않아도 된다고 오해할 수 있으나, 사실을 전혀 그렇지 않다.
물론 C같은 언어에 비해 신경쓸 부분이 적어지는건 사실이지만..
다음 코드를 보며 설명을 이어가겠다.
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);
}
}
}
위 코드에서 문제점을 하나 찾아보자.
어떠한 테스트에도 정상적으로 작동할 것이다.
그러나 보이지 않는 문제가 하나 숨어있는데, 바로 '메모리 누수(memory leak)'이다.
이 Stack을 사용하는 프로그램을 오래 실행하다 보면 점차 가비지 컬렉션 활동과 메모리 사용량이 늘어나 결국 성능이 저하될 것이다.
상대적으로 드문 경우긴 하지만, OutOfMemoryError를 일으켜 프로그렘을 종료시킬 수도 있다.
그래서 위 코드 중에서 어느 부분에서 메모리 릭이 발생할까?
위 코드에서는 스택이 커졌다가 줄어들었을 때, 스택에서 꺼내진 객체들을 가비지 컬렉터가 회수하지 않는다.
해당 Stack이 그 객체들의 다 쓴 참조(obsolete reference)를 여전히 가지고 있기 때문이다.
여기서 다 쓴 참조란, 앞으로 다시는 쓰지 않을 참조를 의미한다.
위의 코드에서는 elements 배열의 '활성 영역' 밖의 참조들이 모두 여기(다 쓴 참조)에 해당한다.
활성 영역은 인덱스가 size보다 작은 원소들로 구성된다.
가비지 컬렉션을 사용하는 언어(대표적으로 JAVA)에서는 의도치 않게 객체를 살려두는 메모리 릭을 찾기가 아주 까다롭다.
객체 참조 하나를 살려주면, 가비지 컬렉터는 그 객체뿐 아니라 그 객체가 참조하는 모든 객체(그리고 또 그 객체들이 참조하는 매우 많은 객체들)을 회수해가지 못한다.
그래서 단 몇 개의 객체가 매우 많은 객체를 회수하지 못하게 만들 수도 있으며, 이는 성능에 악영향을 미칠 수 있다.
해법은 간단한데, 해당 참조를 모두 사용한 경우 null처리 해주면 된다.
예시의 스택 클래스에서 각 원소의 참조를 모두 사용한 경우는 스택에서 꺼내질 때(pop 메서드가 실행)이며, 이제 제대로 구현한 코드는 다음과 같다.
public Object pop(){
if(size == 0) throw new EmptyStackException();
Object result = elements[--size];
elements[size] = null;//다 쓴 참조 해제
return result;
}
사실 이번 설명만 들으면 모든 객체들 다 쓰자마자 일일이 null 처리를 해주는 데 혈안이 될 수도 있다.
그러나 그럴 필요도 없고, 바람직하지도 않다. 이는 프로그램을 필요 이상으로 지저분하게 만들 뿐이다.
객체 참조를 null처리하는 일은 예외적인 경우여야 한다.
다 쓴 참조를 해제하는 가장 좋은 방법은 그 참조를 담은 변수를 유효 범위(scope) 밖으로 밀어내는 것이다.
변수의 범위를 최소가 되게 정의했다면(아이템 57) 이 일은 자연스럽게 이뤄진다.
그렇다면 null처리는 언제 해야 할까? 우리가 만들었던 Stack 클래스는 왜 메모리 릭에 취약한걸까?
바로 스택이 자기 메모리를 직접 관리하기 때문이다.
이 스택은 (객체 자체가 아니라 객체 참조를 담는) elements 배열로 저장소 풀을 만들어 원소들을 관리한다.
배열의 활성 영역에 속한 원소들이 사용되고, 비활성 영역은 쓰이지 않는다.
문제는 가비지 컬렉터는 이 사실을 모른다는 것이다.
가비지 컬렉터가 보기에는 비활성 영역에서 참조하는 객체도 똑같이 유효한 객체이다.
비활성 영역의 객체가 더 이상 쓸모없다는 건 프로그래머만 아는 사실이다.
그러므로 프로그래머는 비활성 영역이 되는 순간 null처리해서 해당 객체를 더는 쓰지 않을 것임을 가비지 컬렉터에 알려야 한다.
일반적으로 자기 메모리를 직접 관리하는 클래스라면 프로그래머는 항시 메모리 릭에 주의해야 한다.
원소를 다 사용한 즉시 그 원소가 참조한 객체를 모두 null 처리 해주어야 한다.
캐시 역시 메모리 누수를 일으키는 주범이다.
객체 참조를 캐시에 넣고 나서, 이 사실을 까맣게 잊은 채 그 객체를 다 쓴 뒤로도 한참을 그냥 놔두는 일을 자주 접할 수 있다.
해법은 여러 가지가 있다.
만약 운 좋게도 캐시 외부에서 키(key)를 참조하는 동안만 엔트리가 살아 있는 캐시가 필요한 상황이라면 WeakHashMap[http://blog.breakingthat.com/2018/08/26/java-collection-map-weakhashmap/]을 사용해 캐시를 만들자. 다 쓴 엔트리는 그 즉시 자동으로 제거될 것이다.
캐시를 만들 때 보통은 캐시 엔트리의 유효 기간을 정확히 정의하기 어렵기 때문에, 시간이 지날수록 엔트리의 가치를 떨어뜨리는 방식을 흔히 사용한다.
이런 방식에서는 쓰지 않는 엔트리를 이따금 청소해줘야 한다.
(ScheduledThreadPoolExecutor 같은) 백그라운드 스레드를 활용하거나, 캐시에 새 엔트리를 추가할 때 부수 작업으로 수행하는 방법이 있다.
LinkedHashMap은 removeEldestEntry 메서드를 써서 후자의 방식으로 처리한다.
메모리 릭의 세 번째 주범은 바로 리스너(listener) 혹은 콜백(callback)이라 부르는 것이다.
클라이언트가 콜백을 등록만 하고 명확히 해지하지 않는다면 뭔가 조치해주지 않는 한 콜백은 계속 쌓여갈 것이다.
이럴 때 콜백을 약한 참조(weak reference)로 저장하면 가비지 컬렉터가 즉시 수거해간다. 예를 들어 WeakHashMap에 키로 저장하면 된다.
회고
음... 솔직히 이번 장에서 캐시라던지 WeakHashMap 등등, 한 번도 사용해보지 않았던 것들, 심지어는 처음 듣는 용어들도 너무 많이 나왔기에 중간 이상부터는 거의 이해를 하지 못했습니다.
'예를 들어 이렇게 하면 된다' 등의 예시를 이해하지 못하고 넘어간게 대부분인데, 이는 아직 저의 실력이 부족한 것이기 때문에... 좀 더 자바에 대해서 공부한 후, 다시 한번 책을 공부하면서 그때 내용을 추가하도록 하겠습니다.
참.. 어렵네요 ㅠㅠ
'☕️ Java > 이펙티브 자바' 카테고리의 다른 글
[Effective Java] 아이템 9 - try-finally 보다는 try-with-resources를 사용하라 (0) | 2022.02.04 |
---|---|
[Effective Java] 아이템 8 - finalizer와 cleaner 사용을 피하라 (0) | 2022.02.04 |
[Effective Java] 아이템 6 - 불필요한 객체 생성을 피하라 (0) | 2022.02.03 |
[Effective Java] 아이템 5 - 자원을 직접 명시하지 말고 의존 객체 주입을 사용하라 (0) | 2022.02.02 |
[Effective Java] 아이템 4 - 인스턴스화를 막으려거든 private 생성자를 사용하라 (0) | 2022.02.02 |