Java에서는 memory 관리를 위해 Garbage Collection을 수행한다. 프로그램 실행 중 동적으로 생성되는 데이터는 Heap 영역에 적재되며, C/C++에서 개발자가 직접 malloc/calloc()을 통해 메모리를 할당한 후 free를 통해 메모리를 관리하는 것이 아닌, 자동으로 메모리를 할당하고 GC에 의해 할당 된 메모리를 회수하는 것이다.
이러한 Garbage Collection을 수행하는 주체를 Garbage Collector라 하며, 이전 포스트에서 Java의 Garbage Collection 알고리즘에 대해 작성 하였으니, 이번에는 Garbage Collector의 종류와 Java version에 따른 GC 변화에 대해 작성해 보겠다.
종종 Garbage Collector와 Garbage Collection을 모두 GC로 칭해 혼동이 있을 것 같은데, 특별한 언급이 없다면 이번 포스트에서는 GC는 Collector를 지칭하도록 하겠다.
우선 GC의 종류에 대해 알아보기 전, JVM의 구조, 정확히는 JVM의 Runtime Data Area중 Heap 영역의 구조에 대해 간략히 작성해 보겠다.
Java는 프로그램 내 대부분의 객체는 짧은 시간만 살아있다는 약한 세대 가설(? - Weak Generational Hypothesis)을 기반으로

아래와 같이 Heap영역을 세 가지 세대(Generation)으로 구분하였다.

위 그림에서 Virtual은 필요 시 실제 메모리에 할당되는 가상의 영역으로, Heap 영역은 Eden, Survivor, 그리고 Tenured(편의 상 Old로 지칭하겠다) 영역으로 나누어 진다.
위 그림은 Java 8 문서에서 가져온 그림으로 PermGen은 포함하지 않는다.
세 영역을 다시 Young, Old 두 개의 영역으로 구분하여 각각 다른 방식으로 Garbage Collection을 수행하는데, Young 영역에서는 Eden에 최초 위치한 객체에 대해 GC를 수행하면서 살아남은 객체를 Survivor로 이동 시키는 Copy & Scavenge 방식의 Minor GC를, Old 영역에서는 메모리 내 사용되지 않는 객체를 찾아 비우는 Mark & Swap(+Compact) 방식의 Major GC가 있다.
각각의 동작을 간략히 표현하면 아래와 같이 표현할 수 있다

실제 GC 동작 방식은 아래 작성할 Garbarge Collector 및 GC algorithm에 따라 상이하다.
Minor/Major GC 동작을 위해 프로그램의 동작을 멈추고(STW, Stop The World) 메모리 작업을 수행해야 하야 하며, 특히 Major GC의 경우 오랜 시간이 소요되기 때문에 다양한 GC algorithm이 존재하며 발전해 왔다.
해당 포스트에서 다루는 GC는 HotSpot JVM을 기준으로 하고 있으며, J9 / IBM 등 JVM에 따라 상이할 수 있다
JVM의 GC 타입과 실행 옵션은 아래와 같으며
| Type | Option |
|---|---|
| Serial GC | -XX:+UseSerialGC |
| Parallel GC | -XX:+UseParallelGC |
| Parallel Old GC | -XX:+UseParallelOldGC |
| Concurrent Mark Sweep GC | -XX:+UseConcMarkSweepGC |
| G1(Garbage First) GC | -XX:+UseG1GC |
| Shenandoah GC | -XX:+UseShenandoahGC |
| Z GC | -XX:+UseZGC |
각 영역(Young/Old Generation)별 Java GC의 종류는 다음과 같다.
Young Generation(Minor GC)
Old Generation(Major GC)
Others
아래에서 확인할 GC에서 사용하는 minor/major GC에 대한 정보는 아래 코드를 통해 확인할 수 있다
public class GCTest {
public static void main(String[] args) {
List<GarbageCollectorMXBean> beans = ManagementFactory.getGarbageCollectorMXBeans();
for (GarbageCollectorMXBean bean : beans) {
System.out.println(bean.getName());
}
}
}
IntelliJ 기준 run configuration에서 JVM option을 설정할 수 있다.

-XX:+UseSerialGC
가장 기본적인 GC로, 하나의 thread가 처리한다. Garbage Collection을 수행하는 시간이 오래 걸려, Stop The World 시간 또한 길어지게 된다.

