GC 동작 원리 — Serial부터 ZGC까지
new로 객체를 만들면 메모리는 누가 치우는 걸까? C/C++에서는malloc/free를 직접 호출해야 하지만, Java에서는 GC(Garbage Collector)가 알아서 처리해준다. 근데 "알아서 처리해준다"를 믿고 넘어가면, 운영 환경에서 Full GC가 10초씩 멈출 때 손 쓸 방법이 없다. GC가 어떻게 동작하는지, 종류마다 뭐가 다른지 한 번 정리해두자.
GC가 왜 필요한가 — 수동 메모리 관리 vs 자동
수동 메모리 관리의 문제
C/C++에서는 개발자가 직접 메모리를 할당하고 해제해요. 문제는 두 가지입니다:
- **메모리 누수 **:
free()를 깜빡하면 메모리가 계속 쌓여요. - ** 댕글링 포인터 **: 이미 해제한 메모리를 다시 참조하면 프로그램이 터집니다.
Java의 선택 — GC
Java는 이 문제를 근본적으로 해결하려고 ** 자동 메모리 관리 **를 도입했습니다.
public void createObjects() {
// 객체 생성 — 힙에 할당
List<String> names = new ArrayList<>();
names.add("홍길동");
// 메서드 끝나면 names는 더 이상 참조되지 않음
// → GC가 알아서 수거
}
개발자는 new로 객체를 만들기만 하면 되고, 더 이상 참조되지 않는 객체는 GC가 찾아서 메모리를 회수합니다. 편하긴 한데, GC가 언제, 얼마나 오래 돌아가는지 에 따라 애플리케이션 성능이 크게 달라져요. 그래서 GC의 동작 원리를 이해하는 게 중요합니다.
GC 기본 알고리즘 — Mark-Sweep, Mark-Compact, Copying
모든 GC는 결국 세 가지 기본 알고리즘의 조합입니다.
Mark-Sweep
가장 기본적인 방식입니다.
[Mark 단계]
GC Root에서 시작해서 참조 그래프를 따라가며 "살아있는 객체"에 표시
GC Root → A → B → C (A, B, C는 살아있음)
D (D는 아무도 참조 안 함 → 가비지)
[Sweep 단계]
표시 안 된 객체(D)의 메모리를 해제
결과: [A][ ][B][ ][C][ ] ← 빈 공간이 듬성듬성 (단편화 발생)
- **장점 **: 구현이 단순해요.
- ** 단점 **: ** 메모리 단편화 **가 생깁니다. 빈 공간이 여기저기 흩어져 있어서 큰 객체를 할당하지 못할 수 있어요.
Mark-Compact
Mark-Sweep에 ** 압축(Compaction)** 단계를 추가한 것입니다.
[Mark] 살아있는 객체 표시
[Compact] 살아있는 객체를 한쪽으로 밀어서 빈 공간을 연속으로 만듦
결과: [A][B][C][ ] ← 빈 공간이 뒤쪽에 연속으로
- ** 장점 **: 단편화가 없어요.
- ** 단점 **: 객체를 이동시켜야 해서 ** 비용이 큽니다 **. 참조 주소도 전부 업데이트해야 해요.
Copying
메모리를 두 영역(From, To)으로 나누고, 살아있는 객체만 복사하는 방식입니다.
[From 영역] [A][ ][B][ ][C][ ]
↓ 살아있는 것만 복사
[To 영역] [A][B][C][ ]
복사 후 From 영역은 통째로 비움
- ** 장점 **: 단편화 없음 + 빠릅니다(살아있는 객체만 복사하면 끝).
- ** 단점 **: ** 메모리를 절반만 사용 **할 수 있어요.
실제 GC들은 이 세 가지를 영역별로 조합해서 쓴다. Young 영역은 Copying, Old 영역은 Mark-Compact 같은 식이다.
Generational GC — Young/Old/Metaspace
Weak Generational Hypothesis
GC 설계의 핵심 전제:
- ** 대부분의 객체는 금방 쓸모없어진다** (Young Die Young)
- 오래 살아남은 객체는 계속 살아남을 가능성이 높다
이 가설 덕분에 힙을 ** 세대(Generation)**로 나눠서 효율적으로 수거할 수 있습니다.
힙 메모리 구조
┌─────────────────────────────────────────────────┐
│ Heap │
│ │
│ ┌──────────────────────┐ ┌──────────────────┐ │
│ │ Young Generation │ │ Old Generation │ │
│ │ │ │ │ │
│ │ ┌─────┐ ┌────┐┌────┐│ │ │ │
│ │ │Eden │ │ S0 ││ S1 ││ │ │ │
│ │ └─────┘ └────┘└────┘│ │ │ │
│ └──────────────────────┘ └──────────────────┘ │
└─────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────┐
│ Metaspace (네이티브 메모리) │
│ 클래스 메타데이터, 메서드 정보 등 │
└─────────────────────────────────────────────────┘
| 영역 | 역할 | 특징 |
|---|---|---|
| Eden | 새 객체가 처음 할당되는 곳 | 가장 자주 GC됨 |
| Survivor (S0, S1) | Minor GC에서 살아남은 객체의 임시 거처 | Copying 알고리즘 사용 |
| Old (Tenured) | 오래 살아남은 객체 | Major GC / Full GC 대상 |
| Metaspace | 클래스 메타데이터 | Java 8부터 PermGen 대체, 네이티브 메모리 사용 |
Minor GC vs Major GC vs Full GC
Minor GC: Young 영역만 수거
└─ 빈번하게 발생, 빠름 (보통 수~수십 ms)
Major GC: Old 영역 수거
└─ 덜 빈번하지만 오래 걸림
Full GC: Young + Old + Metaspace 전체 수거
└─ 가장 오래 걸림, 피하고 싶은 녀석
객체의 생애 주기
// 1. new로 객체 생성 → Eden에 할당
Object obj = new Object();
// 2. Eden이 가득 차면 Minor GC 발생
// 살아남으면 Survivor(S0 또는 S1)로 이동
// 3. Minor GC를 반복하며 살아남을 때마다 age 증가
// age가 임계값(기본 15)에 도달하면 Old로 승격(Promotion)
// 4. Old 영역이 가득 차면 Major GC 발생
여기서 헷갈리기 쉬운 부분이 하나 있어요. Minor GC가 발생하면 Eden의 살아있는 객체가 S0으로 복사되고, 다음 Minor GC 때는 Eden + S0의 살아있는 객체가 S1으로 복사됩니다. S0과 S1 중 하나는 항상 비어 있다 는 게 핵심이에요. Copying 알고리즘이 여기서 쓰이는 겁니다.
GC 종류별 비교
Java 역사와 함께 GC도 계속 발전해왔습니다. 한눈에 비교해볼게요.
| GC | 도입 | Young | Old | STW | 특징 |
|---|---|---|---|---|---|
| Serial | JDK 1.3 | Copying | Mark-Compact | 길다 | 싱글 스레드, 클라이언트 VM 기본 |
| Parallel (Throughput) | JDK 1.4 | Copying (병렬) | Mark-Compact (병렬) | 중간 | 멀티 스레드, 처리량 우선 |
| CMS | JDK 1.4 | Copying (병렬) | Concurrent Mark-Sweep | 짧다 | 응답 시간 우선, Java 14에서 제거 |
| G1 | JDK 7 (기본: 9) | Copying | Mixed (Mark-Compact) | 짧다 | Region 기반, 예측 가능한 pause |
| ZGC | JDK 11 (실험), 15 (정식) | — | Concurrent | 매우 짧다 | 10ms 이하, 대용량 힙 |
| Shenandoah | JDK 12 | — | Concurrent | 매우 짧다 | ZGC와 유사, Red Hat 주도 |
Serial GC
가장 단순한 GC입니다. ** 싱글 스레드 **로 동작하고, GC하는 동안 모든 애플리케이션 스레드가 멈춰요.
# Serial GC 사용
java -XX:+UseSerialGC -jar app.jar
- 쓸 곳: 메모리가 작은 환경 (수백 MB 이하), 임베디드 시스템
- 운영 서버에서는 쓸 일이 거의 없어요
Parallel GC (Throughput Collector)
Serial의 멀티 스레드 버전입니다. ** 여러 GC 스레드가 동시에** Mark-Compact를 수행해요.
# Parallel GC 사용 (Java 8까지 기본)
java -XX:+UseParallelGC -XX:ParallelGCThreads=4 -jar app.jar
- 쓸 곳: 배치 처리 같은 ** 처리량(Throughput)이 중요한** 환경
- 응답 시간보다 전체 처리량을 우선시합니다
CMS (Concurrent Mark-Sweep)
Old 영역을 ** 애플리케이션과 동시에(Concurrent)** 수거하는 최초의 GC였습니다.
[CMS 4단계]
1. Initial Mark (STW — 짧음) : GC Root에서 직접 참조하는 객체만 표시
2. Concurrent Mark (동시) : 참조 그래프를 따라가며 표시
3. Remark (STW — 짧음) : Concurrent Mark 중 변경된 참조 보정
4. Concurrent Sweep(동시) : 가비지 수거
- ** 장점 **: STW 시간이 짧아요
- ** 단점 **: CPU를 많이 먹고, Compact 단계가 없어서 단편화 가 발생합니다
- Java 9에서 Deprecated, Java 14에서 완전히 제거 되었습니다
Shenandoah GC
Red Hat이 주도하는 저지연 GC입니다. ZGC와 목표가 비슷하지만 접근 방식이 달라요.
# Shenandoah 사용
java -XX:+UseShenandoahGC -jar app.jar
- Brooks Pointer: 각 객체에 포워딩 포인터를 두어 이동 중에도 접근 가능
- ZGC는 Colored Pointer, Shenandoah는 Brooks Pointer — 둘 다 Concurrent Compaction이 목표
- OpenJDK에서 사용 가능 (Oracle JDK에는 미포함)
G1 GC 동작 원리 — Region 기반
G1(Garbage First)은 Java 9부터 기본 GC 입니다. 기존 세대별 GC의 연속 메모리 구조를 버리고, 힙을 동일 크기의 Region으로 분할 해요.
Region 구조
┌────┬────┬────┬────┬────┬────┬────┬────┐
│ E │ E │ S │ │ O │ O │ H │ │
├────┼────┼────┼────┼────┼────┼────┼────┤
│ O │ │ E │ O │ │ O │ O │ E │
├────┼────┼────┼────┼────┼────┼────┼────┤
│ │ O │ O │ E │ S │ │ O │ │
└────┴────┴────┴────┴────┴────┴────┴────┘
E = Eden S = Survivor O = Old H = Humongous 빈칸 = Free
- Region: 힙을 1~32MB 크기의 블록으로 나눕니다 (기본 2048개)
- 각 Region은 역할이 동적으로 바뀌어요 (Eden → Survivor → Old → Free)
- Humongous: Region 크기의 50% 이상인 큰 객체 전용
G1 GC 동작 흐름
1. Young GC (STW)
Eden Region이 가득 차면 발생
살아있는 객체를 Survivor Region으로 복사
2. Concurrent Marking
Old Region 사용률이 IHOP(기본 45%) 넘으면 시작
살아있는 객체 비율을 Region별로 계산
3. Mixed GC (STW)
Young Region + 가비지가 많은 Old Region을 함께 수거
"Garbage First" — 가비지가 가장 많은 Region부터 수거
4. Full GC (최후의 수단)
Mixed GC로도 Old 영역 확보가 안 되면 발생
싱글 스레드 Mark-Compact → 매우 느림 (Java 10부터 병렬화)
G1의 핵심 장점
# G1 GC 사용 (Java 9+ 기본)
java -XX:+UseG1GC \
-XX:MaxGCPauseMillis=200 \ # 목표 pause time (기본 200ms)
-XX:InitiatingHeapOccupancyPercent=45 \ # IHOP
-jar app.jar
G1의 가장 큰 장점은 MaxGCPauseMillis로 목표 응답 시간을 설정 할 수 있다는 점입니다. G1은 이 목표에 맞춰 수거할 Region 수를 조절해요. 물론 "목표"일 뿐 보장은 아니지만, 예측 가능한 pause time이라는 점에서 CMS보다 훨씬 실용적입니다.
Remember Set과 Card Table
한 가지 궁금했던 게 있었어요. Region 단위로 GC를 하면, 다른 Region에서 이 Region의 객체를 참조하는 경우는 어떻게 처리할까요?
다른 Region에서 이 Region의 객체를 참조하는 경우, 전체 힙을 스캔해야 할까요? G1은 Remember Set(RSet) 으로 이 문제를 해결합니다. 각 Region은 "나를 참조하는 외부 Region"의 목록을 RSet에 기록해둬요. 덕분에 전체 힙을 스캔하지 않고도 참조 관계를 파악할 수 있습니다.
ZGC — 10ms 이하 pause time의 비밀
ZGC는 테라바이트 급 힙에서도 pause time이 10ms 이하 를 목표로 하는 저지연 GC입니다.
ZGC의 핵심 기술
Colored Pointer
ZGC는 64비트 포인터의 상위 비트에 메타데이터를 저장 합니다.
64비트 포인터 구조:
┌──────────┬─────┬──────┬──────┬────────────────────────┐
│ 미사용 │ Fin │ Remap│ Mark │ 객체 주소 (44비트) │
│ (16비트) │(1b) │ (1b) │(2b) │ = 최대 16TB │
└──────────┴─────┴──────┴──────┴────────────────────────┘
- Mark 비트 : 이 객체가 마킹되었는지
- Remap 비트 : 이 객체가 새 위치로 이동했는지
- Finalize 비트: finalizer 처리가 필요한지
포인터 자체에 상태 정보가 있으니, 객체에 접근할 때마다 상태를 즉시 알 수 있어요.
Load Barrier
객체 참조를 읽을 때(load) Load Barrier 가 개입합니다.
// 애플리케이션 코드
Object ref = obj.field;
// JIT 컴파일 후 (ZGC의 Load Barrier 삽입)
Object ref = obj.field;
if (ref의 Colored Pointer 상태가 올바르지 않으면) {
ref = slowPath(ref); // 포인터 보정 (remap, mark 등)
}
Load Barrier 덕분에 GC가 객체를 이동시키는 동안에도 애플리케이션이 올바른 참조를 유지할 수 있어요. 이게 ZGC가 대부분의 작업을 Concurrent하게 처리 할 수 있는 비결입니다.
ZGC 동작 흐름
1. Pause Mark Start (STW — ~1ms) : GC Root 스캔
2. Concurrent Mark (동시) : 참조 그래프 순회하며 마킹
3. Pause Mark End (STW — ~1ms) : 마킹 완료 확인
4. Concurrent Relocate (동시) : 살아있는 객체를 새 Region으로 이동
5. Concurrent Remap (동시) : 이전 주소를 새 주소로 갱신
STW는 1번과 3번에서만 발생하고, 그 시간이 힙 크기와 무관하게 일정 합니다. 8TB 힙이든 1GB 힙이든 pause time은 비슷해요.
ZGC 사용법
# ZGC 사용 (Java 15+)
java -XX:+UseZGC -Xmx16g -jar app.jar
# Java 21+ Generational ZGC (세대별 ZGC)
java -XX:+UseZGC -XX:+ZGenerational -Xmx16g -jar app.jar
Java 21부터는 Generational ZGC 가 도입되어 Young/Old 세대 구분이 추가되었습니다. 기존 ZGC보다 처리량이 향상되었고, Java 23부터는 Generational ZGC가 기본이 되었어요.
Stop-the-World — 왜 발생하고 어떻게 줄이는가
STW란?
GC가 수행될 때 모든 애플리케이션 스레드가 멈추는 현상 입니다. 왜 멈춰야 할까요?
GC 스레드: "이 객체 참조 관계를 분석 중이야"
앱 스레드: "나 지금 그 객체 참조 바꿀 건데?"
→ 동시에 하면 정합성이 깨짐
객체 그래프를 정확하게 분석하려면, 그 순간만큼은 참조 관계가 변하지 않아야 합니다. 그래서 애플리케이션을 멈추는 거예요.
Safepoint
JVM은 아무 때나 스레드를 멈추지 않아요. Safepoint 라는 안전한 지점에서만 멈춥니다.
// Safepoint가 되는 지점들
while (condition) { // 루프 백엣지
doSomething();
// ← 여기가 Safepoint (JVM이 GC 플래그를 확인하는 지점)
}
obj.method(); // 메서드 호출/리턴 지점도 Safepoint
- 모든 스레드가 Safepoint에 도달해야 STW가 시작돼요
- 한 스레드라도 Safepoint에 늦게 도달하면 Time To Safepoint(TTSP) 가 길어집니다
- JNI 코드 실행 중인 스레드는 Safepoint에 도달하지 못해서 GC가 지연될 수 있어요
STW를 줄이는 방법
| 방법 | 설명 |
|---|---|
| GC 종류 변경 | Serial → G1 → ZGC로 갈수록 STW 감소 |
| ** 힙 크기 최적화** | 너무 작으면 GC 빈도 증가, 너무 크면 Full GC 시간 증가 |
| ** 객체 생성 줄이기** | 불필요한 객체 생성을 피하면 GC 부담 감소 |
| Old 영역 승격 줄이기 | 불필요하게 오래 사는 객체(캐시, static 컬렉션) 관리 |
GC 로그 읽기 — -Xlog:gc* 옵션과 로그 해석
GC 로그 활성화
# Java 9+ 통합 로깅 (Unified Logging)
java -Xlog:gc*:file=gc.log:time,uptime,level,tags -jar app.jar
# Java 8 (레거시 방식)
java -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:gc.log -jar app.jar
G1 GC 로그 읽기
[2026-03-19T10:15:30.123+0900][info][gc] GC(42) Pause Young (Normal)
(G1 Evacuation Pause) 256M->128M(512M) 12.345ms
│ │ │ │ │
│ │ │ │ └─ STW 시간
│ │ │ └─ 전체 힙 크기
│ │ └─ GC 후 사용량
│ └─ GC 전 사용량
└─ GC 번호
[info][gc] GC(42) Pause Young (Normal) (G1 Evacuation Pause)
# Young GC, Eden → Survivor 복사
[info][gc] GC(100) Pause Young (Concurrent Start) (G1 Humongous Allocation)
# Concurrent Marking 시작을 트리거하는 Young GC
[info][gc] GC(101) Concurrent Mark Cycle
# Old Region의 살아있는 객체 분석
[info][gc] GC(102) Pause Young (Mixed) (G1 Evacuation Pause)
# Mixed GC: Young + 일부 Old Region 수거
[warn][gc] GC(200) Pause Full (G1 Compaction Pause) 512M->256M(512M) 2345.678ms
# Full GC 발생! — 2.3초 STW, 원인 분석 필요
로그에서 주의할 포인트:
Pause Full이 보이면 ** 빨간불 **입니다. G1에서 Full GC가 발생하면 뭔가 잘못된 거예요.Mixed가 자주 발생하는 건 정상이에요. Old Region을 점진적으로 수거하는 과정이니까요.to-space exhausted나evacuation failure가 나오면 Survivor 영역이 부족한 상황입니다.
GC 튜닝 기초 — 힙 크기, GC 선택 가이드
힙 크기 설정
# 기본 힙 설정
java -Xms4g # 초기 힙 크기
-Xmx4g # 최대 힙 크기 (Xms와 같게 하면 리사이징 오버헤드 제거)
-Xmn1g # Young 영역 크기 (G1에서는 자동 관리하므로 보통 생략)
-jar app.jar
** 힙 크기 원칙:**
-Xms와-Xmx를 같게 설정합니다. 힙 리사이징은 Full GC를 유발할 수 있어요.- Old 영역 사용량이 정상 상태에서 50~60% 정도면 적절합니다.
- 너무 작으면 GC가 너무 자주, 너무 크면 한 번에 오래 멈춰요.
GC 선택 가이드
처리량 중요?
┌─ Yes → Parallel GC (배치, 데이터 파이프라인)
│
└─ No → 응답 시간이 중요
┌─ 힙 < 4GB → G1 GC (대부분의 웹 서버)
│
└─ 힙 ≥ 4GB → ZGC (대규모 서비스, 실시간 시스템)
| 상황 | 추천 GC | 이유 |
|---|---|---|
| 일반 웹 서버 (힙 2~8GB) | G1 | Java 9+ 기본, 별도 튜닝 없이도 무난 |
| 대규모 서비스 (힙 16GB+) | ZGC | 힙 크기 무관하게 일정한 pause |
| 배치 처리 | Parallel | STW가 길어도 전체 처리량이 중요 |
| 컨테이너 (메모리 제한) | G1 또는 ZGC | 컨테이너 메모리 인식 (-XX:+UseContainerSupport) |
| 레거시 (Java 8) | G1 (-XX:+UseG1GC) | CMS 대체, Java 8에서도 사용 가능 |
자주 쓰는 GC 튜닝 옵션
# G1 GC 튜닝 예시
java -XX:+UseG1GC \
-XX:MaxGCPauseMillis=100 \ # 목표 pause time 100ms
-XX:InitiatingHeapOccupancyPercent=35 \ # IHOP 낮추면 Mixed GC 일찍 시작
-Xms8g -Xmx8g \
-jar app.jar
ZGC는 튜닝할 게 거의 없어요. 힙 크기(-Xms, -Xmx)만 적절히 설정하면 나머지는 자동으로 조절됩니다.
주의할 점
Xms와 Xmx를 다르게 설정하면 안 돼요
힙이 늘어나는 과정에서 Full GC가 유발될 수 있어요. 운영 환경에서는 -Xms와 -Xmx를 같은 값으로 설정해서 리사이징 오버헤드를 제거해야 합니다.
Full GC가 보이면 원인부터 파악해야 합니다
G1에서 Pause Full이 로그에 찍히면 뭔가 잘못된 신호예요. 힙만 키우면 해결된다고 생각하기 쉽지만, 메모리 누수가 원인이면 힙을 아무리 키워도 결국 OOM이 발생합니다. 먼저 GC 로그를 분석해서 Full GC의 원인(힙 부족인지, 승격 비율이 높은지, Humongous 객체인지)을 파악해야 해요.
ZGC는 처리량(throughput)이 G1보다 떨어질 수 있어요
ZGC는 pause time은 짧지만, Load Barrier 오버헤드와 추가 메모리 사용(약 3~5%)이 있어요. 배치 처리처럼 처리량이 중요한 워크로드에서는 G1이나 Parallel이 더 적합할 수 있습니다. 워크로드 특성을 먼저 파악하고 GC를 선택해야 해요.
정리
| 개념 | 핵심 |
|---|---|
| Mark-Sweep | 단순하지만 메모리 단편화 발생. Mark-Compact로 해결하나 이동 비용 있음 |
| Generational GC | Weak Generational Hypothesis — 대부분 객체는 금방 죽으므로 Young만 자주 수거 |
| Minor vs Full GC | Minor: Young만 수거 (빠름). Full: 전체 힙 수거 (느림, 피해야 함) |
| G1 GC | Region 기반, MaxGCPauseMillis로 목표 pause 설정, Mixed GC로 Old 점진 수거 |
| ZGC | Colored Pointer + Load Barrier로 대부분 Concurrent 처리, 10ms 이하 pause |
| Stop-the-World | GC 시 모든 앱 스레드 정지. 정합성 보장을 위해 필요. Safepoint에서만 발생 |
| GC 선택 기준 | 일반 웹 서버 → G1, 대용량 저지연 → ZGC, 배치 처리량 → Parallel |
GC는 "Java가 알아서 해주는 것"이라고 넘기기엔, 운영 환경에서 너무 많은 문제의 원인이 된다. 기본 알고리즘을 이해하고 세대별 GC의 원리를 알면, GC 로그를 읽고 어떤 GC를 선택할지 판단할 수 있다.
▸ TIP 이 글의 코드 예제를 직접 실행해보고 싶다면 Java 기본기 핸드북을 확인해보세요.
** 다음 글 **: [메모리 누수와 트러블슈팅 — OOM이 발생하면 어떻게 하나요](/개발/백엔드/자바/메모리 누수와 트러블슈팅 — OOM이 발생하면 어떻게 하나요)
GC 원리를 알았으니, 이제 실제로 문제가 터졌을 때 어떻게 대응하는지 다뤄볼게요. 힙 덤프 분석부터 흔한 메모리 누수 패턴까지 정리할 예정입니다.