GC(Garbage Collection)는 memory의 garbage를 찾고 지우는 역할을 한다.
현재까지 Java의 GC는 발전을 거듭해 여러 종류가 있다.
그치만 그 종류를 알기 전에, GC의 기본 개념과 용어들 GC가 생긴 이유들을 아는 것이 중요하다.

이번 글에선 그 내용을 정리한다.

우선, GC를 왜 쓸까?

장점

  • memory의 allication/deallocation을 직접 핸들링 하지 않아도 된다.
  • Dangling Pointer를 걱정하지 않아도 된다.
    • Dangling Pointer: GC 된 memory를 객체가 참조하는 것x
  • Double free 를 걱정하지 않아도 된다.
    • Double free: GC 된 memory를 다시 GC 하는 것
  • memory leak을 알아서 관리해준다.
    • memory leak: GC 되어야 하는 memory가 GC 되지 않은 것

단점

  • JVM이 객체 생성을 추적하기 때문에 CPU를 더 사용한다.
  • 개발자가 GC의 CPU 시간을 조정할 수 없다.
  • 적절하게 직접 memory를 관리하는 것보다 성능이 떨어질 수 있다.

초기 Java에서 GC는 개발자가 핸들링 할 수 없는 등의 이유로 많은 논란이 되었다고 한다.
현재는 논란의 여지가 없이 GC는 Go 등의 최신 언어에서도 채택된다.


기본 원칙

모든 언어의 모든 GC는 두 가지 원칙을 기본으로 설계된다.

  1. 모든 garbage를 수집해야 한다.
  2. 살아있는 객체는 절대로 수집해선 안된다.

STW (Stop The World)

GC에서 가장 중요한 것은 STW 이다.

GC를 실행하기 위해 JVM이 application을 멈추는 것을 stop-the-world라고 하는데,
stop-the-world가 발생하면 GC를 실행하는 thread를 제외한 나머지 thread는 모두 작업을 멈춘다.

  • 즉, GC 완료 시까지 모든 application thread가 중지된다는 말이다.

그리고 GC 작업이 완료된 이후에 다시 작업을 시작한다.

어떤 GC를 사용하더라도 STW는 발생할 수 밖에 없는데,
STW 시간을 줄이는 것이 GC 선택과 튜닝의 중요한 목표가 된다.

GC 기본 동작

GC 알고리즘마다 동작의 차이는 있지만 기본적인 GC 동작은 아래와 같다.
기본 GC 동작을 완벽히 이해하면 다른 알고리즘들이 왜 생겼고 어떻게 동작하는지 이해하기 쉽다.

1. Marking

marking

어떤 memory가 사용중인지 확인하는 과정이다.

2. Normal Deletion

Normal Deletion

참조되지 않은 객체를 제거해서 여유 공간 확보한다.

2a. Deletion with Compacting

Deletion with Compacting

성능 향상을 위해 객체를 압축한다.
이렇게 하면 새로운 memory allocation이 쉽고 빨라진다.


GC 발생 요인

어떤 요인들이 Java app에서 GC 발생에 영향을 주는지를 알면,
GC의 구조를 이해하는데 더 도움이 된다.

Java app에서 GC 발생에 영향을 주는 주요 요인은 두 가지이다.

  1. 할당률
  2. 객체 수명

할당률은 일정 기간(MB/s) 새로 생성된 객체가 사용한 memory 크기이다.

  • 비교적 쉽게 측정할 수 있고 툴을 사용하면 정확하게 구할 수 있다.

객체 수명은 측정하기 어렵다.

  • 대부분의 객체는 short-lived 객체인 것이 실험적으로 파악되었다.

Weak Generational Hypothesis

GC에서는 위 요인 중 객체 수명이 굉장히 중요하다.
객체가 얼마나 살아있냐에 따라 GC의 수행 여부와 대상이 정해지기 때문이다.

object lifetime

그래프에서 볼 수 있듯 실험적으로 확인한 결과 대부분의 객체는 short-lived 객체이다.

여기서 하나의 가설이 나오는데 이게 바로 Weak Generational Hypothesis이다.

  • 대부분의 객체는 아주 짧은 시간 동안 살아있고(short-lived) 나머지 객체는 훨씬 수명이 길다(long-lived)는 가설이다.

이 가설의 결론은 short-lived 객체를 쉽게 빠르게 수집할 수 있고, long-lived 객체를 short-lived 객체와 분리해 놓는 설계가 좋다는 결론으로 이어진다.


JVM Generations

모든 객체에 대해서 매번 위와 같이 GC 동작을 수행하는건 효율적이지 않다.
그리고 Weak Generational 가설의 결론으로 보다 효율적인 GC 구조를 설계하게 된다.

