GC 로그에서 "pause"가 200ms인 이유 — JVM 메모리 구조와 가비지 컬렉터 선택
프로덕션 서버의 응답 시간이 주기적으로 200ms로 튄다. GC 로그를 보면 GC pause (G1 Evacuation Pause) 187ms가 찍혀 있다. 사용자는 "왜 갑자기 느려지나요?"라고 묻는다. 답은 GC의 stop-the-world — GC가 객체를 회수하는 동안 애플리케이션 스레드가 모두 멈추기 때문이다.
서버 응답이 주기적으로 200ms로 튄다. GC 로그를 보면 "GC pause 187ms". 누가 멈추게 하는가? — 가비지 컬렉터가 객체를 청소하는 동안 모든 스레드가 잠시 멈춘다(stop-the-world). 이 글은 왜 멈추는지, G1/ZGC/Shenandoah가 각각 다르게 해결하는지를 풀어간다. (JVM GC 가이드)
JVM 힙 메모리 구조
flowchart TD
HEAP["Heap (GC 관리 영역)"]
HEAP --> YOUNG["Young Generation"]
YOUNG --> EDEN["Eden<br/>(새 객체)"]
YOUNG --> S0["Survivor 0"]
YOUNG --> S1["Survivor 1"]
HEAP --> OLD["Old Generation<br/>(오래 살아남은 객체)"]
HEAP --> META["Metaspace<br/>(클래스 메타데이터, Java 8+)"]
| 영역 | 용도 | GC 유형 |
|---|---|---|
| Eden | 새로 생성된 객체 | Minor GC |
| Survivor 0/1 | Eden에서 살아남은 객체 | Minor GC |
| Old (Tenured) | 여러 GC에서 살아남은 객체 | Major/Full GC |
| Metaspace | 클래스 메타데이터 (Java 8 이전: PermGen) | 별도 관리 |
Java 8부터 PermGen이 제거되고 Metaspace로 대체됐다. PermGen은 고정 크기로 OOM이 자주 발생했는데, Metaspace는 네이티브 메모리를 사용하여 자동 확장된다.
가비지 컬렉션 알고리즘 — "도달 가능성"과 "세대"
JVM GC의 핵심 전제: 대부분의 객체는 짧게 산다. 이를 세대별 가설(generational hypothesis)이라 한다.
- 새 객체는 Eden에 할당된다.
- Eden이 가득 차면 Minor GC 발생 — 살아있는 객체를 Survivor로 이동, Eden 비움.
- Survivor에서 여러 번 살아남으면 Old로 승격(promotion).
- Old가 가득 차면 Major/Full GC — 전체 힙 정리 (느림).
Minor GC는 빠르다(Young 영역만 정리). Major GC는 느리다(전체 힙 정리, stop-the-world 길어짐). GC 튜닝의 목표는 Major GC 빈도와 지속 시간을 줄이는 것이다.
GC가 객체를 "쓰레기"로 판단하는 기준 — reachability: GC root(스택 변수, static 필드, JNI 참조)에서 시작해서 참조 체인을 따라갈 수 있는 객체는 살아있고, 도달할 수 없는 객체는 쓰레기다. 예를 들어 List<String> list = new ArrayList<>()에서 list가 GC root(스택 변수)이고, ArrayList 객체는 list가 참조하므로 살아있다. list = null을 하면 → ArrayList에 대한 참조가 사라지고 → GC가 "이 객체에 도달할 수 있는 경로가 없다"고 판단하여 회수한다.
비유: GC는 사내 전화번호부에서 "누가 아직 이 회사에 있나?"를 확인하는 것과 같다. 사장(GC root)이 부서장에게, 부서장이 팀원에게 전화를 걸 수 있으면 — 그 팀원은 "살아있다". 사장이 특정 부서장에게 더 이상 전화를 걸지 않으면(참조 해제) → 그 부서장과 그 팀원 전체가 "도달 불가" → 퇴사 처리(GC 회수).
GC의 근본 — reachability와 stop-the-world
도달 가능성(reachability): GC root(스택 변수, static 필드, JNI 참조 등)에서 참조 체인을 따라 도달할 수 있는 객체는 살아있고, 도달할 수 없는 객체는 쓰레기다.
stop-the-world: GC가 객체 그래프를 정확히 분석하기 위해 애플리케이션 스레드를 일시 정지한다. 이 일시 정지 시간(pause time)이 응답 지연의 원인이다.
G1 GC (Garbage First) — Java 9+ 기본
G1은 힙을 Region(1 ~ 32MB) 단위로 분할하여 관리한다. (G1 GC 가이드)
flowchart TD
subgraph g1heap["G1 Heap (Region 단위)"]
R1["Region<br/>Eden"]
R2["Region<br/>Survivor"]
R3["Region<br/>Old"]
R4["Region<br/>Old"]
R5["Region<br/>(미사용)"]
end
- 가비지가 가장 많은 Region부터 먼저 회수(Garbage First) → 효율적
- 목표 일시 정지 시간(
-XX:MaxGCPauseMillis=200) 설정 가능 - Java 9부터 기본 GC
# Java 25 — G1 GC (기본값, 명시적 설정)
java -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -Xms4g -Xmx4g MyApp
ZGC — 초저지연 GC (Java 15+ 안정화)
ZGC는 동시(concurrent) GC — 대부분의 GC 작업을 애플리케이션과 병렬로 수행하여 stop-the-world를 1ms 미만으로 유지한다. (JEP-377, JEP-439)
# Java 25 — ZGC 활성화
java -XX:+UseZGC -Xmx16g MyApp
# 세대별 ZGC (Java 21+에서 도입, JEP-439) — 기본 활성화
java -XX:+UseZGC -XX:+ZGenerational MyApp
| 항목 | G1 | ZGC | Shenandoah |
|---|---|---|---|
| 목표 pause | ~ 200ms | <1ms | <10ms |
| 힙 크기 | 수십 GB | 수 TB | 수백 GB |
| 처리량 | 높음 | 약간 낮음 | 약간 낮음 |
| 메모리 오버헤드 | 보통 | 높음(포인터 색칠) | 높음(Brooks 포인터) |
| 용도 | 일반 서버 | 저지연 실시간 | 저지연 |
ZGC는 힙 크기가 커도(수 TB) pause time이 1ms 미만을 유지한다. 대규모 메모리 + 낮은 지연이 필요한 서비스(금융, 게임, 실시간 분석)에 적합하다. Java 21에서 도입된 세대별 ZGC(JEP-439)는 Young/Old 세대를 분리하여 처리량까지 개선했다.
GC 선택 가이드
| 시나리오 | 추천 GC | 이유 |
|---|---|---|
| 일반 웹 서비스 (힙 4 ~ 32GB) | G1 | 균형잡힌 처리량/지연 |
| 실시간/저지연 (지연 < 10ms) | ZGC | 초저지연 |
| 대규모 힙 (> 32GB) | ZGC | 큰 힙에서도 일관된 성능 |
| 처리량 최우선 (배치) | Parallel GC | stop-the-world는 길어도 총 처리량 최대 |
| 작은 힙/개발 | G1 (기본) | 튜닝 불필요 |
G1 GC의 회수 메커니즘 — Region evacuation
G1은 GC를 수행할 때 가비지가 가장 많은 Region부터 선택적으로 회수한다(Garbage First). 회수 과정:
flowchart TD
SRC["회수 대상 Region<br/>(가비지 多)"] -->|"살아있는 객체 복사"| DST["빈 Region<br/>(evacuation)"]
SRC -->|"가비지 객체"| DROP["버림 (메모리 해제)"]
DST -->|"참조 갱신"| ROOTS["GC roots + remembered set<br/>포인터 업데이트"]
- 회수 대상 선정: 각 Region의 가비지 비율을 평가하여, 가비지가 많은 Region부터 선택. 이것이 "Garbage First" 이름의 유래다.
- Evacuation(대피): 선택된 Region에서 살아있는 객체를 다른 빈 Region으로 복사. 이때 객체가 이동하므로 참조를 갱신해야 한다.
- Remembered Set: 다른 Region에서 이 Region을 참조하는 포인터를 추적하기 위해 remembered set을 유지한다. 이것이 G1의 메모리 오버헤드 중 하나다.
- Pause: evacuation과 참조 갱신은 stop-the-world 하에 수행된다.
MaxGCPauseMillis로 목표 시간을 설정하면, G1이 목표 내에서 회수할 수 있는 Region 수를 조절한다.
G1은 목표 pause time을 보장하는 것이 아니라 목표로 삼는 것이다. Region이 크고 살아있는 객체가 많으면 pause가 목표를 초과할 수 있다. (G1 GC 가이드)
ZGC의 동시 회수 — 읽기/쓰기 장벽
ZGC가 pause time을 1ms 미만으로 유지하는 비결은 대부분의 GC 작업을 애플리케이션 스레드와 동시에(concurrent) 수행하는 것이다. 이를 위해 읽기/쓰기 장벽(read/write barrier)을 사용한다.
flowchart LR
APP["애플리케이션 스레드<br/>객체 참조 읽기/쓰기"] -->|장벽 가로채기| BARRIER["ZGC Barrier<br/>포인터 색칠(colored pointer)"]
BARRIER -->|"GC와 동시 실행"| CHECK["객체가 이동 중?<br/>참조 갱신 필요?"]
CHECK -->|"문제 없음"| APP2["정상 진행"]
CHECK -->|"이동 중"| FIXUP["참조 수정(forward)<br/>애플리케이션과 동시"]
ZGC의 핵심 기술 — colored pointer(포인터 색칠):
- 64-bit 포인터의 상위 비트를 GC 상태 표시(Marked0, Marked1, Remapped)에 사용
- 객체 참조 시 barrier가 포인터를 검사 — 이동 중인 객체면 즉시 올바른 주소로 수정(forward)
- 애플리케이션 스레드가 잠시 멈추지 않고 동시에 참조를 수정
ZGC의 pause는 객체 루트 집합(GC roots)을 스캔하는 짧은 순간만 발생한다 — 전체 힙 스캔이나 evacuation은 concurrent로 수행되므로 힙이 수 TB여도 pause가 1ms 미만이다. (JEP-439)
Compact Object Headers (Java 25, JEP-519) — 힙 사용량 혁신
Java 25에서 가장 큰 메모리 개선: 객체 헤더 크기가 96/128비트에서 64비트로 축소됐다. (JEP-519)
flowchart LR
subgraph before["Java 24 이전 (객체 헤더 12~16바이트)"]
H1["Mark Word (64-bit)<br/>GC, hash, lock"] --> K1["Klass Pointer (32~64-bit)<br/>클래스 메타데이터 주소"]
end
subgraph after["Java 25 Compact Object Header (8바이트)"]
H2["Mark Word + Klass Pointer 통합<br/>64-bit 하나로 압축"]
end
| 항목 | Java 24 이전 | Java 25 (Compact Headers) |
|---|---|---|
| 객체 헤더 크기 | 12 ~ 16 바이트 | 8 바이트 |
| 힙 사용량 | 기준 | 최대 10 ~ 20% 감소 |
| 배포 밀도 | 기준 | 향상 (같은 메모리로 더 많은 객체) |
| 상태 | — | Java 25에서 정식(finalized) |
Compact Object Headers는 Java 24에서 실험적(experimental)으로 도입됐고, Java 25에서 정식 기능으로 승격됐다. (JEP-519) 대부분의 워크로드에서 힙 사용량이 감소하고, 데이터 지역성(data locality)이 향상되어 캐시 효율도 개선된다. 별도 설정 없이 기본 활성화된다.
GC 로그 읽기
# Java 25 — GC 로그 활성화
java -Xlog:gc*=info:file=gc.log:time,uptime,level,tags:filecount=10,filesize=10M MyApp
# G1 GC 로그 예
[2026-07-10T14:30:00.123] GC(15) Pause Young (Normal) (G1 Evacuation Pause)
Eden regions: 120->0(120)
Survivor regions: 8->8(16)
Old regions: 45->45
Humongous regions: 2->2
Metaspace: 128MB->128MB(256MB)
Pause Time: 187ms
주요 지표:
- Pause Time: stop-the-world 기간. 이것이 응답 지연의 원인.
- Allocation Rate: 초당 Eden 할당량. 높으면 Minor GC 빈번.
- Promotion Rate: Young → Old 승격량. 높으면 Major GC 빠름.
메모리 누수 탐지
Java의 GC는 자동이지만, 의도치 않은 참조로 인한 메모리 누수는 여전히 발생한다:
// Java 25 — 흔한 메모리 누수 패턴
class Cache {
// static Map이 계속 커짐 (만료 정책 없음)
private static final Map<String, byte[]> CACHE = new HashMap<>();
public static void put(String key, byte[] data) {
CACHE.put(key, data); // 절대 삭제 안 함 → 메모리 누수
}
}
힙 덤프 분석
# Java 25 — 힙 덤프 생성
jcmd <pid> GC.heap_dump heapdump.hprof
# 또는 OOM 시 자동 덤프
java -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/oom.hprof MyApp
힙 덤프(
.hprof)를 Eclipse MAT(Memory Analyzer Tool)나 VisualVM으로 분석하면 가장 큰 객체, 참조 체인, 의심되는 누수 지점을 찾을 수 있다.
Escape Analysis — 힙 할당 회피
JIT 컴파일러(C2)는 escape analysis를 수행하여, 객체가 메서드 밖으로 탈출(escape)하지 않으면 힙이 아닌 스택에 할당한다(scalar replacement). 이 경우 GC가 관여하지 않는다.
// Java 25 — escape analysis의 대상
public int compute(int a, int b) {
Point p = new Point(a, b); // 힙 할당? 또는 스택 최적화?
return p.x() + p.y();
// p가 메서드 밖으로 나가지 않음 → JIT가 스택에 최적화할 수 있음
// → GC 부하 없음
}
escape analysis가 잘 작동하면, 짧은 수명의 작은 객체는 힙에 할당되지 않는다. 따라서 단기 객체 생성 비용은 생각보다 낮다 — "객체 생성은 비싸니까 재사용하라"는 오래된 조언은 Java 8+에서 더 이상 유효하지 않다. 명확한 코드가 미세 최적화보다 중요하다.
실습 — GC 동작 관찰
// Java 25 — GCDemo.java
import java.util.*;
public class GCDemo {
public static void main(String[] args) {
List<byte[]> data = new ArrayList<>();
try {
for (int i = 0; i < 1000; i++) {
// 1MB 객체 생성
data.add(new byte[1024 * 1024]);
if (i % 10 == 0) {
System.out.printf("할당: %dMB, 사용 힙: %dMB%n",
i, Runtime.getRuntime().totalMemory() / 1024 / 1024);
}
}
} catch (OutOfMemoryError e) {
System.out.println("OOM! 최대 힙: "
+ Runtime.getRuntime().maxMemory() / 1024 / 1024 + "MB");
}
}
}
# 힙을 64MB로 제한하고 실행
java -Xmx64m -Xlog:gc GCDemo.java
[0.123s] GC(0) Pause Young (Normal) (G1 Evacuation Pause) 15M->5M(64M) 3.456ms
[0.456s] GC(1) Pause Young (Normal) (G1 Evacuation Pause) 20M->8M(64M) 4.123ms
할당: 0MB, 사용 힙: 16MB
...
OOM! 최대 힙: 64MB
확인할 것: Xmx64m로 힙을 64MB로 제한하면 GC가 빈번하게 발생하고 결국 OOM이 발생한다. GC 로그로 Minor/Major GC 패턴을 관찰할 수 있다.
Shenandoah GC — Red Hat의 동시 GC
Shenandoah는 ZGC와 유사하게 동시 압축(concurrent compaction)을 수행하는 저지연 GC다. (Shenandoah 가이드)
# Java 25 — Shenandoah 활성화
java -XX:+UseShenandoahGC -Xmx8g MyApp
Shenandoah vs ZGC:
| 항목 | ZGC | Shenandoah |
|---|---|---|
| 기술 | 포인터 색칠(colored pointers) | Brooks 포인터 |
| pause time | <1ms | <10ms |
| 메모리 오버헤드 | 약간 높음 | 약간 높음 |
| 지원 | OpenJDK 기본 | OpenJDK (Red Hat 주도) |
Shenandoah는 Amazon Corretto, RHEL 등 Red Hat 계열 배포판에서 선호된다. ZGC와 목표가 같지만(저지연) 구현 방식이 다르다. 둘 중 어느 것을 쓸지는 조직의 배포판과 하드웨어에 따라 결정한다.
Generational Shenandoah (Java 25, JEP-521) — 세대 분리 정식
Java 25에서 Generational Shenandoah(JEP-521)가 정식(finalized)으로 승격됐다. (JEP-521) 세대별 가설(대부분의 객체는 짧게 산다)을 적용하여 Young/Old 세대를 분리, 회수 효율을 크게 높였다.
| 항목 | 기존 Shenandoah (Java 24 실험적) | Generational Shenandoah (Java 25 정식) |
|---|---|---|
| 세대 분리 | 없음 (전체 힙 단일 관리) | Young/Old 분리 |
| 회수 효율 | 보통 | 향상 (Young GC 빈번 + 저비용) |
| 처리량 | 기준 | 향상 |
| 상태 | 실험적(experimental) | 정식(finalized) |
# Java 25 — Generational Shenandoah (기본 활성화)
java -XX:+UseShenandoahGC -Xmx8g MyApp
# Java 24에서는 -XX:ShenandoahGCMode=generational 필요 (실험적)
Generational Shenandoah는 기존 Shenandoah의 동시 회수 장점(pause time <10ms)을 유지하면서, 세대 분리로 회수 주기와 비용을 최적화한다. ZGC의 세대별 모드(JEP-439, Java 21)와 같은 방향이지만 Brooks 포인터 기반으로 구현된다.
주요 JVM 옵션 — 메모리/GC
| 옵션 | 기본값 | 의미 |
|---|---|---|
-Xms<size> |
힙의 1/64 | 초기 힙 크기 |
-Xmx<size> |
힙의 1/4 | 최대 힙 크기 |
-Xss<size> |
512KB ~ 1MB | 스레드 스택 크기 |
-XX:MetaspaceSize=<size> |
— | Metaspace 초기 크기 |
-XX:MaxMetaspaceSize=<size> |
무제한 | Metaspace 최대 크기 |
-XX:+UseG1GC |
Java 9+ 기본 | G1 GC 사용 |
-XX:+UseZGC |
— | ZGC 사용 |
-XX:MaxGCPauseMillis=<ms> |
200 | G1 목표 일시 정지 |
-XX:+HeapDumpOnOutOfMemoryError |
— | OOM 시 힙 덤프 자동 생성 |
-Xlog:gc* |
— | GC 상세 로그 |
-Xms와-Xmx를 같게 설정하는 것이 권장된다 — JVM이 힙 크기를 동적으로 조정할 때 resize 비용(GC, 메모리 매핑 변경)이 발생하므로, 서버 환경에서는 초기 크기 = 최대 크기로 설정하여 이 비용을 없앤다.
객체 수명과 finalize — 더 이상 사용하지 말 것
Java의 GC는 자동 메모리 관리를 제공하지만, 객체의 정리 시점은 보장되지 않는다:
// Java 25 — finalize()는 deprecated (Java 18+, JEP-421)
class Resource {
@Override
protected void finalize() throws Throwable {
// 언제 실행될지 모름 — 실행 안 될 수도 있음
// GC가 finalize를 실행하는 스레드는 제한적
// 프로덕션에서 절대 사용하지 말 것
close();
}
// 올바른 패턴: try-with-resources + AutoCloseable
@Override
public void close() {
System.out.println("자원 해제");
}
}
finalize()는 Java 18+에서 deprecated되었고, Java 25에서 제거 예정이다. (JEP-421) 자원 해제는 항상try-with-resources+AutoCloseable을 사용한다 (장 09 참조).Cleaner(Java 9+)가 finalize의 안전한 대안이지만, 이것도 마지막 수단이다 — 명시적close()가 우선이다.
요약 — 이 글의 결론
- 대부분의 객체는 짧게 산다(세대별 가설). 이 전제로 Young(Eden/Survivor)과 Old 세대를 분리하여 GC 비용을 최소화한다.
- G1 GC가 Java 25의 기본이다. Region 기반으로 목표 일시 정지 시간(
MaxGCPauseMillis)을 맞춘다. 일반적인 서비스에 적합. - ZGC는 초저지연(1ms 미만) GC다. 대규모 힙(TB)에서도 일관된 pause time. 실시간 서비스에 적합. Java 21에서 도입된 세대별 ZGC로 처리량도 개선.
- stop-the-world가 응답 지연의 원인이다. GC 로그에서 pause time을 모니터링하고, 튜닝 목표는 Major GC 빈도와 pause time 감소.
- 메모리 누수는 static 참조로 발생한다.
HeapDumpOnOutOfMemoryError로 덤프를 남기고, MAT로 분석한다.
생각해 볼 문제
System.gc()를 호출하면 즉시 GC가 실행되는가? 프로덕션에서 호출해야 할 상황은?- G1 GC에서
-XX:MaxGCPauseMillis=50으로 설정하면 항상 50ms 이내에 GC가 끝나는가? - 세대별 ZGC(JEP-439)가 기존 ZGC보다 나은 점은? 세대 분리가 어떤 이점을 주는가?
WeakReference와SoftReference의 차이는? GC가 각각을 언제 회수하는가?- 힙 크기를
-Xms와-Xmx를 같게 설정하는 것이 권장되는 이유는? (동적 리사이즈 비용) - Escape analysis가 활성화된 상태에서
new Point(x, y)가 스택에 할당되면 GC에 어떤 영향을 주는가?
참고
- JVM GC 튜닝 가이드 (Java 25) - 접근 2026-07-10
- G1 GC 가이드 - 접근 2026-07-10
- JEP-439: Generational ZGC - 접근 2026-07-10
- JEP-377: ZGC (Production) - 접근 2026-07-10
- JEP-331: Low Overhead Heap Profiling - 접근 2026-07-10
- JEP-421: Deprecate Finalization - 접근 2026-07-10
- JEP-521: Generational Shenandoah - 접근 2026-07-10
'Develop Artifacts > Java' 카테고리의 다른 글
| Java - 25. classloader jit (0) | 2026.07.12 |
|---|---|
| Java - 23. modules (0) | 2026.07.12 |
| Java - 22. networking (0) | 2026.07.12 |
| Java - 21. jdbc (0) | 2026.07.12 |
| Java - 20. virtual threads (0) | 2026.07.12 |
