GC의 역할과 핵심 개념
가비지 컬렉션(GC)은 JVM이 더 이상 참조되지 않는 객체의 메모리를 자동으로 회수하는 메커니즘입니다. GC 튜닝의 목표는 처리량(Throughput)과 지연 시간(Latency) 사이의 최적 균형을 찾는 것입니다.
도서관에 비유하면, GC는 반납된 책을 서가에 정리하는 사서입니다. 사서가 정리하는 동안 대출(애플리케이션)이 잠시 멈추는 것이 Stop-the-World(STW) 일시 정지입니다.
JVM 힙 메모리 구조
| 영역 | 설명 | 특징 |
|---|---|---|
| Young Generation | 새로 생성된 객체 | Eden + Survivor 0/1 |
| Old Generation | 오래 살아남은 객체 | Minor GC에서 생존한 객체가 승격 |
| Metaspace | 클래스 메타데이터 | Java 8+에서 PermGen 대체, 네이티브 메모리 |
GC 알고리즘 비교
// GcComparison.java — GC 알고리즘별 특성 비교
public class GcComparison {
public static void main(String[] args) {
// GC 알고리즘 선택 가이드
//
// 1. G1 GC (Java 9+ 기본)
// 실행: java -XX:+UseG1GC -Xmx4g -jar app.jar
// 특징: 힙을 동일 크기 리전으로 분할, 가비지가 많은 리전 우선 수집
// 목표: STW 일시 정지를 200ms 이하로 유지
// 적합: 힙 4GB 이상, 일반적인 서버 애플리케이션
//
// 2. ZGC (Java 15+, Java 21에서 Generational ZGC)
// 실행: java -XX:+UseZGC -Xmx16g -jar app.jar
// 특징: 컬러 포인터 + 로드 배리어, 대부분 동시(concurrent) 수행
// 목표: STW 일시 정지 1ms 미만 (힙 크기와 무관)
// 적합: 저지연 필수 시스템 (금융, 실시간 처리)
//
// 3. Shenandoah GC
// 실행: java -XX:+UseShenandoahGC -Xmx8g -jar app.jar
// 특징: 동시 컴팩션, ZGC와 유사한 저지연 목표
// 적합: OpenJDK 환경에서 저지연이 필요한 경우
//
// 4. Parallel GC
// 실행: java -XX:+UseParallelGC -Xmx4g -jar app.jar
// 특징: 다수 스레드로 GC 수행, 처리량 최대화
// 적합: 배치 처리, 일시 정지 허용 가능한 경우
// 현재 JVM의 GC 정보 확인
var gcBeans = java.lang.management.ManagementFactory.getGarbageCollectorMXBeans();
for (var gc : gcBeans) {
System.out.printf("GC 이름: %s, 수집 횟수: %d, 총 시간: %dms%n",
gc.getName(), gc.getCollectionCount(), gc.getCollectionTime());
}
// 실행 결과 (G1 GC 기준):
// GC 이름: G1 Young Generation, 수집 횟수: 3, 총 시간: 15ms
// GC 이름: G1 Old Generation, 수집 횟수: 0, 총 시간: 0ms
}
}
G1 GC 튜닝
// G1GcTuning.java — G1 GC 주요 옵션과 힙 분석
public class G1GcTuning {
public static void main(String[] args) {
// G1 GC 주요 JVM 옵션
//
// 힙 크기 설정
// -Xms4g -Xmx4g // 최소/최대 힙 동일하게 설정 (GC 힙 리사이징 방지)
//
// G1 목표 일시 정지 시간
// -XX:MaxGCPauseMillis=200 // 목표 STW 시간 (기본 200ms)
//
// 리전 크기 (1~32MB, 2의 거듭제곱)
// -XX:G1HeapRegionSize=4m // 대형 객체가 많으면 크게 설정
//
// Humongous 객체 임계값
// 리전 크기의 50% 이상인 객체 → Humongous 리전에 할당
// 큰 배열이 빈번하면 리전 크기를 늘려야 함
//
// GC 로그 활성화 (Java 9+)
// -Xlog:gc*:file=gc.log:time,uptime,level,tags:filecount=10,filesize=10m
// 메모리 사용량 모니터링 코드
Runtime runtime = Runtime.getRuntime();
long maxMemory = runtime.maxMemory() / (1024 * 1024); // 최대 힙
long totalMemory = runtime.totalMemory() / (1024 * 1024); // 현재 할당된 힙
long freeMemory = runtime.freeMemory() / (1024 * 1024); // 미사용 힙
long usedMemory = totalMemory - freeMemory; // 실제 사용량
System.out.printf("최대 힙: %dMB%n", maxMemory);
System.out.printf("현재 힙: %dMB%n", totalMemory);
System.out.printf("사용 중: %dMB%n", usedMemory);
System.out.printf("여유분: %dMB%n", freeMemory);
System.out.printf("사용률: %.1f%%%n", (double) usedMemory / maxMemory * 100);
// 실행 결과:
// 최대 힙: 4096MB
// 현재 힙: 256MB
// 사용 중: 12MB
// 여유분: 244MB
// 사용률: 0.3%
// GC 강제 실행 (프로덕션에서는 사용 금지, 분석용)
System.gc();
}
}
ZGC 활용과 설정
// ZgcDemo.java — ZGC 특성과 메모리 할당 테스트
import java.util.ArrayList;
import java.util.List;
public class ZgcDemo {
public static void main(String[] args) {
// ZGC 실행 옵션
// java -XX:+UseZGC -XX:+ZGenerational -Xmx8g ZgcDemo
//
// Java 21+ Generational ZGC (기본 활성화)
// -XX:+UseZGC // ZGC 활성화
// -XX:+ZGenerational // 세대별 ZGC (Java 21+)
// -XX:SoftMaxHeapSize=4g // 소프트 힙 상한 (가능하면 이 이하로 유지)
// -XX:ConcGCThreads=4 // 동시 GC 스레드 수
//
// ZGC 특성:
// - STW 일시 정지가 힙 크기와 무관하게 1ms 미만
// - 힙 크기를 넉넉히 설정할수록 GC 효율 증가
// - 메모리 오버헤드: 약 3~5% (컬러 포인터용)
// 메모리 압박 시뮬레이션
List<byte[]> allocations = new ArrayList<>();
long startTime = System.nanoTime();
for (int i = 0; i < 1000; i++) {
allocations.add(new byte[1024 * 1024]); // 1MB 할당
if (i % 100 == 0) {
// 오래된 할당 절반 해제 → GC 대상
int removeCount = allocations.size() / 2;
for (int j = 0; j < removeCount; j++) {
allocations.remove(0);
}
long elapsed = (System.nanoTime() - startTime) / 1_000_000;
Runtime rt = Runtime.getRuntime();
long used = (rt.totalMemory() - rt.freeMemory()) / (1024 * 1024);
System.out.printf("[%4dms] 할당 %d회, 사용 메모리: %dMB%n",
elapsed, i, used);
}
}
// 실행 결과 (ZGC 기준):
// [ 5ms] 할당 0회, 사용 메모리: 25MB
// [ 45ms] 할당 100회, 사용 메모리: 312MB
// [ 82ms] 할당 200회, 사용 메모리: 289MB
// [120ms] 할당 300회, 사용 메모리: 301MB
// ...
// ZGC: 일시 정지 없이 안정적인 메모리 관리
}
}
JFR(Java Flight Recorder)로 GC 분석
// JfrGcAnalysis.java — JFR 이벤트로 GC 동작 분석
import jdk.jfr.consumer.RecordedEvent;
import jdk.jfr.consumer.RecordingFile;
import java.nio.file.Path;
import java.util.DoubleSummaryStatistics;
import java.util.ArrayList;
import java.util.List;
public class JfrGcAnalysis {
public static void main(String[] args) throws Exception {
// JFR 녹화 시작 방법:
// 1. JVM 옵션: java -XX:StartFlightRecording=duration=60s,filename=gc.jfr -jar app.jar
// 2. jcmd: jcmd <PID> JFR.start duration=60s filename=gc.jfr
// JFR 파일 분석 (gc.jfr이 존재한다고 가정)
Path jfrFile = Path.of("gc.jfr");
List<Double> pauseTimes = new ArrayList<>();
try (RecordingFile recording = new RecordingFile(jfrFile)) {
while (recording.hasMoreEvents()) {
RecordedEvent event = recording.readEvent();
// GC 일시 정지 이벤트 필터링
if (event.getEventType().getName().equals("jdk.GCPhasePause")) {
double pauseMs = event.getDuration().toNanos() / 1_000_000.0;
String gcName = event.getString("name");
pauseTimes.add(pauseMs);
System.out.printf("GC 일시 정지: %s, 시간: %.2fms%n", gcName, pauseMs);
}
// 힙 사용량 이벤트
if (event.getEventType().getName().equals("jdk.GCHeapSummary")) {
long heapUsed = event.getLong("heapUsed") / (1024 * 1024);
System.out.printf("힙 사용량: %dMB%n", heapUsed);
}
}
}
// 통계 요약
if (!pauseTimes.isEmpty()) {
DoubleSummaryStatistics stats = pauseTimes.stream()
.mapToDouble(Double::doubleValue)
.summaryStatistics();
System.out.println("\n=== GC 일시 정지 통계 ===");
System.out.printf("총 횟수: %d%n", stats.getCount());
System.out.printf("평균: %.2fms%n", stats.getAverage());
System.out.printf("최대: %.2fms%n", stats.getMax());
System.out.printf("최소: %.2fms%n", stats.getMin());
}
// 실행 결과 예시:
// GC 일시 정지: G1 Young, 시간: 8.34ms
// 힙 사용량: 512MB
// GC 일시 정지: G1 Young, 시간: 5.21ms
// ...
// === GC 일시 정지 통계 ===
// 총 횟수: 47
// 평균: 6.82ms
// 최대: 15.43ms
// 최소: 2.11ms
}
}
메모리 누수 진단
// MemoryLeakDetection.java — 일반적인 메모리 누수 패턴과 진단
import java.lang.management.ManagementFactory;
import java.lang.management.MemoryMXBean;
import java.lang.management.MemoryUsage;
import java.util.HashMap;
import java.util.Map;
public class MemoryLeakDetection {
// 메모리 누수 패턴 1: 정적 맵에 데이터 축적
// private static final Map<String, byte[]> CACHE = new HashMap<>();
// → 해결: WeakHashMap 또는 크기 제한 캐시(Caffeine, Guava Cache) 사용
// 메모리 누수 패턴 2: 리스너/콜백 미해제
// eventEmitter.addListener(this);
// → 해결: removeListener() 호출, WeakReference 사용
// 메모리 누수 패턴 3: 커넥션/스트림 미종료
// Connection conn = dataSource.getConnection();
// → 해결: try-with-resources 사용
public static void main(String[] args) {
// 현재 메모리 상태 확인
MemoryMXBean memoryBean = ManagementFactory.getMemoryMXBean();
MemoryUsage heapUsage = memoryBean.getHeapMemoryUsage();
System.out.printf("힙 초기: %dMB%n", heapUsage.getInit() / (1024 * 1024));
System.out.printf("힙 사용: %dMB%n", heapUsage.getUsed() / (1024 * 1024));
System.out.printf("힙 커밋: %dMB%n", heapUsage.getCommitted() / (1024 * 1024));
System.out.printf("힙 최대: %dMB%n", heapUsage.getMax() / (1024 * 1024));
// 실행 결과:
// 힙 초기: 256MB
// 힙 사용: 8MB
// 힙 커밋: 256MB
// 힙 최대: 4096MB
// 힙 덤프 생성 (OutOfMemoryError 시 자동)
// JVM 옵션: -XX:+HeapDumpOnOutOfMemoryError
// -XX:HeapDumpPath=/tmp/heapdump.hprof
//
// 수동 덤프: jcmd <PID> GC.heap_dump /tmp/heapdump.hprof
// 분석 도구: Eclipse MAT, VisualVM, JProfiler
//
// 진단 순서:
// 1. jcmd <PID> GC.heap_info → 힙 영역별 사용량 확인
// 2. jcmd <PID> GC.class_histogram → 객체 수/크기 상위 목록
// 3. 힙 덤프 → MAT에서 Leak Suspects 분석
}
}
정리
GC 튜닝은 “측정 → 분석 → 조정 → 검증” 사이클로 접근해야 합니다.
- GC 선택 기준: 일반 서버는 G1(기본값), 저지연 시스템은 ZGC, 배치 처리는 Parallel GC를 선택합니다
- 힙 크기:
-Xms와-Xmx를 동일하게 설정하여 힙 리사이징 오버헤드를 제거합니다. 물리 메모리의 50~70%를 힙에 할당합니다 - G1 튜닝:
MaxGCPauseMillis로 목표 일시 정지를 설정하고, 대형 객체가 많으면G1HeapRegionSize를 늘립니다 - ZGC: Java 21+에서 Generational ZGC를 사용하면 힙 크기와 무관하게 1ms 미만 일시 정지를 달성합니다
- 모니터링 필수: GC 로그(
-Xlog:gc*)와 JFR을 항상 활성화합니다. 운영 환경에서 GC 로그 없이 튜닝하는 것은 눈 감고 운전하는 것과 같습니다 - 메모리 누수 진단: Old Gen이 GC 후에도 계속 증가하면 메모리 누수를 의심하고, 힙 덤프를 분석합니다