
서비스를 운영하면 가끔 트래픽이 평소와 다르지 않은데 갑자기 응답 지연 발생, CPU 사용률 상승, 서비스 잠시 멈춤 등의 문제가 발생할 수 있다. 이 상황에서 콘솔을 봐도 에러에 대한 로그도 나타나지 않는다. 이러한 경우는 상당수 GC관련으로 발생된 문제이다.
GC는 Java 언어에서 큰 장점 중 하나이다. C/C++의 경우에는 개발자가 메모리를 직접 할당을 하고 해제를 해야 하지만, Java는 JVM이 알아서 사용되지 않는 객체를 수거하여 메모리를 관리하기 떄문에 개발자는 비교적으로 메모리 누수나 여러 문제에서 자유롭게 작업할 수 있다는 장점이 있다.
하지만, GC가 동작하는 동안 JVM은 일정 시간 어플리케이션 스레드를 멈추게 한다. 이러한 현상을 Stop-The-World (STW)라고 한다. 이러한 현상이 길어진다면 응답 지연, 타임아웃, 최악의 걍우에는 Out Of Memory Error)로 이어지게 된다.
GC를 이해하면 객체 생명 주기를 의식하면서 코드를 작성하게 되고, 장애 상황에서 GC로그를 읽어 빠르게 원인을 해결할 수 있다.
JVM의 Runtime Data Area는 크게 다음으로 구성된다.
⠀
GC가 관리하는 핵심 영역은 Heap이다. Heap은 아래와 같은 구조를 갖는다.
⠀
이 세대 구조는 GC의 핵심 가설인 "대부분의 객체는 금방 죽는다"는 관찰에 기반한다. 이를 약한 세대 가설(Weak Generational Hypothesis) 이라 한다.
GC의 본질적인 역할은 Heap 메모리에서 더 이상 사용되지 않는 객체를 찾아 제거하고, 메모리를 재사용 가능한 상태로 만드는 것이다.
"더 이상 사용되지 않는 객체"를 어떻게 판단하는가? JVM은 도달 가능성(Reachability) 개념을 사용한다. GC Root에서 시작해 참조 관계를 따라갔을 때 도달할 수 있으면 살아있는(Live) 객체, 도달할 수 없으면 죽은(Dead) 객체로 판단한다.
GC Root의 종류:
GC Root
│
├──▶ Object A (Live)
│ └──▶ Object B (Live)
│
└──▶ Object C (Live)
Object D (Dead) ← 아무도 참조하지 않음
Object E (Dead) ← D만 참조하고 있었음
GC는 D와 E를 수거 대상으로 판단하고 메모리에서 제거한다. 참조 카운팅(Reference Counting) 방식은 순환 참조 문제가 있어 현대 JVM에서는 사용하지 않는다.
young Generation, 특히 Eden이 포화되면 Minor GC가 발생된다.
old Generation이 꽉 차거나 일정 임계치에 도달하면 Major GC(또는 Full GC)가 발생한다. 범위가 넓어 Minor GC보다 훨씬 오래 걸리며, STW 시간도 길다.
Full GC는 일반적으로 Young과 Old Generation을 포함한 Heap 전체를 대상으로 수행되며,
경우에 따라 Metaspace까지 영향을 줄 수 있다. 수행 비용이 크기 때문에
발생 빈도가 낮을수록 이상적이다.
GC의 고전적인 3단계 알고리즘이다.
1 Mark: GC Root에서 시작해 살아있는 객체에 표시한다.
2 Sweep: 표시되지 않은 객체를 제거한다.
3 Compact: 남아있는 객체들을 한쪽으로 모아 단편화(Fragmentation)를 해소한다.
[객체 존재]
O O X O X
↓
[Mark]
O O _ O _
↓
[Sweep]
O O O
↓
[Compact]
O O O
⠀
Compact 단계는 비용이 비싸기 때문에, 일부 GC는 Compact 대신 Copy(복사) 방식을 사용한다. Young Generation의 Survivor 방식이 대표적이다.
GC가 동작하는 동안 JVM은 GC 스레드 외의 모든 애플리케이션 스레드를 일시 정지시킨다. 이것이 STW다. STW가 발생하는 이유는 GC가 힙을 스캔하는 도중 객체가 생성·수정되면 일관성 있는 스냅샷을 얻을 수 없기 때문이다.
STW를 줄이는 것이 현대 GC 설계의 핵심 목표다.
단일 스레드로 GC를 수행한다. 클라이언트 애플리케이션이나 단일 CPU 환경을 위해 설계되었다. -XX:+UseSerialGC로 활성화한다. 서버 환경에는 적합하지 않다.
Minor GC와 Major GC 모두 여러 스레드로 병렬 수행한다. STW는 발생하지만 GC 자체가 빠르게 끝난다. 처리량(Throughput) 최대화가 목표다. Java 8의 기본 GC였다. -XX:+UseParallelGC로 활성화한다.
Old Generation의 GC를 애플리케이션 스레드와 동시에(Concurrent) 수행하여 STW를 최소화한다. 단, Compact 단계가 없어 메모리 단편화가 발생하고, CPU를 더 많이 사용한다. Java 9부터 Deprecated되었고 Java 14에서 제거되었다.
Java 9부터 기본 GC다. 힙을 Region이라는 고정 크기의 작은 블록으로 나눈다. Young/Old 구분이 고정적이지 않고 Region 단위로 동적으로 할당된다. 가비지가 많은 Region부터 우선적으로 수거하는 방식으로 예측 가능한 STW 시간을 목표로 한다.
[ G1 Heap 구조 ]
┌───┬───┬───┬───┬───┬───┬───┬───┐
│ E │ E │ S │ O │ O │ H │ E │ O │
└───┴───┴───┴───┴───┴───┴───┴───┘
E: Eden, S: Survivor, O: Old, H: Humongous(대형 객체)
-XX:MaxGCPauseMillis로 STW 목표 시간을 설정할 수 있다는 점이 실무에서 매우 유용하다.
Java 15부터 정식 지원. STW를 수 밀리초 이하로 유지하는 것을 목표로 한다. 대부분의 GC 작업을 애플리케이션 스레드와 동시에 수행한다. TB급 힙에서도 안정적이다. 지연 시간이 극도로 중요한 서비스에 적합하다.
ZGC와 유사한 목표를 가진 저지연 GC. Red Hat이 개발했으며 OpenJDK 12부터 포함되었다. Compact 단계도 동시에 수행하는 것이 특징이다.
| GC | STW 목표 | 기본 적용 버전 | 특징 |
|---|---|---|---|
| Serial | 없음 | - | 단일 스레드, 소규모 |
| Parallel | 처리량 최대 | Java 8 | 멀티 스레드, 배치에 적합 |
| CMS | 짧은 STW | (Deprecated) | 동시 수행, 단편화 문제 |
| G1 | 예측 가능 STW | Java 9 이후 | Region 기반, 범용 |
| ZGC | 수 ms 이하 | Java 15 | 초저지연, 대용량 힙 |
| Shenandoah | 수 ms 이하 | Java 12 | 동시 Compact |
Full GC가 자주 발생한다면 다음 원인을 먼저 의심한다.
튜닝은 JVM 옵션만의 영역이 아니다. 코드 수준에서도 GC 부담을 줄일 수 있다.