C, C++을 사용해본 사람은 알겠지만, 해당 언어들은 메모리를 사용자가 직접 관리해주어야 한다.
예를 들어, malloc 함수를 사용해 동적으로 힙 영역에 배열을 만들었다면, 더이상 쓰이지 않는 시점에 free 함수를 통해 메모리 할당을 풀어주어야 한다.
이런 세심한 부분들을 모두 고려하는 것은 코드가 복잡해질 수록 개발자들에게 큰 부담을 안겨주기도 하지만 이후에 문제가 발생해도 원인을 찾아내기가 여간 어려운 것이 아니다.
하지만 Java에는 GC가 더 이상 참조되지 않는 객체들을 알아서 회수해가는 역할을 대신해준다.
물론 프로그래머에게 이는 매우 유용한 기능이긴 하지만 더이상 다 쓴 객체를 고려해주지 않아도 됨을 의미하지는 않는다.
GC는 '더 이상 참조되지 않는' 객체를 회수하는데, 어쨌건 GC가 제 기능을 다 하기 위해선 해당 조건을 충족시켜 주어야만 하는 것이다.
✒️ 가비지 컬렉터(GC, Garbage Collector)
더 이상 쓰지 않는 메모리를 자동으로 찾아서 제거한다. Heap 메모리 재활용을 위해 참조되지 않은 객체들을 해제하여 프로그래머 대신 메모리 관리를 해준다.
• pros: 프로그래머가 매번 메모리를 신경쓰지 않아도 되므로 개발 속도가 향상된다.
• cons: GC는 그 즉시 동작하지 않는다. 메모리를 회수하는 시점을 단정지을 수 없어 오버헤드로 인한 속도 지연이 발생한다.
스택을 간단하게 구현한 다음 코드에서 문제점을 찾아내보자. (뒤에 정답을 보지 말고, 꼭 스스로 찾아보자.)
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);
}
}
위 스택은 기능 상으로 문제는 없다.
하지만 해당 스택을 사용하는 프로그램은 시간이 지날 수록 GC 활동과 메모리 사용량이 늘어나 성능 저하가 일어날 것이다.
그 이유는 바로 메모리 누수(Memory Leak)가 일어나기 때문이다.
✒️ 메모리 누수(Memory Leak)
동적 할당한 뒤, 해제를 하지 않으면 프로그램이 메모리 공간을 계속 유지하여, 이후 메모리가 부족해서 발생하는 현상이다.
메모리 누수를 방치하면, 심한 경우 OOM(Out Of Memory) 현상이 발생해서 애플리케이션이 더 이상 동작하지 못 하여 JVM이 종료되는 최악의 상황으로 이어질 수 있다.
왜냐하면, 이 의도치 않은 객체를 살려두기 위해서 그 객체가 참조하는 모든 객체, 그리고 또 그 객체들이 참조하는 모든 객체들을 회수하지 못하고 방치해둔다.
그래서 고작 몇 개의 객체로 인해서, 수많은 객체들을 GC가 회수하지 못하여 성능에 악영향을 미치게 되는 것이다.
현재 작성된 코드 상으로 문제점은 pop() 과정에서 발생한다.
원소를 push 하다가 pop을 하면, 꺼내진 객체는 더이상 스택이 참조하고 있을 필요가 없다.
하지만 스니펫에서는 pop이 작동해도 size의 값을 조절하여 활성 영역을 조절할 뿐, size 크기보다 큰 인덱스에서는 여전히 불필요한 원소들을 참조함으로써 메모리를 점유하고 있도록 만든다.
이를 다 쓴 참조(obsolete reference, 앞으로 다시 쓰지 않을 참조)를 유지하고 있다고 말하는데 해결책은 단순하다.
public Object pop() {
if (size == 0)
throw new EmptyStackException();
Object result = elements[--size];
elements[size] = null; // 다 쓴 참조 해제
return result;
}
elements에서 size 이상의 원소는 불필요하므로 null 처리를 하여 참조를 해제하면, 해당 객체는 GC에 의해 메모리 영역에서 제거된다.
null 처리한 참조를 실수로 사용하려 하면 프로그램이 NullPointerException을 던지며 종료할 수도 있다.
그런데 차라리 이게 나은 것이다. 에러를 초기에 빠르게 잡아낼 수 있으니, 디버깅에도 용이하다.
(조금 다른 케이스지만 필자는 예전에 C언어로 리눅스 명령어를 구현하는 과정에서 배열의 인덱스를 벗어나 이상한 값을 들고오는 경우를 봤었다. 배열이 할당된 메모리 영역에 바로 근접한 곳에 다른 값이 있다면, 포인터는 이 쓰레기 값을 들고오는데 디버깅이 매우 힘들어진다.)
✒️ null 처리는 예외적이어야 한다.
필요 없는 객체가 있을 때마다 null 처리를 하는 것은 프로그램을 필요 이상으로 지저분하게 만든다.
중요한 건, "null 처리를 해야 한다"가 아니라 그 참조를 담은 변수를 "유효 범위(scope) 밖으로 밀어낸다"에 있다.
따라서, 변수의 범위를 최소가 되게 정의(Item 57)했다면 이 작업은 자연스럽게 이루어진다.
public Object pop() {
Object obj = "something";
...
obj = null; // 불필요하다. pop이 종료되면, obj는 GC에 의해 정리된다.
}
📌 왜 위의 Stack이 메모리 누수에 취약점이 발생했을까?
자기 메모리를 직접 관리하는 클래스라면 메모리 누수에 주의해야 한다.
Stack은 객체 자체가 아니라 객체 참조를 담는 elements 배열로 저장소 풀을 만들어 직접 관리한다.
활성 영역에 속한 원소들을 제외하고 불필요 하다는 것을 프로그래머는 알지만 GC는 이 사실을 알 길이 없다.
(GC 입장에서는 비활성 영역의 원소도 여전히 참조되고 있다면, 여전히 유효한 객체로 판단한다.)
그러므로, 프로그래머는 비활성 영역이 되는 순간 null 처리를 하여 해당 객체는 더이상 쓰이지 않음을 GC에 알려주어야 한다.
이 과정은 원소를 다 사용한 즉시 그 원소가 참조한 객체들을 다 null 처리해주어야 한다.
📌 캐시(Cache) 역시 메모리 누수의 주범이다.
객체 참조를 캐시에 넣어두고서, 사용이 끝났음에도 비우는 것을 잊어먹는 경우가 있다.
public class Cache {
public static void main(String[] args) {
Object key = new Object();
Object value = new Object();
Map<Object, List> cache = new HashMap<>();
cache.put(key, value);
...
}
}
해당 케이스에서는 key 사용이 없어져도 cache가 key의 레퍼런스를 가지므로, GC의 대상이 될 수 없다.
1️⃣ WeakHashMap
이에 대해 여러 방법들이 있지만, 캐시의 키에 대한 레퍼런스가 캐시 밖에서 필요 없어지면 해당 엔트리를 캐시에서 자동으로 비워주는 WeakHashMap을 쓸 수 있다.
public class Cache {
public static void main(String[] args) {
Object key = new Object();
Object value = new Object();
Map<Object, List> cache = new WeakHashMap<>();
cache.put(key, value);
...
}
}
WeakHashMap은 key 값을 모두 weak reference로 감싸서 hard reference가 없어지면 GC의 대상이 되도록 만들어 제거한다.
단, WeakHashMap은 이러한 상황에서만 유용하므로 사용하기 적당한지를 우선 고려하자.
✒️ 약한 참조(Weak Reference)
• 강한 참조 (Strong Reference)
→ Integer i = 1; 과 같이 가장 일반적인 참조 유형이다. 이 객체를 가리키는 강한 참조가 있는 객체는 GC 대상이 될 수 없다.
• 부드러운 참조 (Soft Reference)
→ SoftReference<Integer> soft_i = new SoftReference(i); 로 SoftReference Class를 이용해 생성 가능하다. i == null; 로 널 처리가 되어 원본이 없어지면 GC 대상으로 판단된다. 다만, GC가 처리하는 우선 순위가 다소 낮기 때문에 메모리가 충분하다면 굳이 회수하지 않는다. 이러한 이유로 덜 엄격한 Cache를 다룰 때 사용한다.
• 약한 참조 (Weak Reference)
→ 마찬가지로 강한 참조가 null 처리되면 GC 대상이 되지만, 메모리가 부족하지 않아도 회수 대상이 되어 다음 GC 작동 시 반드시 사라진다.
2️⃣ Background Thread
캐시 엔트리 유효기간을 정확히 정의하기 어려워 시간이 지날 수록 엔트리 가치를 떨어트리는 방식에선, 쓰지 않는 엔트리를 이따금 청소해주어야 한다.
ScheduledThreadPoolExecutor 같은 백그라운드 스레드를 활용하거나 캐시에 새 엔트리를 추가할 때 부수 작업으로 수행하는 방법이 있다.
그 예시로 LinkedHashMap이 있는데, removeEldestEntry 메서드를 사용한다.
void afterNodeInsertion(boolean evict) { // possibly remove eldest
LinkedHashMap.Entry<K,V> first;
if (evict && (first = head) != null && removeEldestEntry(first)) {
K key = first.key;
removeNode(hash(key), key, null, false, true);
}
}
물론 더 복잡한 캐시를 다루는 방법이 있지만, java.lang.ref 패키지나 다른 라이브러리를 사용하는 내용이므로 포스팅에서는 다루지 않을 것이다.
📌 GC 로그
GC가 수행되는 시점을 예측하긴 어렵지만, 적어도 GC가 수행되고 있는지 여부를 확인하는 방법은 있다.