Minor GC에는 Copy collector를, Major GC에는 Mark-Sweep(-Compact) collector를 사용한다.

-XX:+UseParallelGC
Serial GC와 유사한 방식으로 동작하지만, 여러 thread를 활용한 병렬 방식으로 동작한다.
Parallel GC는 Young generation에 대해 여러 thread를 통해 빠른 처리가 가능하며, Througput GC라 불리기도 하며, Parallel Old GC는 Young generation 뿐만 아니라 Old generation에 대해서도 여러 thread를 통해 빠른 처리가 가능하다.

필요한 경우 -XX:+ParallelGCThread= option을 통해 GC에 사용할 thread의 수를 설정할 수 있으며, 기본적으로 cpu 수 만큼 사용한다(CPU 수가 8개 이상인 경우 상이하다)
다만, Minor GC에서 여러 thread를 통해 동작하는 경우, 둘 이상의 thread에서 promotion이 발생 하는 경우 문제가 발생할 수 있는데, 이는 PLAB(Parallel Local Allocation Buffer) 등 JVM의 메모리 할당 방법을 통해 해결할 수 있다. (자세한 내용은 다음에… 작성해 보겠다)
Parallel GC는 Minor GC에 PS Scavenge collector를, Parallel Old GC는 Major GC에 PS MarkSweep collector를 사용한다.

다만 Java 7(?)부터 ParallelGC를 사용하여도 ParallelOldGC가 default로 적용된다.
Parallel Old GC 부터 Mark ans Sweep 방식이 아닌 Summary를 추가한 Mark-Summary-Compact 방식을 사용하는데, 이를 통해 old 영역에 대해 수행되는 Major GC의 pause time(STW) 비율을 줄일 수 있으며, Old 영역의 메모리에 대한 병렬 처리가 가능하다.

Mark-Summary-Compact의 대략적인 동작 과정은 아래와 같다
Mark(STW) : 각 thread 별로 영역(Region)을 나누어 메모리 내 객체를 체크한다.

Summary : 하나의 thread가 Mark phase에서 수집한 정보를 기반으로 dense prefix를 결정한다. 이때 GC를 수행하지 않는 다른 thread들은 application thread로 실행되어 STW 상황이 아닌 프로그램이 실행 가능한 상황이 된다.

Dense prefix는 살아남은 객체가 많은 region에 대한 prefix로, 이는 이후compactphase에 사용되며, 해당 prefix 이전의 Region은 compaction의 대상이 되지 않는다. 이는 오래 살아남은 객체는 사용될 가능성이 있다는 가정 하에 compaction 대상 영역을 줄이는 것이다.
Compact(STW) : 각 thread들이 compaction을 수행하는 GC thread가 된다. Dense prefix 이후의 region을 source와 destination으로 구분하여 사용하지 않는 메모리 정리 및 compaction을 수행한다.

-XX:+UseParNewGC
Minor GC로 ParNew GC를 사용하며, Young 영역에 대한 병렬 GC 작업이 가능하며, Old 영역의 Major GC가 객체에 대한 작업을 수행할 수 있도록 하는 “callback”을 가진 GC라고 한다. Concurrent Collector와 함께 사용될 목적으로 개발된 듯 하다.


Java 8 이전에 사용 가능한 GC지만, Java 8에서 부터 deprecated 되었으며, Java 9 에서 부터 사라지게 되었다.
-XX:+UseConcMarkSweepGC
CMS(Concurrent Mark Sweep) GC는 Parallel GC의 pause time(STW)을 줄이고자 도입된 GC이다. 전체적인 성능이 떨어지는 문제점이 있지만, 성능이 아닌 STW로 인한 pause time을 줄이고자 한 GC이며, 성능 문제 등으로 인해 Java 9에서 deprecated 되었고 Java 14에서 제거 되었다. Parallel (Old) GC의 Mark-Summary-Compact 방식 또한 STW를 줄일 수 있지만, Summary phase에서 여러 thread의 동시성에 대한 제어를 구현한 방식에 따라 STW가 존재할 수 있으며 이러한 STW를 더 줄이고자 한 방식이 CMS GC이다.
Minor GC의 경우 Parallel GC와 유사하지만, Major GC의 동작 방식이 다르다. STW를 줄이고자 하였기에 STW가 발생하는 몇몇 phase와 그렇지 않은 phase로 나누어 복잡하게 구현되었으며, Initial Mark, Marking/Pre-cleaning, Remark, Sweeping단계를 통해 구현하였다.

