본문 바로가기
Language/Java

이펙티브 자바 - 유효기간이 지난 객체 참조

by ocwokocw 2021. 9. 19.

- 이 글은 Effective Java 를 기반으로 작성되었습니다.

- Java 의 GC

C, C++ 처럼 메모리 관리를 손수 하다가 Java 를 처음 사용해보면 편리함을 느끼게 된다. 메모리 관리를 손수하지 않아도 GC 가 알아서 해주기 때문이다. 그렇다며 메모리 관리는 전혀 신경쓰지 않아도 되는거 아닌가 라고 착각하게 될 수 있는데 이는 잘못된 생각이다.


- 유효기간이 지난 객체 참조

자료구조를 젤 처음 접할 때 보통은 Queue 나 Stack 을 구현하게 된다. Stack 의 경우 배열을 선언해놓고 push, pop 연산시 index 를 증감시킨다.

 

public class SimpleStack {

	private Object[] elements;
	private static final int DEFAULT_CAPACITY = 16;
	private int size = 0;
	
	public SimpleStack() {
		elements = new Object[DEFAULT_CAPACITY];
	}

	public Object pop() {
		if(size == 0) {
			throw new EmptyStackException();
		}
		
		size -= 1;
		return elements[size];
	}
	
	public void push(Object element) {
		
		ensureCapacity();
		
		elements[size] = element;
		size += 1;
	}
	
	public void ensureCapacity() {
		
		if(elements.length == size) {
			elements = Arrays.copyOf(elements, size * 2 + 1);
		}
	}
}

 

위의 코드는 Stack 을 간단하게 구현한것이다. 실제 Stack 에 비해서는 기능이 모자라지만 Stack 자료구조의 기본적인 요구사항은 갖추어져 있다.

 

Stack 에서 a,b,c,d 를 push 하고, pop 을 2 번했다고 가정해보자. elements 배열에서 c와 d는 남아있고 현재 b를 가리키고 있는 상태가 된다. c와 d는 물리적으로 elements 배열에 남아있지만, Stack 자료구조의 논리적으로는 필요없는 객체가 된다. 이를 만기 참조(obsolete reference) 라고 한다.

 

GC 가 동작할 때 c와 d를 GC 의 대상으로 삼아 메모리를 회수하지는 않는다. GC 가 자체적으로 자료구조를 판단하여 만기 참조라고 판단할 수 없기 때문이다. 이 문제를 해결하려면 c 와 d 원소를 null 이 할당된 상태로 변경해야 한다.

 

public Object pop() {
	if(size == 0) {
		throw new EmptyStackException();
	}
	
	size -= 1;
	Object returnObj = elements[size];
	elements[size] = null;
	
	return returnObj;
}

 

c와 d의 원소를 참조하고 있던 배열의 index 원소를 null 로 할당하면 c와 d가 참조를 잃게 되어 GC의 대상이 된다.

 

여기서 한 가지 함정에 빠질 수가 있는데 모든 코드에 항상 null 을 할당해야한다는 강박에 빠질 수가 있다. 이렇게 null 을 할당하는것은 방어코딩을 위한 규범이 아니라 예외적인 조치임을 생각해야 한다.

 

그렇다면 이런 상황을 어떻게 판단해야 하는가? 위의 Stack 의 클래스처럼 자체적으로 메모리를 관리하는 클래스라면 사용하지 않게 되는 객체에 대해 null 을 할당해야하는지 생각해볼 필요가 있다.


- 캐시(Cache)

