1. G1 탄생 배경
Garbage-First (G1) GC는 Concurrent Mark Sweep (CMS)의 단점인 fragmentation debt과 예측 불가능한 pause time을 해결하기 위해 만들었다.
CMS에서는 이를 해결하기 위해 Cluster를 만들고 인스턴스 하나를 가져와 FULL GC를 돌린 다음 다시 cluster에 추가하는 방식으로 했지만, G1은 compacting을 이용해 단점을 보안했다.
Java 9부터 기본 GC가 G1으로 설정되어있다.
2. 메모리 레이아웃과 동작 순서
G1은 메모리를 크게 young, old, permanent/meta 영역으로 나눠서 관리한다. Young 영역은 Eden과 Survivor으로 나뉜다. Old 영역에는 절반 이상을 차지하면 Humongous 객체로 분류되고, 연속된 영역을 순차적으로 사용하도록 할당된다.

각 구역의 크기는 JVM이 시작될 때 결정된다. -XX:G1HeapRegionSize로 설정된 크기로 2의 배수가 되야한다. 기본값은 JVM이 영역 개수를 약 2048개가 되도록 자동 설정한다.
예를 들어 8G의 힙 크기를 가진 앱이라면 8GB / 2048 = 4MB로 4MB의 영역 크기를 가지게 된다.
새로운 객채를 생성하면 하나의 영역이 선택되어 Thread-local allocations buffer (TLAB)로 할당한다. 이러면 각 스레드가 TLAB안에서 객체를 자유롭게 수정 가능하다.
2.1 Young GC
이후 GC를 시작하면
먼저 메모리 공간에 객체를 할당한다. Humongous 객체를 제외한 객체는 먼저 Eden 영역에 객체가 추가된다. 이후 Eden 영역이 채워졌다면 GC를 실행한다. Eden 영역의 크기는 G1 GC가 설정된 -XX:MaxGCPauseMillis 에 따라 자동으로 조절한다.
GC가 시작하면 root에서 시작해 접근 가능한 객체를 탐색한다. 탐색 후에 각 영역에서 살아있는 객체는 다른 비어있는 영역으로 복사하면서 승격하고 기존 객체는 영역에서 제거한다.
여기서 GC는 Remembered Set을 이용해 각 영역이 다른 영역을 참조하고 있는 정보를 가지고 있어 모든 힙을 탐색할 필요가 없게 만든다.
이 과정을 그림으로 보자.

먼저 객체가 할당되었고 다음 GC를 돌리면 Eden에 있는 객체중 살아있는 객체는 승격해 새로운 영역에 Survivor로 승격한다.