대략적인 동작은 위 그림과 같으며, 각 단계 별 동작은 아래와 같다.

GC root 와 Young 영역의 객체에서 직접 참조되는 객체를 marking하여 STW를 최소화 한다.Dirty Card를 통해 프로그램 동작 중 변경 사항을 반영한다.CMS는 복잡한 phase를 통해 STW를 최소화 하여 pause time을 줄일 수 있지만, compact phase가 없어 발생하는 메모리 단편화 및 이로 인해 발생할 수 있는 CMF(Concurrent Mode Failure)와 프로그램 동작 중 반영할 수 없는 참조 변화(Floating Garbage) 등으로 인해 필요한 GC Scheduling 등의 문제가 존재하고 복잡한 과정으로 인한 높은 CPU 사용량 등으로 인해 사용되지 않게 되었다.
Minor GC에 ParNew, Major GC에 ConcurrentMarkSweep GC를 사용하지만, Java 14 이후 찾아볼 수 없는 GC이다.

-XX:+UseG1GC
기존 GC 들이 메모리를 물리적 영역으로 나눈 것과 달리, Heap 영역을 특정 사이즈(default : 1MB)의 Region이라는 영역으로 나누어 기존 영역의 역할을 동적으로 각 Region에 부여한다. G1 GC의 목적은 큰 메모리가 있는 환경에서 높은 throughput과 적은 pause time을 목표로 하는 것이다.
Throughput과 STW로 인한 pause time 간에는 trade-off가 존재할 수 있으며, G1 GC의 목표는 이들 사이 최적의 결과를 내는 것이다.
”The G1 collector achieves high performance and tries to meet pause-time goals in several ways described in the following sections.”
HW 성능이 갖추어 졌을 때 최적화를 위한 GC인 만큼 G1 GC에 적합한 상황은 아래와 같다.
G1(Garbage First) GC의 특징은 STW를 통해 throughput을 향상 시키지만 global marking과 같은 긴 STW가 필요한 작업을 application thread와 병렬로 처리해 짧은 pause time을 유지하고, Garbage의 비율이 높은(효율적인) region을 우선적으로(Garbage First), 병렬로 점진적(incrementally in steps and in parallel)으로 정리하여 pause time을 짧게 유지하는 것이다.

G1 GC는 Heap 영역을 region으로 나누는데 각 영역은 Eden, Survivor, Old, Humongous, Available/Unused 가 될 수 있으며 각각은 아래와 같다