캐시를 사용할때에도 누수가 발생할 수 있는데 데이터를 넣어놓고 잊어버리기 때문이다. 이런 문제를 해결하기 위한 방법이 있는데 첫번째로는 WeakHashMap 을 사용하는 것이다. WeakHashMap 은 key 가 더이상 참조되지 않을 경우 이를 제거하는데 동작방식을 이해하려면 Strong, Soft, Weak 참조를 먼저 살펴봐야할 필요가 있다.

  • 강한 참조(Strong): Integer a = 1; 과 같이 우리가 일반적으로 사용하는 참조 방식이다. a 를 강하게 참조하고 있고 언제라도 사용할 수 있기 때문에 GC의 대상이 되지 않는다.
  • 부드러운 참조(Soft): SoftReference 를 통해 참조한다. a = null; 로 할당하면 GC 의 대상이 되며 GC 가 동작할 때 메모리의 부족여부 및 사용빈도에 따라 수거된다.
  • 약한 참조(Weak): WeakReference 를 통해 참조한다. a = null; 로 할당하면 GC 의 대상이 되며 GC 가 동작하면 무조건 수거된다.

 

public class ReferenceTest {

	public static void main(String[] args) {

		Integer a = 1234;
		
		SoftReference<Integer> softRef = new SoftReference<Integer>(a);
		WeakReference<Integer> weakRef = new WeakReference<Integer>(a);
	}
}

 

우선 일반적인 HashMap 은 key 의 객체에 null 을 할당한다고 해서 곧바로 Map 에서 제거하지 않는다. 참고로 실제 개발시에는 System.gc() 로 GC 를 수동실행 하는 것을 절대 금물이다.

 

public class ReferenceTest {

	public static void main(String[] args) {

		Integer a = 1234;
		Integer b = 1235;
		
		Map<Integer, String> ordinaryMap = new HashMap<>();
		
		ordinaryMap.put(a, "a");
		ordinaryMap.put(b, "b");
		
		a = null;
		
		System.gc();
		
		ordinaryMap.keySet()
			.forEach(System.out::println);
	}
}

............

1234
1235

 

하지만 WeakHashMap 은 null 을 할당 후 GC 를 수행하면 Map 에서 제거된다.

 

public class ReferenceTest {

	public static void main(String[] args) {

		Integer a = 1234;
		Integer b = 1235;
		
		Map<Integer, String> weakMap = new WeakHashMap<>();
		
		weakMap.put(a, "a");
		weakMap.put(b, "b");
		
		a = null;
		
		System.gc();
		
		weakMap.keySet()
			.forEach(System.out::println);
	}
}

.......

1235

 

WeakHashMap 은 캐시 key 의 참조에 도달할 수 있는가의 여부로 유효기간이 지난 객체 참조를 제거하지만 일반적인 캐시 라이브러리를 사용해보면 보통 기간을 정해놓는 경우가 많다. 이럴 때에는 후면 쓰레드나 LinkedHashMap 으로 신규 항목 추가시 제어를 할 수 있다.

 

public class LinkedHashMapTest {

	public static void main(String[] args) {

		LinkedHashMap<Integer, String> linkedMap = new CustomLinkedHashMap();
		
		
		IntStream.range(0, 6)
			.forEach(testKey -> {
				linkedMap.put(testKey, "" + testKey);
			});
		
		linkedMap.forEach((key, val) -> {
			System.out.println("key: " + key + ",val: " + val);
		});
	}

}

class CustomLinkedHashMap extends LinkedHashMap<Integer, String>{

	@Override
	protected boolean removeEldestEntry(Entry<Integer, String> eldest) {
		return this.size() > 3;
	}
}

 

LinkedHashMap 은 순서를 보장해주는 HashMap 이다. 그리고 removeEldestEntry 라는 메소드를 제공하는데 기본적으로는 구현이 되어있지 않다. 이를 Override 해주어서 true 를 반환하게 재정의해주면 해당 조건을 만족할 때 가장 오래된 원소를 Map 에서 제거해준다.


- Callback 호출

어떤 라이브러리들은 callback 을 등록하는 API 를 제공할 떄가 있다. 이를 사용하는 Client 입장에서 해당 callback 을 이용하기 위해 등록해놓고 사용 후 이를 제거하지 않으면 유효기간이 지난 객체를 참조하고 있는 경우가 된다. GC 가 callback 을 제거하게 하려면 WeakReference 로 저장하는 것이 좋다. 

댓글