가비지 컬렉터는 뭘까?

maketheworldwise·2022년 2월 7일
0


이 글의 목적?

지금까지 계속 개발을 하면서 메모리 해제를 자동으로 해주기에 가비지 컬렉터에 관심을 가지지 않았다. 하지만 자바에서 GC를 수행할 때는 애플리케이션의 성능에 영향을 주기에 중요하다고 한다. 내가 참고한 글을 기준으로 필요한 개념들을 숙지해두자.

Garbage Collector 개념

가비지 컬렉터는 메모리 관리 기법으로 프로그램이 동적으로 할당한 메모리 영역 중에서 필요 없게된 영역을 해제하는 기능이다. 내부적으로 finalize() 메소드를 호출하여 객체를 메모리에서 해제시킨다.

왜 알아야할까?

stop-the-world는 GC을 실행하기 위해 JVM이 애플리케이션 실행을 멈추는 것을 의미한다. stop-the-world가 발생하면 GC를 실행하는 스레드를 제외한 나머지 스레드는 모두 작업을 멈춘다. GC 작업을 완료한 이후에야 중단했던 작업을 다시 시작한다. 어떤 GC 알고리즘을 사용하더라도 stop-the-world는 발생한다. 대부분 GC 튜닝은 stop-the-world 시간을 줄이는 것을 의미한다.

자바에서 메모리를 명시적으로 지정하여 해제하는 방법은 두 가지가 있다. 해당 객체를 null로 만들거나 System.gc() 메소드를 호출하는 방법이 있다. 후자의 경우 시스템의 성능에 매우 큰 영향을 주므로 절대로 사용해서는 안된다고 한다.

GC에서 사용하는 개념

가비지 컬렉터가 만들어진 두 가지 전제 조건을 확인해보자.

  • 대부분의 객체는 금방 접근 불가능한 상태(Unreachable)가 된다.
  • 오래된 객체에서 새로운 객체로의 참조는 아주 적게 존재한다.

Root Set

대부분의 객체는 여러 다른 객체를 참조하고, 참조된 다른 객체들도 또 다른 객체들을 참조하는 참조 사슬을 이룬다. 이런 상황에서 유효한 참조 여부를 파악하려면 항상 유효한 최초의 참조가 있어야 하는데 이를 객체 참조의 Root Set이라고 한다.

Reachability

GC는 처리할 객체를 판별할 때 Reachability 개념을 사용한다. 힙 영역에서 어떤 객체에 유효한 참조가 있을 경우 Reachable, 없으면 Unreachable이로 구별하고 Unreachable이 객체를 가비지로 간주하여 처리한다.

힙 내의 객체를 중심으로된 이미지로 살펴보면 Root Set에 연결된 참조 객체와 그렇지 않은 객체들을 확인할 수 있다.

GC Reference

GC의 Reachability를 개발자가 컨트롤 할 수 있도록 Reachable한 객체는 모두 다른 접근성 수준을 가지고 있다. 즉, 자바는 적절한 Reference를 이용하여 GC에 의해 제거될 데이터에 우선순위를 적용하여 더 효율적인 메모리 관리를 하기 위해 종류를 나누어 제공한다. 각각의 단계에는 Strong, Soft, Weak, Phantom Reference가 있다. 뒤로 갈 수록 GC에 의해 제거될 우선순위가 높다.

GC는 Root Set에서 시작하여 객체에 대한 모든 경로를 탐색하고 그 경로에 있는 참조 객체들을 조사하여 그 객체에 대한 Reachability를 결정한다.

Strong Reference

java.lang.ref 패키지를 사용하지 않은 일반적인 참조이며, 흔히 생성하는 자바 객체들을 Strongly Reachable Object라고 한다. Strong 접근성을 가진 객체들은 Root Set과의 참조 관계가 연결되어 있다면 제거되지 않는 객체다.

내가 참고한 Strongly Reference에 대한 글을 확인해보자.

public class StrongReferenceExample {
  public static void main(String[] args) {
    // 1. Strong Reference로 생성
    Printer printer = new Printer();

    // 2. print() 메서드 호출
    printer.print();

    // 3. printer에 null 할당 (printer의 heap 데이터는 GC에 의해 제거될 가능성이 있음)
    printer = null;
  }

  public static class Printer {
    public void print() {
      System.out.println("printing...");
    }
  }
}

이 과정에서 개발자가 임의로 null을 할당하거나 객체가 Unreachable 상태가 되어야 메모리에서 해제가 된다는 것을 알 수 있다.

Soft Reference

이 참조는 java.lang.ref에서 SoftReference클래스로 제공한다. Soft 객체들은 JVM 메모리가 부족한 순간이 오는 경우에 수거해가고, 사용되는 빈도수가 높을 수록 어떠한 참조 관계를 가지더라도 GC가 되지 않는다.

