메모리 직접 관리
자바에 GC(가비지 콜렉터)가 있기 때문에 메모리 관리에 대해 신경쓰지 않아도 될거라 생각하기 쉽지만 그렇지 않다. 다음 코드를 살펴보자.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 코드 7-1 메모리 누수가 일어나는 위치는 어디인가? (36쪽)
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();
//사이즈를 먼저 줄이고 그다음 그 인덱스에 해당하는 elements를 리턴함. 왜냐? push할 때 값을 넣은 뒤 ++해서 인덱스를 하나 미리 늘려놨기 때문이다.
return elements[--size];
}
/**
* 원소를 위한 공간을 적어도 하나 이상 확보한다.
* 배열 크기를 늘려야 할 때마다 대략 두 배씩 늘린다.
*/
private void ensureCapacity() {
if (elements.length == size)
elements = Arrays.copyOf(elements, 2 * size + 1);//현재사이즈의 2배정도 되는 배열을 하나 더 만들어서 용량을 늘려준다.
}
}
스택에 계속 쌓으면서 커졌다가 많이 빼면서 줄어들었다고 가정하자, 그런 경우 스택이 차지하고 있는 메모리는 줄어들지 않는다. 왜냐하면 이 스택의 구현체는 필요없는 객체들의 다 쓴 참조(obsolete reference)를 그대로 가지고 있기 때문이다.
- 즉 elements[0]에는 계속해서 그 element가 들어있다. 왜냐하면 리턴만 했지 그 elements[0]을 지워준게 아니기 때문이다. 이 배열은 계속해서 값을 넣기만 하고 있고 실제로 빼지 않는다.
가용한 범위는 size보다 작은 부분이고, 그 값보다 큰 부분에 있는 값들은 필요없이 메모리를 차지하고 있는 부분이다. 이렇게 차지만 하고 있고 앞으로 다시 쓰지 않을 부분을 다 쓴 참조라 한다.
- 여기서 가용한 범위란 실제 유의미한 값들을 element에 담고 있는 size이다.
위 코드를 다음과 같이 수정할 수 있다.
- 코드 5-1
1 2 3 4 5 6 7
public Object pop() { if (size == 0) throw new EmptyStackException(); Object result = elements[--size]; elements[size] = null; // 다 쓴 참조 해제 return result; }
스택에서 꺼낼 때 그 위치에 있는 객체를 꺼내준 다음 그 자리를 null
로 설정해서 다음 가비지 컬렉터가 발생할 때 참조가 정리되게 한다. 실수로 해당 위치에 있는 객체를 다시 꺼낼려고 하면 nullPointerException
이 발생할 수 있긴 하다. 하지만 미리 null 처리 하지 않았다면 잘못된 객체를 돌려주었을 테니 익셉션이 발생하는게 낫다. 프로그램 오류는 가능한 조기에 발견하는 것이 좋다.
그렇다고 모든 객체를 다 쓰자마자 일일히 null
로 설정하는 코드는 작성하지 말자. 객체 참조를 null처리 하는 일은 예외적인 경우여야 한다. 다 쓴 참조를 해제하는 가장 좋은 방법은 그 참조를 담은 변수를 유효 scope 안에서만 사용하는 것이다. 로컬 변수는 그 영역이 넘어가면 쓸모가 없어져서 정리가 되기 때문이다.
아래 소스코드에서 age라는 변수는 scope이 pop이라는 메서드 안에서만 한정되어 있다. pop 메서드 밖에서는 무의미한 참조 변수가 되기 때문에 gc에 의해서 정리가 된다. 그러므로 이런 경우 매번 null
로 만들지 않아도 된다.
1
2
3
4
5
6
7
8
9
10
11
Object age = 27 ;
if (size == 0)
throw new EmptyStackException();
Object result = elements[--size];
elements[size] = null; // 다 쓴 참조 해제
age = null;
return result;
}
변수의 범위를 최소가 되게 정의했다면 자연스럽게 gc에 의해 정리 될 것이다. 하지만 코드 5-1
처럼 size라는 멤버 변수와 elements를 쓰는 경우는 자연스럽게 정리 되지않는 예외적인 상황이라 명시적으로 null로 설정하는 코드를 써줬다.
그럼 언제 참조를 null처리 해줘야할까? 바로 자기 메모리를 직접 관리할 때이다. Stack
구현체처럼 elements
라는 배열을 직접 관리하는 경우에 GC는 어떤 객체가 필요없는 객체인지 알 수 없다. 오직 프로그래머만 elements
에서 가용한(size보다 작은) 부분과 필요없는(size보다 큰) 부분을 알 수 있다. 따라서, 프로그래머는 해당 참조를 null 처리하여 GC한테 필요 없는 객체임을 알려야 한다.
자기 메모리를 직접 관리하는 클래스는 프로그래머가 항시 메모리 누수를 조심해야한다.
캐시
어떤 Object
라는 키에 대해 아래와 같이 캐싱을 한다고 하자.
1
2
3
4
5
Object key1 = new Object();
Object value1 = new Object();
Map<Object, Object> cache = new HashMap<>();
cache.put(key1, value1);
그런데 이런 경우가 있을 수 있다. cache 안에다가 데이터를 쌓아놨는데 이 cache를 비워야하는 시기를 놓치는 경우가 있다. 캐시의 키인 key1이 없어지면 value1 값은 무의미해지는데 그런 경우 자동으로 해당 엔트리를 비워주는 weakHashMap
이라는게 있다.
- 매핑(mapping) 또는 엔트리(entry): 키와 값으로 구성되는 데이터 (from 자바 공식 문서)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
public class Item7App { public static void main(String[] args) { WeakHashMap<String, Object> map = new WeakHashMap<>(); String key1 = new String("key1"); String key2 = new String("key2"); map.put(key1, "test 1"); map.put(key2, "test 2"); key1 = null; // key1에 대한 참조가 사라졌을 경우에 GC 대상이 된다. System.gc(); map.entrySet() .stream() .forEach(System.out::println);//출력: key2=test 2 } }
WeakHashMap 주의사항
- String을 literal 방식으로 할당할 경우 gc의 대상이 되지 않는다.
(참조 : Java - Collection - Map - WeakHashMap (약한 참조 해시맵) - 조금 늦은, IT 관습 넘기 (JS.Kim))
또는 특정 시간이 지나면 캐시값이 의미가 없어지는 경우에 아래 2가지 방법을 통해 캐시를 비우는 작업을 해줘야 한다.
Scheduled ThreadPoolExcutor
백그라운드 스레드를 활용하거나- 캐시에 새 엔트리를 추가할 때 부수 작업으로 수행하는 방법
- ex)
LiknedHashMap
클래스는removeEldestEntry
메서드
- ex)
리스너(listner) or 콜백(callback)
클라이언트가 콜백을 등록만 하고 명확히 해지하지 않는다면, 콜백은 계속 쌓여갈 것이다. 이것 역시 weakHashMap
으로 저장하면 가비지 컬렉터가 즉시 수거해간다.
결론
메모리 누수는 발견이 쉽지 않기 때문에 수년간 시스템에 잠복하고 있을 수도 있다. 코드리뷰나 heap profiler
같은 디버깅 도구를 사용하여 찾아야한다. 따라서 이런 문제는 예방법을 익혀두고 미연에 방지하는 것이 좋다.