[이펙티브 자바] 아이템 7. 다 쓴 객체 참조를 해제하라.

0

이펙티브자바

목록 보기
2/2
post-thumbnail

들어가면서

사실 자바를 쓰며 메모리 누수를 깊게 고민해본 적은 없는 것 같습니다..
공부용 코드는, 메모리가 부족할 일이 드물기 때문입니다.

하지만 실제 운영에서 메모리 누수로 인한 OOM가 발생한다면 .. 정말 곤란하겠죠?

이 글에서는 메모리 누수가 무엇인지 부터, 자바의 가비지 컬렉터의 메모리 관리 규칙,
메모리 누수가 일어나게 되는 상황, WeakHashMap클래스의 특징,
자바의 주된 메모리 누수 원인 세가지, 그리고 해결책에 대해서 알아보겠습니다! 🔥🔥

메모리 누수는 무엇일까?

메모리 누수란(Memory leak)는 응용 프로그램에서 데이터를 메모리에 올렸다가, 이것이 쓸모없어지는 시점에서 적절하게 제거되지 않는 것을 말한다.

자바는 대부분의 상황에서 메모리 관리를 가비지 컬렉터(GC)에게 맡긴다.
GC는 개발자가 개발에 집중할 수 있도록, 접근하지 않는, 사용되지 않을 객체를 수거하며 메모리를 관리한다.

그러나 GC가 수거하지 못하고, 메모리가 쌓이면 메모리 누수가 발생하는 것이다.
따라서 자바개발자더라도 GC가 적절히 객체를 수거할 수 있도록 관리를 해야한다!

자바 가비지 컬렉터는 어떤 객체를 수거할까

자바의 GC는 더 이상 접근할 수 없는 객체를 수거한다!.
1. 모든 강한 참조가 없어진 객체
2. 약한 참조만 남은 객체
3. 순환 참조 중인 객체들이 외부에서 참조되지 않는 경우

상황 1. 여전히 (강한) 참조가 존재하면 GC는 삭제하지 않는다.

할당된 객체를 실제로 삭제하지 않고, 대충 삭제하기만 하는 클래스
즉, 내부에서 메모리를 직접 관리하는 클래스

class Stack {
    private Object[] elements;
    private int size = 0;

    public Stack() {
        elements = new Object[5];
    }

    public void push(Object e) {
        if (elements.length == size)
            elements = Arrays.copyOf(elements, 2 * size + 1);
        elements[size++] = e;
    }

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

    public String printAll() {
        StringBuilder result = new StringBuilder();

        for (int i = 0; i < elements.length; i++) {
            result.append(elements[i] + " ");
        }

        return result.toString();
    }
}

실제 사용 예시 코드


public class StackExample {
    public static void main(String[] args) {
        Stack stack = new Stack();

        // 데이터 추가 (push)
        stack.push("첫 번째 데이터");
        stack.push("두 번째 데이터");

        // 데이터 꺼내기 (pop) -> 이때 개발자는 삭제했다고 판단한다.
        System.out.println(stack.pop());  // 출력: "두 번째 데이터"
        System.out.println(stack.pop());  // 출력: "첫 번째 데이터"

        //그러나 실제로 stack 객체는 삭제 되지 않는다.
        System.out.println(stack.printAll()); // 출력: "첫 번째 데이터 두번째 데이터" 
        
    }
}

위 상황에서 개발자는 메모리가 정상관리되고 있다고 생각하지만, 해당 객체를 여전히 참조하고 있기 때문에 문제가 발생한다.

더 최악의 상황을 생각해보면 위 Stack에는 여러 타입의 객체가 들어갈 수 있다.
만약 2차원, 3...n차원의 배열이 되고, 모두 수거되지 않는다고 생각하면.,,? 정말 끔찍하다.

위 코드에서 GC가 정상적으로 일하게 만든다면 어떻게 해야할까??

쓰지 않을 객체은 접근 할 수 없도록 참조를 없앤다!