generation으로 heap의 part를 나누는 방식이다.

heap structure

Young Generation

새로 생성된 대부분의 객체가 할당되는 영역이다.
Young Generation을 정리하는 GC를 minor garbage collection라고 한다.

  • minor garbage collection은 STW event 이다.

사용되지 않는 memory는 GC 처리되고, 살아남은 객체는 Old Generation으로 이동한다.

Young은 3가지 영역으로 나뉜다.

  • eden
  • survivor1
  • survivor2

Generational Process

Young이 3가지 영역으로 나뉘는 이유가 중요하다.

  1. 새로 생성한 객체는 eden 영역에 위치한다.
    filling the eden space

  2. eden 영역에서 GC가 발생하면 survivor 영역 중 하나로 이동한다.
  3. eden 영역에서 GC가 발생하면 객체가 이미 존재하는 survivor 영역에 객체가 쌓인다. copying referenced objects

  4. 하나의 survivor 영역이 가득차면 살아남은 객체를 다른 survivor 영역으로 이동한다.
    • 이 로직으로 survivor 영역 중 한 곳은 항상 비어있는 상태를 유지한다. object aging
  5. 이런 동작이 반복되는 것을 aging이라 표현한다. additional aging

  6. aging을 반복하며 generation이 변한다.
    계속 살아남은 객체는 Old Generation으로 이동한다. promotion1 promotion2

Old Generation

Weak Generational가설과 Generational Process를 잘 봤다면 Old Generation은 명확하다.

Young Generation에서 임계값 만큼의 GC가 처리되고 나서 살아남은 객체가 이동하는 곳이다.

Old Generation은 보통 Young보다 큰 heap을 할당받고, 따라서 Old Generation의 GC는 느리다.

  • 여기서 발생하는 GC를 major garbage collection라고 하며 이 또한 STW event 이다.
  • 따라서 major GC 수행이 최소화 되는 것이 성능에 유리하다.
  • Weak Generational 가설에 따라 분리되는 것이 성능상 유리하다.
  • major GC는 GC 알고리즘 종류에 영향을 받는다.

Permanent Generation

JVM에서 Class와 Method를 사용하기 위해 필요한 metadata들이 있는 곳이다.
application에 사용되는 class를 기반으로 runtime에 JVM에 의해서 생성된다.

  • Java SE library의 Class와 Method도 여기에 위치한다.

TLAB (Thread-Local Allocation Buffer)

multi-thread를 효율적으로 사용하기 위한 방식으로 현재는 GC의 기본적인 기술이다.

GC에 memory를 할당할 때 multi-thread인 경우 thread-safe 하도록 memory를 할당하는 것은 비용이 크다.

  • global lock을 잡는다고 하면 bottlenect이 걸리고 성능이 떨어진다.

이를 해결하기 위해 도입된 것이 TLAB이다.
JVM은 eden을 여러 buffer로 나누어서 각 thread가 새 객체를 할당하는 구역으로 활용하도록 한다.
thread마다 사용하는 buffer가 정해져 있기 때문에 thread-safe를 위한 계산을 하지 않아도 된다.

이와 함께 Bump-The-Pointer가 사용되는데,
이는 할당된 메모리 바로 뒤에 메모리를 할당하고 pointer는 비어있는 memory 주소를 가리키도록 업데이트 하는 방식이다.

  • TLAB + BTP 를 통해 JVM thread의 memory allocation 복잡도가 O(1)이 된다.

이 외에도 여러 기술들이 사용되어 TLAB에서 memory 낭비가 최소화 되었고 평균적으로 eden의 1% 미만이 낭비된다고 한다. (굉장하다..)

특정 thread의 buffer가 초과되는 경우 일반적으로 더 큰 크기의 TLAB를 할당하는 방식으로 해결한다.

죄짓기

Java에서 아래와 같이 명시적으로 gc를 호출할 수 있다.

System.gc()

하지만 gc는 위에서 말했듯 시스템 성능에 영향을 미치므로 절대로 사용해서는 안된다.

reference

  • https://www.baeldung.com/jvm-garbage-collectors
  • https://www.oracle.com/webfolder/technetwork/tutorials/obe/java/gc01/index.html
  • https://d2.naver.com/helloworld/1329
  • Optimizing Java, chapter6
  • https://www.oracle.com/technetwork/java/javase/memorymanagement-whitepaper-150215.pdf
  • https://dzone.com/articles/thread-local-allocation-buffers#:~:text=TLAB%20stands%20for%20Thread%20Local,new%20objects%20in%20this%20area.