자바 전공 수업에서 교수님이 자바의 특징 중에서 강조하신 것이 가비지 컬렉터를 가진 언어라는 것이었다.
C나 C++ 처럼 일일이 메모리를 해제해주지 않아도 되어서 매우 편했지만 이렇게 맘 편히 메모리 관리에 신경 쓰지 않아도 되는 것이 아니었다!
자바에서는 메모리 관련해서 숨겨져 있는 문제가 있는데 바로 '메모리 누수'이다.
메모리 누수가 일어나는 3가지 경우를 살펴보자.
1. 자기 메모리를 직접 관리하는 클래스
대표적인 예를 살펴보자.
public class Stack {
private Object[] elements;
private int top = 0;
private static final int DEFAULT_INITIAL_CAPACITY = 16;
public Stack() {
elements = new Object[DEFAULT_INITIAL_CAPACITY];
}
public void push(Object e) {
ensureCapacity();
elements[top++] = e;
}
public Object pop() {
if (top == 0)
throw new EmptyStackException();
return elements[--top];
}
private void ensureCapacity() {
if (elements.length == size)
elements = Arrays.copyOf(elements, 2 * size + 1);
}
자바로 구현한 Stack 클래스이다.
이 코드에서 메모리 누수는 어디서 일어날까?
pop 메서드에서 스택이 줄어들 때 스택에서 꺼내진 객체들을 가비지 컬렉터가 회수하지 않는다. 이 스택이 객체들의 다 쓴 참조 (obsolete reference)를 여전히 가지고 있기 때문이다 여기서 다 쓴 참조는 앞으로 다시 쓰지 않을 참조 즉 elements 배열의 활성 영역 밖의 참조들이다.
이 스택은 객체 자체가 아니라 객체 참조를 담는 elements 배열로 저장소 풀을 만들어 원소들을 관리한다. 따라서 가비지 컬렉터는 배열의 비활성 영역에서 참조하는 객체도 똑같이 유효한 객체로 인식한다. 비활성 영역의 객체 즉 pop() 메서드를 사용하여 top 보다 크기가 큰 배열의 인덱스를 사용하지 않는다는 것은 프로그래머만 아는 사실이다.
의문점 : 원래 객체 배열은 모두 객체 참조를 담는 배열로 알고 있었는데 객체 자체를 담는 배열이 있는 것인가?
해법은 해당 참조를 다 썼을 때 null 처리 (참조 해제) 하는 것이다.
그렇다면 null 처리를 사용하여 pop 메서드를 제대로 구현해 보자
public Object pop() {
if (top == 0)
throw new EmptyStackException();
Object result = elements[--top];
elements[top] = null; // 다 쓴 참조 해제
return result;
}
위와 같이 다 쓴 참조를 null 처리하면 만약 실수로 다 쓴 참조를 사용하려 해도 프로그램이 즉시 NullPoiinterException을 던지며 종료시켜 준다.
이처럼 null 처리를 하여 문제를 해결할 수도 있지만 다 쓴 참조를 해제하는 가장 좋은 방법은 그 참조를 담은 변수를 유효 범위 밖으로 밀어내는 것이다.
내가 이해한 예를 들면 가변배열 ArrayList에서 remove() 메서드를 통해 특정 인덱스를 리스트 내에서 제거하면 해당 인덱스가 가리키고 있는 객체의 참조 상태가 unreachable로 바뀌므로 이걸 말하는 게 아닌가 싶다.
객체 참조 하나를 살려두면 가비지 컬렉터는 그 객체뿐 아니라 그 객체가 참조하는 모든 객체를 회수해가지 못한다.
자기 메모리를 직접 관리하는 클래스라면 프로그래머는 항시 메모리 누수에 주의해야 한다!
의문점 : 왜 stack을 구현할 때 가변배열을 사용하지 않은 것인가?
2. 캐시
캐시 역시 메모리 누수를 일으키는 주범이다. 객체 참조를 캐시에 넣고 나서, 이 사실을 까맣게 잊은 채 그 객체를 다 쓴 뒤로도 한참을 놔두는 경우가 있다. 아래 3가지 해법을 살펴보자.
1. WeakHashMap
일반적인 HashMap의 경우 일단 Map안에 Key와 Value가 put 되면 사용여부와 관계없이 해당 내용은 삭제되지 않는다. 하하지만 Map안의 Element들이 일부는 사용되고 일부는 사용되지 않을 수 있는 경우도 있다.
이때 메모리 누수가 일어나는 예는 Key에 해당하는 객체가 더 이상 존재하지 않게 되는 경우이다. 만약 어떤 객체가 null이 되어 버리면 해당 객체를 key로 하는 HashMap의 Element도 더이상 꺼낼 일이 없는 경우가 발생하는데 여기서 메모리 누수가 발생하는 것이다.
이러한 경우를 대비해 WeakHashMap을 사용해 보자.
WeakHashMap은 key 값이 null이 되면 HashMap의 Element를 자동으로 제거해버린다.
public class WeakHashMapTest {
public static void main(String[] args) {
WeakHashMap<Integer, String> map = new WeakHashMap<>();
Integer key1 = 1000;
Integer key2 = 2000;
map.put(key1, "test a");
map.put(key2, "test b");
key1 = null;
System.gc(); //강제 Garbage Collection
map.entrySet().stream().forEach(el -> System.out.println(el));
}
}
2000=test b
위와 같이 key1은 제거된다.
단, 캐시 외부에서 키를 참조하는 동안만 엔트리(key , value)가 살아 있는 캐시가 필요한 상황에서만 사용하자.
2. ScheduledThreadPollExecutor 백그라운드 스레드 활용
캐시를 만들 때 보통은 캐시 엔트리의 유효 기간을 정확히 정의하기 어렵기 때문에 시간이 지날수록 엔트리의 가치를 떨어뜨리는 방식을 사용한다.
이런 방식에서는 쓰지 않는 엔트리를 청소해주어야 하는데 그 방법 중 하나다.
3. LinkedHashMap에서 removeEldestEntry 사용
캐시에 새 엔트리를 추가할 때 부수 작업으로 수행하는 방식
LinkedHashMap 은 우선 기본적으로 HashMap을 상속받아 만들었는데 차이점은 바로 순서라는 개념이 도입된 것이다.
기존 HashMap은 순서의 개념이 없어 Iterator 를 사용해 출력을 하여도 입력한 순서대로 출력되지 않고 무작위로 출력되지만 LinkedHashpMap은 입력 순서 그대로 출력된다. 예를 들어 HashMap에 오름차순대로 1부터 10 까지 숫자를 put 했다면 Iterator를 사용해 출력해도 무작위 순서가 출력 되지만 LinkedHashMap은 입력한 순서 그대로 1~10이 출력된다.
이러한 순서의 개념을 가지고 있는 LinkedHashMap은 removeEldestEntry()라는 메서드를 가지고 있는데 이 메서드는 put을 할 때 불리게 되어 들어온 순서를 기억해 LinkedHashMap에 들어온 지 가장 오래된 값을 eldest로 알고 있다.
이러한 removeEldestEntry를 오버라이딩 하여 캐시 엔트리의 가치를 떨어뜨리는 작업을 수행하자.
LinkedHashMap<Key,Value> linkmap = new LinkedHashMap<Key,Value>(){
@Override
protected boolean removeEldestEntry( Entry<Key,Value> eldest ){
//오버라이딩하여 엔트리의 가치를 떨어뜨림
return false
}
}
3. 리스너 or 콜백
클라이언트가 콜백을 등록만 하고 명확히 해지하지 않는다면 콜백은 계속 쌓여간다. 이럴 때 콜백을 약한 참조로 저장하면 콜백 객체가 더 이상 필요하지 않은 경우 가비지 컬렉터가 즉시 수거해간다.
리스너 : 특정 이벤트를 처리하는 인터페이스로 이벤트의 발생 여부를 기다리다가 이벤트 발생 시 특정 메서드를 호출해 알려준다 (1개)
콜백 : 다른 함수에 인수로 전달되는 함수이며, 이벤트가 발생하면 연결된 리스너들에게 이벤트를 전달한다. (n개)
WeakHashMap 클래스는 약한 참조를 사용하는 해시 맵이다. 해시 맵은 키-값 쌍을 저장하는 자료구조로, WeakHashMap 클래스에서는 키를 약한 참조로 저장한다. 따라서 WeakHashMap 클래스를 사용하면 콜백 함수를 키로 사용하여 쉽게 메모리 누수를 방지할 수 있다.
WeakHashMap에 콜백 함수를 키로 저장하는 예제 코드다.
public class Example {
private Map<Object, String> map = new WeakHashMap<>();
public void addCallback(Object key, Runnable callback) {
map.put(new WeakReference<>(key), callback.toString());
}
public void removeCallback(Object key) {
map.remove(key);
}
public void callCallbacks() {
for (Object key : map.keySet()) {
WeakReference<Object> weakKey = (WeakReference<Object>) key;
if (weakKey.get() != null) {
Runnable callback = (Runnable) map.get(key);
callback.run();
}
}
}
}
위 코드에서 addCallback() 메소드는 콜백 함수를 WeakReference로 감싸고 WeakHashMap에 저장한다. removeCallback() 메소드는 WeakHashMap에서 해당 키를 제거한다. callCallbacks() 메소드는 WeakHashMap에서 모든 키를 반복하고 약한 참조를 사용하여 유효한 객체를 검사한 후, 콜백 함수를 실행한다. 이렇게 하면 콜백 함수를 약한 참조로 저장하여 메모리 누수를 방지할 수 있다.
도움 받은 글
https://blog.breakingthat.com/2018/08/26/java-collection-map-weakhashmap/
Java – Collection – Map – WeakHashMap (약한 참조 해시맵) – 조금 늦은, IT 관습 넘기 (JS.Kim)
> Weak Reference WeakHashMap의 작동 방식을 이해하려면 JVM의 GC와 관련하여 WeakReference 를 조금은 이해할 필요가 있다. Java에서는 세 가지 주요 유형의 참조(Reference) 방식이 존재한다. 강한
blog.breakingthat.com
https://velog.io/@jack2ee/Object-Reference%EA%B0%9D%EC%B2%B4%EC%B0%B8%EC%A1%B0
[CS] Object Reference(객체참조)
Object Reference(객체 참조)에 대해 알아본다.
velog.io
'Java > Effective Java' 카테고리의 다른 글
[Effective Java] Item 11. equals를 재정의 하려거든 hashCode도 재정의 하라 (2) | 2023.03.08 |
---|---|
[Effective Java] Item 10. equals는 일반 규약을 지켜 재정의 하라 (0) | 2023.03.07 |
[Effective Java] Item 9. try-finally 보다는 try-with-resources 를 사용하라 (0) | 2023.03.07 |
[Effective Java] Item 8. finalizer 와 cleaner 사용을 피하라 (0) | 2023.03.05 |
[Effective Java] Item 1~6. 객체 생성과 파괴 (2) | 2023.03.01 |