public Object pop() {
    if (size == 0)
        throw new EmptyStackException();
    Object result = elements[--size];
    elements[size] = null; // 다 쓴 참조 해제
    return result;
}

위 코드 한 줄로 메모리 누수를 방지할 수 있다 .

상황 2. 순환 참조에서는 하나가 null이 되더라도 수거되지 않는다.

클래스가 서로를 필드로 가지고 있는 순환 참조 상황을 가정해보자

public class Child {
    private Parent parent;  // 강한 참조 addParent()가 있다고 가정
}

public class Parent {
    private Child carent;  // 강한 참조, addChild()가 있다고 가정
}

Parent parent = new Parent();    // Parent 생성
Child child = new Child();      // Child 생성

parent.addChild(child);
child.addParent(parent);

만약 parent가 null이 된다면 어떨까?

parent 변수가 null이 되더라도 child변수에서 참조하고 있기 때문에 GC가 수거하지 않는다.

만약 child도 null이 된다면 어떨까?

둘 다 null이 된다면 메모리에서는 참조하고 있어도, 외부에서 각 변수에 접근 할 수 업기 때문에 수거된다. (경우 3에 해당)

결국 메모리 관리를 하는 방법은 참조가 적절히 해제 되게 하는 것이다.
그런데 매번 null을 직접 처리하는 게 과연 적절할까?
저자 또한 '객체 참조를 null 처리하는 방법은 예외적이어야한다.'라고 말했다.

null없이 참조가 해제되게 하는 방법 ps.더 바르게 객체를 관리하는 법

객체가 살아 있는 유효범위를 최소화한다.

지역변수 사용하기

// 전역 변수 대신 지역 변수를 사용하면 자동으로 범위가 제한됨
public void processData() {
    List<Data> tempData = new ArrayList<>();
    // 데이터 처리
    // 메서드가 끝나면 tempData는 자동으로 범위를 벗어남
}

변수의 범위를 최소화하기

public void processElements() {
    { // 블록으로 범위 제한
        Element element = new Element();
        // element 사용
    } // 여기서 element는 자동으로 범위를 벗어남
}

try-with-resources 사용하기

// 자원을 자동으로 해제
try (FileInputStream fis = new FileInputStream("file.txt")) {
    // 파일 처리
} // 자동으로 close() 호출

💡 try-with-resources란?
AutoCloseable 인터페이스를 구현한 객체인 경우에 컴파일러가 자동 해제를 해주는 것이다.
알반 trt문에는 괄호가 없지만, try-with-resources는 괄호에 리소스를 선언한다.
따라서 일반 try문에서는 finally 블록에서 예외 처리를 해야한다.

약한 참조인 WeakReference

Weak reference objects, which do not prevent their referents from being made finalizable, finalized, and then reclaimed. Weak references are most often used to implement canonicalizing mappings.

🔗 공식 문서 Class WeakReference 설명

WeakReference는 특이한 클래스인데, 이 타입의 객체는 그 대상이 GC에 수거될 수 있게 한다.
즉 참조되어도 수거가 되는 약한 참조상태를 만든다.

HashMap에서의 메모리 누수

이 글을 공부하기 전 실제 운영 중 메모리 누수 트러블 슈팅 글 3개를 읽었는데, 우연의 일치인지 모두 HashMap으로 인한 메모리 누수였다.

직접 메모리를 참조하고, 해제하는 클래스의 예시를 위에서 살펴봤다.
이런 클래스의 예시 중에 대표적으로 HashMap이 있다.

HashMap 클래스

  • HashMap은 자체적으로 Node테이블을 가지고 있다.
    (위에서 본 클래스와 동일한 구조)
 public class HashMap<K,V> extends AbstractMap<K,V>
    //HashMap이 가지는 노드 테이블
    transient Node<K,V>[] table; 

    //해당 노드의 구조 : 일반 타입(약한 참조 x)
    static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        V value;
        Node<K,V> next;