G1 GC의 동작 Cycle은 위 그림과 같다.
Young-only phase는 객체를 Old 영역으로 할당(Promotion) 및 young generation에 대한 garbage collection을 수행하는 단계이며, Space Reclamation phase는 Young, Old 영역에서 Garbage Collection을 수행하는 단계이다. 이전 Major GC들이 Old 영역에 대한 garbage collection 만 수행 했던 것과 달리 G1 GC에서는 old/young 모두 수행하기에 Mixed GC라고 부른다.
Young-only phase는 Eden 영역이 가득 찼을 때 객체를 Survivor/Old 영역으로 이동 및 제거를 수행하는 GC와, 객체들에 대한 marking 작업과 Space Reclamation phase로 전환할 지 결정하는 과정을 포함한다. 위 그림과 같이 Old generation의 메모리 점유율이 특정 수준을 넘어가면 Concurrent Start phase로 전환이 될 수 있으며, 이 threshold를 IHOP(Initiating Heap Occupancy Percent)라고 한다. 이후 Remark, Cleanup 과정을 거치는데, Cleanup 과정에서 Space Reclamation 단계를 수행할 지 결정한다. 대략적인 G1 GC의 동작 cycle은 아래와 같다.
IHOP(Initiating Heap Occupancy Percent)에 도달하면 동작하며, Young 영역에 대한 garbage collection 뿐만 아니라, 이후 Space Reclamation phase를 위해 Old 영역에 대한 marking 또한 수행한다. 이 과정에서 Normal young GC는 계속 발생할 수 있다.Concurrent Start의 다음 단계로, 전역 참조와 class unloading을 수행하며 빈 region 회수 및 내부 데이터 구조를 정리한다. 또한 이후 Cleanup phase를 위한 정보를 계산한다. (G1 calculates information to later be able to … : Space Reclamation phase로 전환할 지 결정한다. 전환하는 경우 young-only phase는 Single Prepare Mixed young collection으로 전환되며 끝나게 된다.Space-Reclamation phase가 끝나면 다시 young-only phase가 시작된다.또한, G1 GC는 위 동작을 통해 짧은 pause time과 높은 throughput을 유지하려 하지만, 프로그램과 병렬로 진행되어 메모리 공간이 부족한 상황이 발생할 수 있다. 이러한 경우, G1 GC는 다른 GC와 유사하게 STW를 동반하는 Full GC를 통해 메모리 공간을 확보한다.
마지막으로 G1 GC가 사용하는 minor/major GC는 아래 사진과 같다.

-XX:+UseShenandoahGC
Java 12에 등장한 GC로 concurrent한 대량의 작은 garbage collection 작업을 통해 heap 크기에 크게 영향을 받지 않고 pause time을 줄일 수 있는 GC이다. G1 GC를 기반으로 하여, heap 영역을 region으로 나누고 유사한 garbage collection 작업을 수행하지만, G1 GC와 달리 각 region을 young/old 등 세대에 따라 구분하지 않는다. 이를 Single-Generational Shenandoa GC라 하며, Generational Shenandoa GC 또한 존재한다.

GC의 대략적인 동작 과정은 아래와 같다.
Root set을 scan 하여 다음 concurrent mark를 준비하는 단계이다.forwarding pointer를 부여하는 Brooks Forwarding Pointer 방식을 통해 이 과정에서 application thread가 해당 객체에 접근할 수 있도록 할 수 있다.Root set을 재갱신(re-update)하며 참조 갱신을 완료한다.Shenandoa GC는 위와 같은 복잡한 과정을 통해 GC를 수행하며, Concurrent한 작업을 통해 STW를 최소화 하였다. 이 때 Concurrency를 보장하기 위한 주요 알고리즘으로 STAB(Snapshot At The Beginning)과 LRB(Load Reference Barriers)가 있는데, 이에 대해서는 추후 기회가 된다면 자세히 다루어 보겠다.

-XX:+UseZGC
마지막으로 Java 15에 등장한 Z GC(Z Garbage Collector)는 G1 GC와 달리 고정된 크기의 Region이 아닌 동적인 크기의 ZPage를 활용해 대량 메모리 처리에도 10ms의 STW 시간을 넘지 않는 장점이 있는 GC이다.
ZPage는 small, medium, large 세가지 타입이 있으며 그 크기는 각각 2, 32, 2*N MB이며 4MB 이상의 객체는 large ZPage에 할당 된다. 또한 Large page에는 단 하나의 객체만 할당 될 수 있다.

...

GC를 통해 개발자가 직접 메모리를 관리할 필요가 없는 편의성을 제공하지만, 반대로 메모리를 직접 관리할 수 없는 단점이 있다. STW와 같은 프로그램의 성능에 영향을 끼치는 단점이 있기에 이를 해결/보완 하기 위해 다양한 GC 알고리즘/타입이 개발되었고 Java의 GC는 위에서 알아본 것 과 같다.
GC 선정에 있어 목적은 크게 두 가지로 나눌 수 있는데, 높은 처리량(throughput)과 짧은 멈춤 시간(pause time : STW)이다. 이를 위해 GC는 병렬 처리와 메모리를 나누어 분리하는 paging(region/Zpage) 방식을 사용하였는데, 각 GC가 어떻게 concurrency를 보장하며 성능을 개선하였는지 GC 튜닝과 함께 다음 포스트에서 다루어 보겠다.
https://docs.oracle.com/javase/8/docs/technotes/guides/vm/gctuning/generations.html
https://docs.oracle.com/en/java/javase/17/gctuning/garbage-first-g1-garbage-collector1.html
https://wiki.openjdk.org/display/shenandoah/Main
https://hub.packtpub.com/getting-started-with-z-garbage-collectorzgc-in-java-11-tutorial/