내가 참고한 Soft Reference에 대한 글을 확인해보자.

public class SoftReferenceExample {
  public static void main(String[] args) {
    // 1. Strong Reference로 생성
    Printer printer = new Printer();

    // 2. print() 메서드 호출
    printer.print();

    // 3. Soft Reference로 생성
    SoftReference<Printer> softReference = new SoftReference<>(printer);

    // 4. Soft Reference값의 printer() 메서드 호출
    softReference.get().print();

    // 5. printer에 null 할당 (Soft Reachable 상태로 만듬)
    printer = null;

    // 6. GC를 실행 (System.gc()을 호출 하더라도 바로 GC가 동작한다고 보장할수는 없지만, 예제상 GC가 동작하였다고 가정, 메모리가 부족하다고 판단되면 SoftReference 참조값은 메모리 회수 대상이 됨)
    System.gc();

    // 7. Soft Reference값의 printer() 메서드 호출 (NullPointerException`이 날수도 있고, 아닐수도 있음)
    softReference.get().print();
  }

  public static class Printer {
    public void print() {
      System.out.println("printing...");
    }
  }
}

Soft Reference는 캐싱에 적합하지 않다. GC 동작시 메모리가 부족할 경우 메모리 회수 대상이 되므로 Soft Reference를 캐싱에 사용시 다른 비즈니스 로직에 필요한 다수의 메모리를 캐싱에서 사용하게되어, 잦은 GC가 발생해 성능에 이슈가 있을 수 있기 때문이다.

Weak Reference

이 참조는 java.lang.ref에서 WeakReference, WeakHashMap 클래스로 제공한다. Weak 객체들은 JVM이 해당 객체의 참조를 Null로 설정하여 Unreachable 상태로 만들기 때문에 GC가 발생했을때 어떠한 참조 관계를 가지던지 가비지 객체를 처리한다.

만약 Weak 객체와 Root Set 혹은 다른 Strong 객체에게 동시에 참조되는 객체는 Strong 객체로 취급한다. Reachability가 강할수록 객체 간의 참조 연결 시 다른 단계의 참조를 덮어쓴다.

내가 참고한 Weak Reference에 대한 글을 확인해보자.

public class WeakReferenceExample {
  public static void main(String[] args) {
    // 1. Strong Reference로 생성
    Printer printer = new Printer();

    // 2. print() 메서드 호출
    printer.print();

    // 3. Weak Reference로 생성
    WeakReference<Printer> weakReference = new WeakReference<>(printer);

    // 4. Weak Reference값의 printer() 메서드 호출
    weakReference.get().print();

    // 5) printer에 null 할당 (Weak Reachable 상태로 만듬)
    printer = null;

    // 6. GC를 실행 (System.gc()을 호출 하더라도 바로 GC가 동작한다고 보장할수는 없지만, 예제상 GC가 동작하였다고 가정, GC에 의해 부조건 WeakReference 참조값은 메모리 회수 대상이 됨)
    System.gc();

    // 7. Weak Reference값의 printer() 메서드 호출 (NullPointerException이 날수도 있고, 아닐수도 있음)
    weakReference.get().print();
  }

  public static class Printer {
    public void print() {
      System.out.println("printing...");
    }
  }
}

이 과정에서 Weak 객체는 GC가 동작할 때마다 메모리 회수 대상이 되지만, GC가 즉각적으로 메모리를 제거한다는 보장은 할 수 없으며, 메모리 회수 시점은 GC 알고리즘에 따라 다르다. 이러한 특성을 이용하여 캐싱 기능을 구현할 때 사용되며, WeakHashMap을 이용하면 쉽게 구현이 가능하다. 즉, Weak Reference는 GC에 의한 메모리 회수 우선순위가 높은점을 이용하여 캐싱에 활용할 수 있다.

Reference Queue

Reference Queue는 Soft, Weak Reference 객체가 참조하는 객체가 GC 대상이 되면, Soft Reference 객체, Weak Reference 객체 내의 참조는 null로 설정되고, 각 객체는 Reference Queue에 enqueue 된다. 이 작업은 GC에 의해 자동으로 수행된다.

실제로 enqueue 되었는지 확인하려면 poll(), remove() 메소드로 알 수 있고, 이를 통해 관련된 리소스나 객체에 대한 후처리 작업을 할 수 있다. 즉, Referece Queue는 특정 객체가 더 이상 필요 없게 되었을 때 관련된 후처리를 해야 하는 애플리케이션에서 유용하게 사용할 수 있다. 캐시 구현에 자주 사용되는 WeakHashMap 클래스는 Reference Queue와 Weak Reference를 사용하여 구현되어있다.