이 클래스에 어떤 키,값 쌍을 넣고 외부에서 그 값을 참조하는 변수에 null을 부여하면 어떻게 될까

    HashMap<String, Person> map = new HashMap<>();
    Person person = new Person("John", 25);
    map.put("key1", person);
    
    //HashMap에 넣었던 변수에 null을 넣으면 어떻게 될까?
    person = null;

HashMap 내부에서 여전히 person가 존재하기 때문에 GC는 이를 수거할 수 없다.

올바르게 삭제하는 방법

HashMapremove()를 사용한다.
해당 코드에서 직접 내부 노드에 null을 부여해 GC가 수거할 수 있는 상태로 만든다.

 public class HashMap<K,V> extends AbstractMap<K,V>
    //remove 코드 : 직접 내부 노드에 null을 부여
    public V remove(Object key) {
        Node<K,V> e;
        return (e = removeNode(hash(key), key, null, false, true)) == null ?
        null : e.value;
        }

외부참조변수가 null이 되면 수거가능한 상태로 만드는 WeakHashMap

위에서 HashMap의 노드 타입은 일반 타입이었다.
그러면 우리가 배웠던 WeakReference를 이용해 내부테이블을 만든다면 null이어도 수거되지 않을까?!

WeakHashMap 클래스

  • WeakHashMap 클래스는 외부 참조 변수가 null이 되면, GC가 수거 가능한 상태가 된다.
public class WeakHashMap<K,V>
        extends AbstractMap<K,V>
        implements Map<K,V> {
        }
    //HashMap이 가지고 있는 Entry
    Entry<K,V>[] table;

    //Entry는 WeakReference 하위 클래스다.
    private static class Entry<K,V> extends WeakReference<Object> implements Map.Entry<K,V> {
        V value;
        final int hash;
        Entry<K,V> next;

캐시의 메모리 누수

캐시를 일반 HashMap으로 만들 경우에 메모리 누수에 위험이 있다.
따라서 크기에 저한을 두고, WeakHashMap으로 캐시를 구현할 수 있다.
다른 방법으로는 LinkedHashMap를 활용할 수 있다.

LinkedHashMap 활용

LinkedHashMap 의 가장 오래된 엔트리를 삭제하는 removeEldestEntry()를 이용해 메모리를 관리할 수 있다.

이를 이용한 캐시가 LRU(Least Recently Used) 캐시이다.
LRU(Least Recently Used)는 할당된 크기를 넘을 시 가장 오래된 엔트리를 제거하는 캐시이다.

리스너와 콜백의 메모리 누수

리스너와 콜백 또한 메모리 누수의 주된 원인이다.
리스너와 콜백 또한 약한 참조를 위한 WeakHashMap으로 구현하고, 명시적엔 해제 메서드를 구현해야한다.
(마치 HashMap의 remove()와 같은 역할)

마치며

이 글에서는 참조로 인해 발생하는 메모리 누수와   
메모리 누수가 일어나는 주된 원인인 HashMap, 이를 방지하기 위한 여러가지 해결책에 대해서 알아봤습니다!

GC는 참조하지 않는 객체는 수거한다~ 정도로만 알고있던 개념이 이렇게 깊은 줄 몰랐습니다.

또한 실제 메모리 누수가 얼마나 심각한 문제인지 알아보기 위해 찾아본 여러 트러블 슈팅 글이 인상 깊었다.
실제 운영 상황에서 해당 이슈가 발생했을 때의 난감함과, 해결하기 위한 선배개발자분들의 노고와 열정이 정말 대단하다고 느꼈습니다 ..!

저희도 실제 서비스를 운영하게 된다면 지금보다 몇배는 더 난감하고 해결하기 어려운 상황을 많이 겪을 것 같네요..! 
하지만 그게 더 재밌는 편아니겠습니까?? 🔥🔥 

제가 읽었던 글 세개를 남기고 갈테니 모두들 화이팅합시다~~ ! 

메모리 관련 누수 글 :

profile
24년도까지 프로젝트 두개를 마치고 25년에는 개발 팀장을 할 수 있는 실력이 되자!

0개의 댓글