-
객체 참조를 해제하라Backend/책 정리 2023. 5. 17. 23:25
이번주부터 이펙티브 자바 스터디를 진행하며 정리한 내용을 적어보려 합니다.
클린코드 책의 경우는 매 챕터마다 정리를 진행했는데 이펙티브 자바는 그보다는 내가 맡은 아이템을 좀 더 집중해서 읽으며 괜찮은 내용이 있으면 정리하는 식으로 앞으로 글을 작성하도록 하겠습니다.
이번 글에서는 아이템 7번 다 쓴 객체 참조를 해제하라는 파트입니다.
지금까지 개발하면서 메모리까지 생각하며 개발을 한 경험이 없다보니 이번 아이템을 읽으며 좀 더 신중하게 개발을 해야겠다는 생각을 많이 했습니다. 즉, C, C++이 아닌 가비지 컬렉터를 갖춘 언어라 할지라도 메모리 관리에 신경쓰도록 하는 습관을 갖는것이 좋아보입니다.
아래 예제를 살펴보겠습니다.
아래 코드는 Stack 자료구조를 간단하게 구현한 클래스 입니다.
이 코드에는 한 가지 문제가 있습니다.
pop() 메서드를 살펴보게 되면 elements 배열에서 원소 하나를 꺼내지만 더 이상 사용하지 않는 원소를 삭제하지 않았습니다.
즉, pop() 메서드에서 스택에서 꺼내진 객체들에 대한 참조를 여전히 가지고 있기 때문에 가비지 컬렉터가 회수하지 않는다는 문제가 있습니다.
import java.util.Arrays; import java.util.EmptyStackException; 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); } } }
따라서 해당 참조를 다 썼을 때 아래 코드처럼 null 처리를 해주면 간단하게 해결할 수 있습니다.
public Object pop() { ensureCapacity(); if (size == 0) { throw new EmptyStackException(); } Object result = elements[--size]; elements[size] = null; // 다 쓴 참조 해제 return result; }
일반적으로 null 처리는 클래스가 자기 메모리를 직접 관리하는 경우 처리해주어야 합니다.
- 위 예제에서도 stack이 elements 배열로 저장소 풀을 만들어 직접 원소를 관리하기 때문에 직접 null 처리를 해줘야 합니다.
- 그러므로 프로그래머는 비활성 영역이 되는 순간 null 처리해서 해당 객체를 더는 쓰지 않을 것임을 가비지 컬렉터에게 알려야 합니다.
다 쓴 참조를 해제하는 가장 좋은 방법은 그 참조를 담은 변수를 유효 범위 밖으로 밀어내는 것이다.
1. 보통은 변수 선언(대게 지역변수)과 동시에 초기화를 사용합니다.
2. 초기화한 변수에 대한 scope가 종료되는 순간 reference가 해제되어 가비지 컬렉션의 대상이 됩니다.
3. try - catch 구문에서는 finally 구문에서 변수에 대한 참조를 해제합니다.메모리 누수를 일으키는 주범
1. 위 예시 처럼 자기 메모리를 직접 관리하는 클래스
- 원소를 다 사용한 즉시 그 원소가 참조한 객체들을 다 null 처리해주어야 합니다.
2. 캐시
- 객체 참조를 캐시에 넣고 나서, 그 객체를 다 쓴 뒤로도 한참을 그냥 놔두는 경우에도 메모리 누수를 일으킬 수 있습니다.
- 만약 캐시 외부에서 키를 참조하는 동안만 엔트리가 살아 있는 캐시가 필요한 상황이라면 WeakHashMap을 사용해 캐시를 만들면 다 쓴 엔트리는 그 즉시 자동으로 제거될 것입니다.
- 단, WeakHashMap은 이러한 상황에서만 유용합니다.
[ WeakHashMap ]
WeakHashMap에 대해 알아보기 전에 Java의 참조 방식에 대해 먼저 알아보겠습니다.
1. Strong Reference
- Integer prime = 1; 과 같은 일반적인 참조 유형
- 이 객체를 가리키는 강한 참조가 있는 객체는 GC 대상이 되지 않습니다.
2. Soft Reference
- SoftReference<Integer> soft = new SoftReference<>(prime); 과 같이 SoftReference 클래스를 이용해서 생성 가능합니다.
- 만약 prime == null 상태가 되어 더 이상 원본은 없고 대상을 참조하는 객체가 SoftReference만 존재할 경우 GC대상으로 들어가도록 JVM은 동작합니다.
- 다만 Weak Reference와의 차이점은 메모리가 부족하지 않으면 굳이 GC하지 않는다는 것입니다.
- 조금은 엄격하지 않은 Cache Library 들에서 널리 사용됩니다.
3. Weak Reference
- WeakReference<Integer> weak = new WeakReference<>(prime); 과 같이 WeakReference 클래스를 이용하여 생성 가능합니다.
- prime == null 이 되면 GC 대상이 됩니다.
- 앞서 얘기한 것처럼 메모리가 부족하지 않더라도 GC 대상이 됩니다.
아래 코드는 WeakHashMap을 사용한 코드입니다.
public class WeakHashMapTest { public static void main(String[] args) { WeakHashMap<String, Integer> numbers = new WeakHashMap<>(); // String two = "Two"; String two = new String("Two"); Integer twoValue = 2; // String four = "Four"; String four = new String("Four"); Integer fourValue = 4; numbers.put(two, twoValue); numbers.put(four, fourValue); System.out.println("WeakHashMap = " + numbers); two = null; System.gc(); System.out.println("WeakHashMap after gc = " + numbers); } }
위 코드를 실행하면 아래처럼 결과가 나오게 됩니다.
이때 주석처리한 부분과 같이 스트링 객체를 리터럴로 생성하게 된다면 System.gc() 이후에도 WeakHashMap에 two 값은 사라지지 않게 됩니다.
이에 대한 원인으로는 stackoverflow를 살펴본 결과 리터럴로 스트링을 생성하게 되면 Strong Reference가 되기 때문에 gc에 의해 삭제되지 않는것 같습니다.
따라서 일반적으로 strong reference인 int나 String 리터럴의 경우 WeakReference의 key 값으로 사용하는 것은 적절하지 못한것을 확인할 수 있었습니다.
3. 마지막으로 리스너 혹은 콜백 구조에서 메모리 누수가 일어날 수 있습니다.
- 아래 코드는 ChangeHandler 라는 콜백 클래스입니다.
public interface ChangeHandler { public void handleChange(); } public class FileMonitor { private File file; private Set<ChangeHandler> handlers = new HashSet<ChangeHandler>(); public FileMonitor(File file) { this.file = file; } public void registerChangeHandler(ChangeHandler handler) { this.handlers.add(handler); } public void unregisterChangeHandler(ChangeHandler handler) { this.handlers.remove(handler); } ... }
- 아래 MyClass라는 클라이언트에서 위에서 정의한 FileMonitor에 특정 ChangeHandler를 등록하고 사용했다고 생각해봅시다.
- 이때 만약 등록을 하고 이후에 따로 등록을 해제하지 않게되면 MyClass를 사용할 때마다 ChangeHandler가 누적해서 쌓일것입니다.
- 따라서 이때에도 HashSet이 아닌 WeakHashMap이나 등록을 제거해주는 메서드를 사용할 수 있습니다.
public class MyClass { File myFile = new File(...); FileMonitor monitor = new FileMonitor(myFile); public void something() { ... ChangeHandler myHandler = getChangeHandler(); monitor.registerChangeHandler(myHandler); ... } }
이번 아이템을 읽으면서 메모리 누수에 대해 처음으로 생각을 해볼 수 있던 경험이었습니다. 앞으로 코드를 작성할 때 위에서 정의한 3가지 패턴의 코드를 작성하게 된다면 좀 더 메모리를 신경 쓰며 코드를 구현하면 좋을 것 같고 추가로 모니터링 시스템을 잘 구축해 놓는것도 좋은 방법이 되지 않을까 생각해서 최근에 공부한 grafana와 prometheus 등의 모니터링 툴을 잘 구축해두고 문제가 생길때마다 좀 더 신경써서 구현하면 좋은 코드를 작성할 수 있을것이라 생각하며 마무리하겠습니다.
잘못된 내용은 언제든 피드백 주시면 수정하겠습니다.
'Backend > 책 정리' 카테고리의 다른 글
내 코드가 그렇게 이상한가요? (0) 2024.06.06 클린 코드를 마무리 하며... (0) 2023.05.18 Clean Code 12 ~ 13 장 (0) 2023.04.04 Clean Code 9 ~ 11 장 (0) 2023.03.28 Clean Code 7~8 장 (0) 2023.03.21