Soft Reference나 Weak Reference는 Reference Queue를 생성자를 통해 사용할 수 있고 사용하지 않을 수도 있다. 하지만 Phantom Reference는 반드시 Reference Queue를 반드시 사용해야하기에 Reference Queue를 인자로 받는 단 하나의 생성자를 가지고 있다.

정리해보자. Soft Reference와 Weak Reference는 객체 내부의 참조가 null로 설정된 이후에 Reference Queue에 enqueue 되지만, Phantom Reference는 객체 내부의 참조가 null로 설정되지 않고 참조된 객체를 Phantomly Reachable 객체로 만든 후에 enqueue된다.

Phantom Reference

GC에서 객체를 처리하는 작업과 할당된 메모리를 회수하는 작업은 연속된 작업이 아니다. GC 대상 객체를 처리하는 작업은 파이널라이즈 작업이 이루어진 후에 GC 알고리즘에 따라 할당된 메모리를 회수한다. 따라서 Phantom Reference로만 참조되는 객체는 먼저 파이널라이즈된 이후에 Phantomly Reachable로 간주된다.

GC는 다음과 같은 순서로 객체를 처리한다고 한다.

1. Soft Reference
2. Weak Reference
3. 파이널라이즈
4. Phantom Reference
5. 메모리 회수

즉, GC 여부를 판별하는 작업은 특정 객체의 Reachability를 Strong, Soft, Weak 순으로 먼저 판별하고 모두 아닐 경우 Phantom Reachable 여부를 판별하기 위해 파이널라이즈를 진행한다. 그리고 대상 객체를 참조하는 Phantom Reference가 있다면 Phantom Reachable로 간주하여 Reference Queue에 넣고 파이널라이즈 이 후 작업을 애플리케이션이 수행하게 하고 메모리를 회수는 지연시킨다.

내가 참고한 Phantom Reference에 대한 글을 확인해보자.

public class PhantomReferenceExample {
  public static void main(String[] args) {
    ReferenceQueue<Object> referenceQueue = new ReferenceQueue<>();
    List<PhantomReference<Object>> phantomReferences = new ArrayList<>;
    List<Object> largeObjects = new ArrayList<>();

    for (int i = 0; i < 3; i++) {
      // 1. Strong Reference로 생성
      Object largeObject = new Object();

      // 2. 생성한 largeObject를 largeObjects에 담기
      largeObjects.add(largeObject);

      // 3. Phantom Reference로 생성
      phantomReferences.add(new PhantomReference<>(largeObject, referenceQueue));
    }

    // 4. largeObjects에 null 할당 (Strong Reference인 largeObject를 unreachable 상태로 만듬)
    largeObjects = null;

    // 5. GC 실행 (System.gc()을 호출 하더라도 바로 GC가 동작한다고 보장할수는 없지만, 예제상 GC가 동작하였다고 가정)
    System.gc();

    for (PhantomReference<Object> phantomReference: phantomReferences) {
      // 6. phantomReference가 ReferenceQueue에 들어갔는지 확인
      if (phantomReference.isEnqueued()) {
        System.out.println("enqueued");
      }
    }

    Reference<?> referenceFromQueue;
    while ((referenceFromQueue = referenceQueue.poll()) != null) {
      // 7. phantomReference 제거 전 수행할 업무를 처리
      ((LargeObjectFinalizer) referenceFromQueue).finalizeResources();

      // 8. phantomReference 객체를 수동으로 clear()
      referenceFromQueue.clear();
    }
  }

  public static class LargeObjectFinalizer {
    public LargeObjectFinalizer(Object referent, ReferenceQueue<? super Object> q) {
      super(referent, q);
    }

    public void finalizeResources() {
      System.out.println("clearing...");
    }
  }
}

Phantom Reachable로 판명된 객체에 대한 참조를 GC에서 자동으로 null로 설정하지 않으므로, 후처리 작업 후에 사용자 코드에서 명시적으로 clear() 메소드를 실행하여 null로 설정해야 메모리 회수가 진행된다.

중요한 점은, PhantomReference는 어떤 객체가 파이널라이즈된 이후에 할당된 메모리가 회수되는 시점에 사용자 코드가 관여할 수 있다. 파이널라이즈 이후에 처리해야하는 리소스 정리 등의 작업이 있다면 유용하게 사용할 수 있다.

각 참조 객체를 지칭하는 단어

Soft, Weak, Phantom Reference로 생성한 객체를 Reference Object라고 한다. Strong Reference로 표현되는 일반적인 참조나 다른 클래스의 객체와는 다르게 3가지 Reference 클래스의 객체에 대해서만 사용하는 용어다. 그리고 Reference Object로 참조된 객체는 Referent라고 부른다.

힙 메모리 구조를 이해해보자

