
Java 애플리케이션을 운영하다 보면 GC 로그에서 다음과 같은 표현을 만나게 된다.
Pause Young
Concurrent Mark Cycle
Pause Young (Mixed)
Pause Full
처음 보면 이름만 비슷해서 각각 무엇이 다른지 헷갈리기 쉽다.
핵심은 “어느 영역을 대상으로 객체를 회수하는가”와 “왜 해당 GC가 발생했는가”다.
G1 GC에서는 전체적인 흐름을 다음과 같이 이해하면 편하다.
Young GC
Eden + Survivor 영역 중심으로 객체 회수
↓
Old Region 사용량 증가
↓
Concurrent Marking
Old Region의 살아있는 객체 조사
↓
Mixed GC
Young Region + 일부 Old Region 회수
↓
메모리 확보 실패 또는 심한 메모리 압박
↓
Full GC
Heap 전체를 대상으로 강한 정리 수행
전통적인 GC 설명에서는 Heap을 보통 다음처럼 나눈다.
Young Generation
├─ Eden
├─ Survivor 0
└─ Survivor 1
Old Generation
G1 GC도 Young과 Old라는 개념은 사용하지만 실제 Heap을 물리적으로 연속된 공간으로 나누지는 않는다.
대신 Heap 전체를 여러 개의 Region이라는 작은 단위로 나눈다.
Heap
┌────────┬────────┬────────┬────────┬────────┐
│ Eden │ Old │ Eden │Survivor│ Old │
├────────┼────────┼────────┼────────┼────────┤
│ Old │ Eden │ Old │ Free │ Old │
└────────┴────────┴────────┴────────┴────────┘
각 Region은 상황에 따라 Eden, Survivor, Old 등의 역할을 맡는다.
이 구조 덕분에 G1은 Old 영역 전체를 한 번에 처리하는 대신 정리 효과가 높은 Region을 골라서 회수할 수 있다.
이 특징이 바로 Mixed GC와 연결된다.
Young GC는 새롭게 생성된 객체들이 주로 존재하는 Young 영역을 정리하는 GC다.
일반적인 객체 생성 흐름은 다음과 같다.
객체 생성
↓
Eden
↓
GC에서 살아남음
↓
Survivor
↓
여러 번 살아남음
↓
Old
Eden 영역이 부족해지면 G1은 Young GC를 수행한다.
GC가 발생하면 사용되지 않는 객체는 제거되고 살아남은 객체는 Survivor Region으로 이동하거나 일정 조건을 만족하면 Old Region으로 이동한다.
이 과정에서 객체가 Old 영역으로 이동하는 것을 Promotion이라고 한다.
대략적인 흐름은 다음과 같다.
Before Young GC
Eden
[AAAA BBBB CCCC DDDD]
Survivor
[EEEE]
↓ GC
After Young GC
Eden
[ ]
Survivor
[AAAA EEEE]
Old
[BBBB]
실제로 어떤 객체가 살아남고 승격되는지는 객체의 생존 횟수, Survivor 공간 상황 등의 영향을 받는다.
GC를 설명할 때 흔히 Young Generation을 수집하는 GC를 Minor GC라고 부른다.
따라서 개념적으로는 다음처럼 이해할 수 있다.
Minor GC
≈ Young Generation을 대상으로 하는 GC
다만 G1 GC 로그에서는 보통 Minor GC라는 표현보다 Young GC 또는 Pause Young이라는 표현을 사용한다.
예를 들어 다음과 같은 로그를 볼 수 있다.
Pause Young (Normal) (G1 Evacuation Pause)
따라서 G1을 분석할 때는 단순히 Minor GC라는 용어만 기억하기보다 Young GC라는 로그 표현에 익숙해지는 것이 좋다.
Young GC가 반복되면서 오래 살아남은 객체는 Old Region으로 이동한다.
Young GC
↓
Young GC
↓
Promotion
↓
Old Region 증가
↓
Promotion
↓
Old Region 증가
하지만 Old Region을 무한히 늘릴 수는 없다.
어느 정도 Old 영역의 사용량이 증가하면 G1은 Old Region에 어떤 객체들이 살아있는지 조사하기 시작한다.
이 과정이 Concurrent Marking이다.
Concurrent Marking은 Heap 전체를 멈추고 Old 객체를 한꺼번에 정리하는 과정이 아니다.
이름 그대로 애플리케이션과 동시에 실행되는 작업이 상당 부분 포함된다.
목적은 간단하다.
어떤 Old Region에 살아있는 객체가 많고, 어떤 Region에 죽은 객체가 많은지 파악하는 것
예를 들어 다음과 같은 Old Region이 있다고 생각해보자.
Old Region A
[████████████████████] 90% 사용
Old Region B
[██ ] 10% 사용
Old Region C
[██████████████ ] 70% 사용
Region B는 살아있는 객체가 매우 적다.
이 Region을 정리하면 한 번의 작업으로 많은 메모리를 확보할 수 있다.
G1은 이런 정보를 수집해서 이후 Mixed GC에서 정리 효율이 높은 Old Region을 우선적으로 선택한다.
그래서 이름도 G1이다.
Garbage First
즉, Garbage가 많이 존재해서 정리했을 때 이득이 큰 Region부터 처리한다는 의미다.
G1 GC에서 특히 중요한 개념이 Mixed GC다.
Mixed GC는 Young 영역만 정리하지 않는다.
Young Region
+
선택된 Old Region 일부
를 함께 정리한다.
그래서 이름이 Mixed다.
Young GC
Eden + Survivor
↓
GC
반면 Mixed GC는 다음과 같다.
Mixed GC
Eden
Survivor
Old Region B
Old Region D
Old Region F
↓
GC
여기서 중요한 점이 있다.
Old 영역 전체를 수집하는 것이 아니다.
Concurrent Marking 결과를 이용해 Garbage 비율이 높아 회수 효과가 좋은 Old Region 일부를 선택해서 처리한다.
예를 들어 다음과 같은 상황이라면
Old A : Garbage 10%
Old B : Garbage 80%
Old C : Garbage 20%
Old D : Garbage 75%
Old E : Garbage 15%
G1은 B와 D처럼 정리 효율이 높은 Region을 우선적으로 선택할 수 있다.
이 방식 덕분에 Old 전체를 한 번에 정리하는 부담을 줄일 수 있다.
간단하게 비교하면 다음과 같다.
| 구분 | Young GC | Mixed GC |
|---|---|---|
| 주요 대상 | Young Region | Young + 일부 Old Region |
| 발생 목적 | Young 영역 확보 | Young 정리 + Old 영역 회수 |
| Old Region 처리 | 기본적으로 하지 않음 | 일부 처리 |
| Concurrent Marking 결과 사용 | 직접적인 핵심 아님 | 사용 |
| STW 발생 | 있음 | 있음 |
여기서 주의할 점이 있다.
Concurrent GC라고 해서 GC 전체가 애플리케이션을 전혀 멈추지 않는 것은 아니다.
G1에서도 Young GC나 Mixed GC 과정에서는 Stop-The-World 구간이 발생한다.
따라서 운영에서는 GC가 발생했다는 사실뿐 아니라 Pause 시간이 얼마나 길었는지가 중요하다.
Full GC는 상황이 조금 다르다.
Young GC와 Mixed GC는 G1이 정상적으로 Heap을 관리하면서 흔히 수행할 수 있는 과정이다.
하지만 Full GC가 발생했다면 조금 더 주의해서 볼 필요가 있다.
Young GC
↓
Concurrent Marking
↓
Mixed GC
↓
메모리 확보가 충분하지 않음
↓
Full GC
Full GC에서는 Heap 전체를 대상으로 강한 정리가 수행될 수 있으며 큰 STW가 발생할 가능성이 있다.
즉 애플리케이션이 오랫동안 멈추는 원인이 될 수 있다.
예를 들어 API 서버가 평소에는 다음처럼 응답한다고 해보자.
평균 응답 시간
100ms
그런데 Full GC로 인해 JVM이 2초 동안 멈추면 해당 시점의 요청들이 영향을 받을 수 있다.
Request
↓
[ JVM 실행 ]
↓
===== Full GC =====
STW 2초
↓
[ JVM 실행 재개 ]
↓
Response
그래서 운영 환경에서 Full GC가 반복적으로 발생한다면 단순히
Full GC가 발생했다.
에서 끝내면 안 된다.
왜 Full GC까지 도달했는지를 찾아야 한다.
최근 JDK에서는 Unified Logging을 이용해 GC 로그를 확인할 수 있다.
Spring Boot 애플리케이션을 다음과 같이 실행해볼 수 있다.
java -Xlog:gc*,gc+heap=debug -jar app.jar
로그를 볼 때 처음부터 모든 내용을 분석하려고 하면 어렵다.
우선 다음 키워드를 찾아보는 것이 좋다.
Pause Young
Concurrent Mark
Mixed
Pause Full
JDK 버전과 GC 상황에 따라 실제 로그 문구는 조금씩 달라질 수 있다.
예를 들어 Young GC는 다음과 비슷하게 나타날 수 있다.
Pause Young (Normal) (G1 Evacuation Pause)
Mixed GC도 로그에 Mixed라는 정보가 포함되어 구분할 수 있다.
GC 로그를 분석할 때 처음에는 복잡한 지표를 모두 보려고 하지 않아도 된다.
다음 세 가지부터 확인하면 된다.
GC 발생 시간
GC가 너무 자주 발생하는지 확인한다.
예를 들어
1초
Young GC
2초
Young GC
3초
Young GC
처럼 매우 짧은 간격으로 반복된다면 객체 생성량이나 Heap 크기를 확인할 필요가 있다.
Pause Young ... 12ms
Pause Young ... 25ms
Pause Full ... 1800ms
GC의 핵심은 횟수만이 아니다.
애플리케이션이 실제로 얼마나 오래 멈췄는가가 중요하다.
특히 Full GC의 Pause 시간이 길다면 서비스 응답 지연으로 바로 연결될 수 있다.
예를 들어 다음과 같은 로그가 있다고 생각해보자.
2048M -> 800M
GC 전에는 약 2GB를 사용했고 GC 후에는 약 800MB까지 감소했다는 의미다.
반대로 다음과 같다면 주의할 필요가 있다.
2048M -> 1900M
GC를 수행했는데도 메모리가 거의 줄지 않았다.
즉 상당수 객체가 여전히 살아있다는 의미다.
이런 상태가 반복된다면 Old 영역이 계속 증가하면서 메모리 압박으로 이어질 수 있다.
Full GC는 원인이 아니라 결과인 경우가 많다.
따라서 다음 단계에서는 원인을 추적해야 한다.
대표적으로 확인할 부분은 다음과 같다.
Young 객체가 너무 빠르게 Old로 이동하고 있는지 확인한다.
객체 대량 생성
↓
Young GC 반복
↓
Survivor 부족
↓
Old Promotion 증가
↓
Old 압박
애플리케이션에서 더 이상 필요하지 않은 객체를 계속 참조하고 있다면 GC는 해당 객체를 제거할 수 없다.
GC Root
↓
객체 A
↓
객체 B
↓
객체 C
GC Root에서 객체까지 참조가 이어져 있다면 객체는 살아있는 것으로 판단된다.
캐시, static 컬렉션, ThreadLocal, Listener 등의 잘못된 관리가 메모리 누수의 원인이 될 수도 있다.
G1에는 Humongous Object라는 개념이 있다.
Region 크기의 절반 이상을 차지하는 매우 큰 객체는 일반 객체와 조금 다르게 처리된다.
대표적으로 큰 배열이나 대용량 데이터를 한 번에 메모리에 올리는 코드가 영향을 줄 수 있다.
byte[] data = new byte[100 * 1024 * 1024];
이런 객체가 반복적으로 생성되면 Heap 사용 패턴과 GC 동작에 영향을 줄 수 있다.
따라서 GC 로그에서 Humongous Region이 비정상적으로 증가한다면 큰 객체가 반복 생성되고 있는지 확인해야 한다.
G1 GC의 흐름을 단순화하면 다음과 같다.
객체 생성
↓
Eden 사용량 증가
↓
Young GC
↓
살아남은 객체 Survivor 이동
↓
오래 살아남은 객체 Old Promotion
↓
Old Region 증가
↓
Concurrent Marking
↓
Old Region의 생존 객체 비율 계산
↓
Mixed GC
↓
Young + 일부 Old Region 회수
정상적인 상황에서는 이 과정을 반복하면서 Heap을 관리한다.
하지만 메모리 확보가 제대로 이루어지지 않는 상황에서는
메모리 압박
↓
Evacuation 실패 등 문제 발생
↓
Full GC
와 같은 상황으로 이어질 수 있다.
G1 GC 로그를 볼 때 가장 중요한 것은 GC 이름을 외우는 것이 아니라 Heap이 어떤 흐름으로 관리되고 있는지 이해하는 것이다.
핵심만 다시 정리하면 다음과 같다.
Young GC
→ Young 영역 중심으로 객체 회수
Concurrent Marking
→ Old Region의 생존 객체 조사
Mixed GC
→ Young + 회수 가치가 높은 일부 Old Region 처리
Full GC
→ 정상적인 Mixed GC만으로 메모리 확보가 어려운 상황 등을 의심
그리고 실제 GC 로그를 볼 때는 우선
1. GC가 얼마나 자주 발생하는가
2. Pause 시간이 얼마나 긴가
3. GC 후 Heap이 얼마나 줄어드는가
이 세 가지를 확인하면 된다.
그다음 단계에서
Promotion
Old Generation 증가
Humongous Object
GC Root
메모리 누수
Heap 크기
객체 생성량
을 함께 살펴보면 단순히 “GC가 많이 발생한다”에서 끝나는 것이 아니라 왜 GC가 발생하고 있는지까지 추적할 수 있다.
결국 GC 튜닝의 시작은 옵션을 무작정 바꾸는 것이 아니다.
GC 로그를 보고 JVM 안에서 객체가 어떻게 살아남고, 이동하고, 회수되고 있는지를 이해하는 것부터 시작한다.