[Java][Effective Java] item 7. 다 쓴 객체 참조를 해제하라

2022. 5. 24. 18:02JAVA/Effective Java

C, C++언어 같은 경우 인스턴스를 전부 사용한다음 수동으로 해제를 해주어야 합니다. 하지만 Java 언어 같은 경우는 가비지 컬렉터가 있기 때문에 해제를 명시하지 않아도 인스턴스가 영역 밖으로 나가게 되면 가비지 컬렉터는 자동으로 회수해갑니다. 하지만 이러한 가비지 컬렉터도 객체를 회수하지 못하고 메모리 누수가 발생할 수 있습니다.

1. 메모리 누수 사례 : Stack

public class Stack {
	private Object[] elements;
	private int size = 0;
	private static final int DEFAULT_INITIAL_CAPACITY = 10;
	
	public Stack() {
		elements = new Object[DEFAULT_INITIAL_CAPACITY];
	}
	
	public void push(Object e) {
		ensureCapcity();
		elements[size++] = e;
	}
	
	public Object pop() {
		if(size == 0) {
			throw new EmptyStackException();
		}
		// 스택이 커졌다가 줄어들었을때 스택에서 꺼내진 객체들을 가비지 컬렉터가 회수하지 않음 => 메모리 누수 발생
		return  elements[--size];	
	}
	
	private void ensureCapcity() {
		if(elements.length == size) {
			elements = Arrays.copyOf(elements, 2 * size + 1);
		}
	}
}

위 Stack 클래스에서 pop 연산에서 메모리 누수가 발생합니다. 이유는 스택이 커졌다가 줄어들었을 때 스택에서 꺼내진 인스턴스들을 가비지 컬렉터가 회수하지 못하기 때문입니다. 그림으로 표현하면 다음과 같습니다.

위 그림을 보면 pop 연산시 배열에서 size 값을 조정하여 스택의 꼭대기를 조정하는 것을 볼 수 있습니다. 이때 정수 5는 배열에는 저장되어 있지만 삭제되지 않아 메모리를 불필요하게 차지하는 것을 볼 수 있습니다.

 

위와 같은 문제를 해결하기 위해서는 다쓴 참조는 null 처리해야 합니다.

	public Object pop() {
		if(size == 0) {
			throw new EmptyStackException();
		}
		// 스택이 커졌다가 줄어들었을때 스택에서 꺼내진 객체들을 가비지 컬렉터가 회수하지 않음 => 메모리 누수 발생
		Object result = elements[--size];
		// solution : 다쓴 참조는 null 처리
		elements[size] = null;
		
		return result;	
	}

 

2. 메모리 누수 원인

2.1 자기 메모리를 직접 관리하는 클래스

Stack과 같은 클래스가 메모리 누수에 취약한 이유는 Stack 클래스가 자기 메모리를 직접 관리하기 때문입니다. 메모리 누수가 발생하지 않기 위해서는 어떤 객체가 비활성 영역이 되는 순간 null 처리를 해서 해당 객체가 더는 쓰이지 않을 것임을 가비지 컬렉터에게 알려야합니다.

 

2.2 캐시(Cache)

객체 참조를 캐시에 넣고 나서, 그 객체를 다쓴 뒤로도 그냥 놔두는 일을 접할 수 있습니다. 이 문제를 해결하는 방법 중 하나로는 캐시 외부에서 키(key)를 참조하는 동안만(값이 아닌) 엔트리가 살아 있는 캐시가 필요한 상황이라면 WeakHashMap 클래스를 사용해 캐시를 만드는 방법이 있습니다. 다 사용한 엔트리는 그 즉시 자동으로 제거될 것입니다.

public class WeakHashMapTest {
	static class Person{
		String name;

		public Person(String name) {
			this.name = name;
		}

		@Override
		public String toString() {
			return name;
		}	
	}
	public static void main(String[] args) throws InterruptedException {
		WeakHashMap<Person, String> weakHashMap = new WeakHashMap<>();
		Person p1 = new Person("홍길동");
		Person p2 = new Person("강감찬");
		
		// key가 String 타입이면 key를 null 처리해도 삭제가 되지 않음
		// Constant Pool에 저장되기 때문에 가리키는 참조가 존재함
		weakHashMap.put(p1, "1");
		weakHashMap.put(p2, "2");
		
		p2 = null;

		// GC가 weakHashMap에서 p2를 제거할때까지 대기
		while (true) {
			System.out.println(weakHashMap);
			System.gc();
			if (weakHashMap.size() == 1) {
				break;
			}
		}

		System.out.println("End");
	}

}
{강감찬=2, 홍길동=1}
{홍길동=1}
End

 

2.3 리스너(Listener) 혹은 콜백(Callback)

클라이언트가 콜백을 등록만 하고 명확히 해지하지 않는다면, 콜백은 계속 쌓여갈 것입니다. 이럴때 콜백은 약한 참조(weak reference)로 저장하면 가비지 컬렉터가 즉시 수거해갑니다. 예를 들어 WeakHashMap에 키로 저장할 수 있습니다.

public class CallBackTest {

	@FunctionalInterface
	public interface Callback{
		public void method();
	}
			
	public static void main(String[] args) throws IOException {
		Object obj = new Object();
		Map<Object, Callback> weakHashMap = new WeakHashMap<Object, Callback>();
		
		CallBackTest.Callback callback = new CallBackTest.Callback() {

			@Override
			public void method() {
				System.out.println("call method");
				
			}
		};
		
		weakHashMap.put(obj, callback);

		obj = null;
		
		// GC가 수거할때까지 대기
		while(true) {
			System.out.println(weakHashMap);
			System.gc();
			if(weakHashMap.size() == 0) {
				break;
			}
		}
	}
}

 

 

References

source code : https://github.com/yonghwankim-dev/effective_java/tree/master/src/role7
[도서] effective java
JAVA - 콜백함수 구현하기(Interface, Functional Interface 활용)
[이펙티브 자바] 규칙6. 메모리 누수 (leak)
Guide to WeakHashMap in Java