이미지에서 볼 수 있듯이 힙 영역은 크게 Young 영역과 Old 영역으로 나뉘어져있다. 큼직한 영역에 대해서 개념을 숙지해보자.

Young Generation / Minor GC

새롭게 생성한 객체의 대부분이 여기에 위치한다. 대부분의 객체가 금방 접근 불가능한 상태(Unreachable)가 되기 때문에 매우 많은 객체가 Young 영역에서 생성되었다가 사라진다. 이 영역에서 객체가 사라질때 Minor GC가 발생한다고 한다.

Old Generation / Major GC (Full GC)

이 영역은 접근 불가능 상태로 되지 않아 Young 영역에서 살아남은 객체가 여기로 복사된다. 대부분 Young 영역부다 크게 할당되며, 크기가 큰 만큼 Young 영역보다 GC는 적게 발생한다. 이 영역에서 객체가 사라질 때 Major GC (Full GC) 가 발생한다고 한다.

Permanent Metaspace Generation

이 영역에서는 각 블로그에 기술되어있는 내용은 사뭇 다르다. 이 영역에서는 GC가 발생한다는 형태로 기술되어있는 블로그가 있는 반면, GC가 발생하지 않는 영역이라고 설명된 블로그도 있다.

다행히도 무엇이 옳고, 무엇이 틀린가를 고민할 필요없이, 이 영역에 대한 내용은 깊게 볼 필요가 없다. 그 이유는 Java 8에서는 Metaspace 영역으로 변경되었기 때문이다.

그래도 간단히 살펴보자. Metaspace로 변경되기전에는 다음과 같은 정보들이 저장되었다.

  • 클래스의 메타데이터
  • 메소드의 메타데이터
  • Static Object 변수, 상수
  • JVM, JIT 관련 데이터 등

Metaspace로 변경된 후에는 Native 메모리 영역으로 JVM이 아닌 운영체제에 의해 관리되도록 변경되었다. 또한 이전에 저장되는 데이터 중에서 Static Object 변수, 상수는 Metaspace가 아닌 힙 영역으로 옮겨져 최대한 GC의 대상이 되도록 변경되었다.

힙 영역으로 옮겨진 이유는 Static으로 Collection 객체를 구현하여 데이터를 추가하다가 Permanent 영역이 가득차 OutOfmemoryError Permanent Space 에러가 나오는 경우가 있기 때문이다. 이 에러가 발생하는 현상을 개선하기 위해 기존에 Permanent 영역에 저장되던 Static Object의 변수와 상수를 힙 영역으로 이동시켜 GC의 대상이 되도록 변경하고, 메타데이터 정보들을 운영체제가 관리하는 영역으로 옮겨 Permanent 영역의 사이즈 제한을 없앴다.

짧게 정리해보자

  • Young 영역은 새롭게 생성한 객체가 위치하는 장소
  • Old 영역은 Young에서 살아남은 객체가 위치하는 장소
  • Permanent Metaspace 영역은 Native 메모리 영역으로 JVM이 아닌 운영체제에 의해 클래스, 메소드, JVM, JIT 관련 데이터들이 관리되는 장소

힙이 가진 메모리 영역의 동작 흐름

내가 참고한 블로그에서 가져온 이미지를 보면 쉽게 이해할 수 있다.

1️⃣ 먼저, 객체는 Eden 영역에 생성된다.

2️⃣ Eden 영역에 생성된 객체가 가득차면 GC가 발생한다.

3️⃣ GC가 발생했음에도 살아남은 객체는 Survivor0 으로 이동하고, Survivor0을 제외한 다른 영역의 객체들을 제거된다.

4️⃣ 위의 과정이 반복되면서 Eden 영역과 Survivor0 영역 모두 가득 차게 되면, 마찬가지로 GC가 발생하는데, 이 때 살아남은 객체들은 Survivor1로 이동한다.

5️⃣ Survivor1을 제외한 나머지 영역들이 객체들이 제거된다.

6️⃣ Eden과 Survivor 영역이 가득차고 GC가 발생함에 따라 살아남은 객체들이 다른 Survivor 영역으로 이동하는 과정이 반복되는데, 일정 시간이나 횟수 이상 살아남은 객체들은 Old 영역으로 이동한다.

7️⃣ Old 영역도 GC가 일어나면 모든 객체들을 검사하고 참조되지 않은 객체들을 모아 제거한다.

이렇게 Young 영역에서 발생하는 GC를 Minor GC라고 하고, Old 영역에서 발생하는 GC를 Major GC라고 한다. 단, Major GC가 일어나는 동안에는 GC를 제외한 모든 스레드가 중지된다.

이 글의 레퍼런스

profile
세상을 현명하게 이끌어갈 나의 성장 일기 📓

0개의 댓글