GC 로그를 보면 GC 전과 후 메모리 영역에 대한 정보를 볼 수 있다.
[1.090s][info][gc,heap ] GC(0) Eden regions: 3->0(40)
[1.090s][info][gc,heap ] GC(0) Survivor regions: 0->1(3)
[1.090s][info][gc,heap ] GC(0) Old regions: 2->2
[1.090s][info][gc,heap ] GC(0) Humongous regions: 110->110 여기서는 Eden 영역은 3에서 0으로 줄어든 것을 볼 수 있다.
이 과정은 다음 조건을 모두 만족할 때 까지 반복한다.
- InitiatingHeapOccupancyPercent (IHOP)에 도달하지 않음.
- G1ReservePercent에 도달하지 않음
- Humongous 할당이 발생하지 않음
2.2 Mixed GC
이후 Old 영역이 어느정도 차면 Old 영역에 대한 GC인 Mixed GC를 시작한다. Mixed GC는 Concurrent Marking 이후 Young GC를 실행한다.
먼저 Old 영역을 스캔해 살아있는 객체를 마킹하고 수집할 영역을 계산한다.
이 과정은 앱과 독립적으로 같이 실행되는데 마킹 전에 살아있는 객체는 이후에 참조가 끊겨도 계속 살아있도록 하는 Snapshot-At-The-Beginning (SATB)방식을 사용해 객체가 변경되어도 재대로 처리하도록 만들어 준다.
여기서 GC는 모든 영역에 대해 복사하지 않고, 목표 Pause Time에 맞춰 가장 쓰래기 비율이 높은 영역순으로 처리한다.
3. GC 튜닝
3.1 기본 설정 및 메트릭 소개
들어가기에 앞서 GC 튜닝을 해야하는 상황은 많이 없다. GC 튜닝을 하기 전에 객체 생성을 최대한 줄이고 메모리 사용량 자체를 줄이는 방법이 더 좋을 것이다. 하지만 GC 튜닝이 필요할 상황이 있을수 있고 이런 상황을 잘 파악하기 위해선 정확한 목표 설정이 필수이다.
예를 들어 RestAPI 호출을 처리하는데 일반적으로 달성 가능한 시간인 0.02 ~ 0.04초에서 0.005초 정도로 감소하려고 하면 GC 튜닝이 필요할 수 있다.
다음은 GC 튜닝이 필요할 수도 있는 주요 지표는 CPU와 메모리 사용량이다.
가급적이면 기본 설정으로 사용하는 것을 권장한다. 실재 튜닝을 하려면 관련 문서를 충분히 읽고 정확한 실험 환경을 구축해 테스트 하자.
이와 관련한 기본 설정이면서 권장 설정은 다음과 같다.
- 초기 Heap 크기 = 물리적 메모리 크기의 1/64
- 최대 Heap 크기 = 물리적 메모리 크기의 1/4
- C1과 C2 컴파일러를 사용
GC에 대한 정보를 얻고 싶다면 -verbose:gc, -XX:+PrintGCDetails, VisualGC를 사용해서 GC가 얼마나 CPU와 메모리를 사용하는지 확인 할 수 있다.
-XX:+PrntGCDetails 인자를 사용하면 아래와 같은 로그를 확인 할 수 있다.
[8.089s][info][gc,cpu ] GC(20) User=0.19s Sys=0.00s Real=0.01s 해당 로그는 코드에서 사용된 시간인 User, OS에서 사용된 시간인 Sys, 실재로 걸린 시간인 Real을 나타낸다.
여기서 주의깊게 봐야할 것은 Sys와 Real이다. 만약 Sys 시간이 많이 걸린다면 다음을 조절하면 해결할 수 있다.
-Xms와-Xmx를 같게해 힙 크기를 조절하는 시간을 없에고,-XX:+AlwaysPreTouch를 사용해 JVM이 시작할 때 해당 매모리를 미리 할당하도록 만든다.- 로그를 작성하고 있다면 로그 I/O를 다른 저장공간에 하는 것이 좋다.
- 리눅스를 사용하고 있다면 THP 기능이 켜져 있는지 확인하고 끄는 것이 좋다. THP는 작은 페이지를 큰 페이지로 병합할 때 랜덤한 프로세스를 멈추게 하는 경향이 있고, JVM이 많은 메모리를 차지하고 있기 때문에 긴 시간동안 멈춰있을 수 있다.
Real이 Sys + User 보다 크게 높다면 JVM이 CPU 자원을 충분히 할당받지 못했을 수 있다.
G1 GC은 Pause Time과 Throughput의 균형을 유지해야 한다.
Pause Time은 GC가 앱을 정지하고 사용하지 않는 메모리를 복구하는 시간이다. G1은 Pause Time에 따라 내부 값이 조절 되므로 중요한 인자이다. Pause Time은 최대 GC Puase Time을 설정할 수 있는 인자인 -XX:MasxGCPauseMillis로 조절할 수 있다.
Throughput을 높이면 GC 횟수가 줄고 한번에 더 많은 메모리를 수집하게 된다. 이에 Pause time이 길어질 수 있으므로 이를 잘 조절해야 한다. Throughput은 GC가 전체 시간에서 차지할 비율을 조절 하는 인자인 -XX:GCTimeRatio로 조절할 수 있다. G1은 90%의 애플리케이션 시간과 10%의 GC 시간을 목표로 한다.
3.2 Throughput 튜닝
다음은 Throughput을 높일 수 있는 다른 인자들에 대한 정보다.
-XX:MaxGCPauseMillis: Pause Time이 길어지면 자동으로 더 많은 메모리를 수집할 시간이 많아지게 된다.-XX:G1MaxNewSizePercent: Young generation 영역 크기를 늘린다.gc+heap=info으로 Young 사이즈를 확인 할 수 있다.-XX:G1RSetUpdatingPauseTimePercent: Remembered set을 업데이트 하는 작업은 CPU를 많이 사용한다. Concurrent refinement를 없에고 GC Pause 시간으로 작업을 옮긴다. 이 값을 늘리면 더 많은 Remebered Set 업데이트를 동시에 하지 않고 GC Pause 시간이 늘어난다.gc+phases=debug에서 나오는 Merge Heap Roots를 찾아서 확인하자.-XX:+UseLargePages: Page table 크기를 키우면 throughput이 늘어난다. TLB는 OS가 관리하므로 OS 마다 따로 확인해야 한다.
References
- https://dev.java/learn/jvm/tool/garbage-collection/
- https://www.oracle.com/webfolder/technetwork/tutorials/obe/java/gc01/index.html
- https://www.redhat.com/en/blog/part-1-introduction-g1-garbage-collector
- https://www.redhat.com/en/blog/collecting-and-reading-g1-garbage-collector-logs-part-2
- https://docs.oracle.com/en/java/javase/21/gctuning/garbage-first-g1-garbage-collector1.html
- https://docs.oracle.com/en/java/javase/21/gctuning/garbage-first-garbage-collector-tuning.html
- https://docs.oracle.com/en/java/javase/21/docs/specs/man/java.html#advanced-garbage-collection-options-for-java
- https://d2.naver.com/helloworld/37111