이 글에서 얻는 것

  • GC를 “종류 암기”가 아니라, 할당률(allocation rate)·생존률(survival)·승격(promotion) 관점으로 이해합니다.
  • Minor/ Major/ Full GC가 언제 늘어나는지, 그리고 무엇을 보면 원인을 좁힐 수 있는지(힙 구성/GC 로그/지표) 기준이 생깁니다.
  • 튜닝을 하기 전에 반드시 해야 하는 것(관측/재현/가설 검증)을 알고, GC 튜닝 글을 읽을 준비를 합니다.

0) GC는 ‘자동’이지만, 문제는 자동으로 해결되지 않는다

GC는 메모리를 자동으로 회수해주지만, 아래는 자동으로 해결되지 않습니다.

  • 너무 많이 할당한다(객체를 지나치게 만든다) → GC 빈도가 증가
  • 오래 사는 객체가 많다 → Old 영역이 커지고 STW가 길어질 수 있음
  • 캐시/버퍼 구조가 잘못됐다 → 힙이 커지거나, pause가 길어짐

그래서 GC는 “플래그로 해결”이 아니라 코드/구조/관측이 함께 가야 합니다.

1. GC 기본 개념

1.1 GC가 필요한 이유

C/C++에서의 수동 메모리 관리:

// C++
User* user = new User("Alice");
// ... 사용
delete user;  // ✅ 명시적 해제 필수

// 문제점:
// 1. Memory Leak: delete 깜빡하면 메모리 누수
// 2. Dangling Pointer: 이미 해제된 메모리 접근

Java의 자동 메모리 관리:

// Java
User user = new User("Alice");
// ... 사용
// delete 불필요! GC가 자동으로 회수

1.2 GC의 대상이 되는 객체

Reachability (도달 가능성):

GC Roots:
- Stack의 로컬 변수
- Method Area의 static 변수
- JNI에서 생성한 객체

          GC Roots
              │
              ▼
         ┌────────┐
         │Object A│ ← Reachable (살아있음)
         └────┬───┘
              │
              ▼
         ┌────────┐
         │Object B│ ← Reachable
         └────────┘

         ┌────────┐
         │Object C│ ← Unreachable (GC 대상)
         └────────┘

예제:

public class GCExample {
    private static User staticUser = new User("Static");  // GC Root

    public static void main(String[] args) {
        User user1 = new User("Local");  // GC Root (Stack)

        User user2 = new User("Temp");
        user2 = null;  // Unreachable → GC 대상

        createUser();
        // createUser()의 user3는 메서드 종료 후 Unreachable
    }

    public static void createUser() {
        User user3 = new User("Method");
        // 메서드 종료 후 user3는 GC 대상
    }
}

1.3 GC의 2가지 전제 (Weak Generational Hypothesis)

1. 대부분의 객체는 금방 Unreachable 상태가 된다
   → 생성된 객체의 98%는 곧바로 GC 대상

2. 오래된 객체에서 젊은 객체로의 참조는 아주 적다
   → Old → Young 참조는 드물다

증명:

public void processOrders() {
    for (Order order : orders) {
        // 임시 객체 생성 (금방 버려짐)
        OrderDto dto = new OrderDto(order);  // ← 98%는 여기서 생성
        validate(dto);
        // dto는 메서드 종료 후 즉시 GC 대상
    }
}

2. GC 알고리즘 동작 원리

2.1 Mark and Sweep

가장 기본적인 GC 알고리즘:

1. Mark 단계:
   GC Roots에서 시작하여 참조 그래프 탐색
   → Reachable 객체 마킹

       GC Roots
           │
           ▼
       [A✓] → [B✓]
           ↘
             [C✓]

       [D] [E]  ← 마킹 안 됨 (Unreachable)

2. Sweep 단계:
   마킹되지 않은 객체 제거

       [A✓] → [B✓]
           ↘
             [C✓]

       [   ] [   ]  ← D, E 제거됨

3. Compact 단계 (선택적):
   살아남은 객체를 한쪽으로 모음 (메모리 단편화 방지)

       [A][B][C][       ]

Stop-The-World (STW):

GC 실행 중에는 모든 애플리케이션 스레드가 중지됨
→ 응답 지연 발생!

GC의 목표: STW 시간을 최소화

2.2 Generational GC

Young Generation 구조:

┌──────────────────────────────────────┐
│         Young Generation             │
│                                      │
│  ┌──────────┐  ┌─────────────────┐  │
│  │   Eden   │  │    Survivor     │  │
│  │          │  │   S0  │   S1    │  │
│  │          │  │       │         │  │
│  └──────────┘  └─────────────────┘  │
│   새 객체 생성    임시 보관소         │
└──────────────────────────────────────┘

Minor GC 동작 과정:

초기 상태:
Eden: [A][B][C][D]
S0:   [   ]
S1:   [   ]

1. Eden이 가득 참 → Minor GC 발생

2. Reachable 객체 찾기
   Eden: [A✓][B✓][C][D]  (C, D는 Unreachable)

3. 살아있는 객체를 S0로 복사 (Age = 1)
   Eden: [   ][   ][   ][   ]
   S0:   [A¹][B¹]
   S1:   [   ]

4. 다음 Minor GC
   Eden: [E][F][G][H]
   S0:   [A¹][B¹]

   → Reachable: Eden의 E, F와 S0의 A, B
   → S1로 복사 (Age + 1)

   Eden: [   ][   ][   ][   ]
   S0:   [   ]
   S1:   [A²][B²][E¹][F¹]

5. S0 ↔ S1 반복 (항상 하나는 비어있음)

6. Age >= 15 → Old Generation으로 Promotion
   Old:  [A¹⁵][B¹⁵]

실제 동작 예제:

public class MinorGCExample {
    public static void main(String[] args) {
        List<User> users = new ArrayList<>();

        // 1. Eden에 객체 생성
        for (int i = 0; i < 1000; i++) {
            users.add(new User("User" + i));
        }

        // 2. Eden이 가득 차면 Minor GC 발생
        // users 리스트는 GC Root → Reachable
        // User 객체들도 users가 참조 → Reachable
        // → Survivor로 이동

        // 3. 임시 객체 대량 생성
        for (int i = 0; i < 1000000; i++) {
            User temp = new User("Temp" + i);
            // temp는 루프 다음 반복에서 Unreachable
            // → Minor GC 때 회수됨
        }

        // 4. users는 계속 참조 유지
        // → Age 증가하며 Survivor 0 ↔ 1 이동
        // → 최종적으로 Old Generation으로 Promotion
    }
}

2.3 Major GC (Full GC)

Old Generation GC:

발생 조건:
1. Old Generation이 가득 참
2. System.gc() 명시적 호출 (권장 안 함!)
3. Metaspace가 가득 참

특징:
- Minor GC보다 훨씬 느림 (10배 이상)
- STW 시간이 길어짐
- 빈번한 Full GC는 성능 저하의 주요 원인

예제:

public class FullGCExample {
    private static List<byte[]> storage = new ArrayList<>();

    public static void main(String[] args) {
        // Old Generation을 계속 채움
        while (true) {
            byte[] data = new byte[1024 * 1024];  // 1MB
            storage.add(data);
            // storage가 static → GC Root
            // → data 객체들이 Old로 Promotion
            // → Old Generation 가득 참 → Full GC 발생!
        }
    }
}

// 실행 결과:
// [GC (Allocation Failure) ... 0.123 secs]  ← Minor GC
// [Full GC (Ergonomics) ... 0.987 secs]     ← Full GC (느림!)

3. GC 알고리즘 상세

3.1 Serial GC

특징:

- 단일 스레드로 GC 수행
- STW 시간이 가장 김
- CPU 코어가 1개인 환경에서만 사용
- 현대 서버 환경에서는 거의 사용 안 함

JVM 옵션:

-XX:+UseSerialGC

동작:

Minor GC: Copy 알고리즘 (Young Generation)
Major GC: Mark-Sweep-Compact (Old Generation)

모든 GC가 단일 스레드로 순차 실행
→ STW 시간 = GC 시간 전체

3.2 Parallel GC (Throughput GC)

특징:

- 멀티 스레드로 GC 수행
- Throughput(처리량) 최적화
- STW 시간은 여전히 존재
- Java 8 기본 GC

JVM 옵션:

-XX:+UseParallelGC
-XX:ParallelGCThreads=4  # GC 스레드 수 (기본: CPU 코어 수)
-XX:MaxGCPauseMillis=200  # 목표 pause time (밀리초)
-XX:GCTimeRatio=99        # GC 시간 비율 (기본: 1%)

동작:

┌────────────┐  ┌────────────┐  ┌────────────┐
│ GC Thread 1│  │ GC Thread 2│  │ GC Thread 3│
└──────┬─────┘  └──────┬─────┘  └──────┬─────┘
       │                │                │
       └────────────────┼────────────────┘
                        │
                 Eden을 3등분하여
                 병렬로 Mark & Copy

→ STW 시간 단축 (Serial 대비 1/N)

사용 사례:

- 배치 작업 (Throughput 중요)
- 백그라운드 분석 작업
- STW가 문제되지 않는 환경

3.3 CMS GC (Concurrent Mark Sweep)

특징:

- Low Latency 최적화
- 애플리케이션과 GC가 동시 실행
- STW 시간 최소화
- Java 14에서 Deprecated

JVM 옵션:

-XX:+UseConcMarkSweepGC
-XX:CMSInitiatingOccupancyFraction=75  # Old가 75% 차면 GC 시작
-XX:+UseCMSInitiatingOccupancyOnly     # 위 설정만 사용

동작 과정:

1. Initial Mark (STW, 짧음)
   GC Roots에서 직접 참조하는 객체만 마킹

2. Concurrent Mark (동시 실행)
   애플리케이션 실행 중에 전체 객체 그래프 탐색
   → 사용자 요청 처리와 동시에 GC 진행

3. Remark (STW, 짧음)
   Concurrent Mark 중 변경된 객체 재마킹

4. Concurrent Sweep (동시 실행)
   Unreachable 객체 제거

┌──────────────────────────────────────────────────┐
│ Application Threads                              │
│ ───────────────────────────────────────────────→ │
└──────────────────────────────────────────────────┘
   ▲STW  Concurrent  ▲STW   Concurrent
   IM               RM

문제점:

1. CPU 자원 사용 증가 (GC + Application 동시 실행)
2. Fragmentation (메모리 단편화)
   → Compact하지 않음
   → 빈 공간이 많아도 큰 객체 할당 실패
3. Floating Garbage
   → Concurrent Mark 중 생성된 가비지는 다음 GC에서 회수

3.4 G1 GC (Garbage First)

특징:

- Java 9+ 기본 GC
- Heap을 Region으로 나눔
- Pause Time 목표 설정 가능
- CMS의 단편화 문제 해결

Heap 구조:

┌────┬────┬────┬────┬────┬────┬────┬────┐
│ E  │ E  │ S  │ O  │ O  │ O  │ H  │ E  │
├────┼────┼────┼────┼────┼────┼────┼────┤
│ O  │ S  │ E  │ E  │ O  │ H  │ O  │ E  │
└────┴────┴────┴────┴────┴────┴────┴────┘

E: Eden Region
S: Survivor Region
O: Old Region
H: Humongous Region (큰 객체, Region 크기의 50% 이상)

각 Region: 기본 1MB ~ 32MB (Heap 크기에 따라 자동 결정)

JVM 옵션:

-XX:+UseG1GC
-XX:MaxGCPauseMillis=200        # 목표 pause time (기본: 200ms)
-XX:G1HeapRegionSize=4m         # Region 크기 (기본: 자동)
-XX:InitiatingHeapOccupancyPercent=45  # GC 시작 임계값

동작 과정:

1. Young GC (Evacuation Pause)
   Eden과 Survivor Region을 다른 Region으로 복사
   → STW 발생하지만 짧음 (Pause Time 목표치 내)

2. Concurrent Marking Cycle
   - Initial Mark (STW, Young GC와 동시)
   - Root Region Scan
   - Concurrent Mark (동시)
   - Remark (STW)
   - Cleanup (STW + Concurrent)

3. Mixed GC
   Young + Old Region 중 가비지가 많은 Region 우선 회수
   → "Garbage First"의 의미

예제:

// G1 GC 최적화 예시
public class G1Example {
    public static void main(String[] args) {
        List<byte[]> list = new ArrayList<>();

        // G1 GC는 pause time 목표를 맞추기 위해
        // 가비지가 많은 Region부터 회수

        for (int i = 0; i < 100; i++) {
            byte[] data = new byte[1024 * 1024];  // 1MB
            list.add(data);

            if (i % 10 == 0) {
                list.clear();  // 10MB마다 비움
                // → 이 Region은 100% 가비지
                // → G1이 우선적으로 회수
            }
        }
    }
}

// G1 GC 로그:
// [GC pause (G1 Evacuation Pause) (young), 0.0123 secs]
// → Pause time이 목표치(200ms) 내에 완료

장점:

1. Predictable Pause Time
   → -XX:MaxGCPauseMillis로 목표 설정 가능

2. 큰 Heap에서도 효율적 (>4GB)
   → Region 단위로 관리

3. Fragmentation 해결
   → Compaction 수행

4. Throughput도 준수
   → CMS보다 Throughput 높음

3.5 ZGC (Z Garbage Collector)

특징:

- Java 15+ 정식 지원
- Ultra-low Latency (< 10ms pause time)
- Heap 크기와 무관하게 일정한 pause time
- Concurrent Compaction

JVM 옵션:

-XX:+UseZGC
-XX:ZAllocationSpikeTolerance=2  # 메모리 할당 spike 허용
-XX:ZCollectionInterval=5        # GC 주기 (초)

핵심 기술:

1. Colored Pointers
   64bit 포인터의 일부 비트를 메타데이터로 사용

   ┌────────────────────────────────────────────┐
   │ 42bit Address │ Metadata │ Reserved │ 0 │
   └────────────────────────────────────────────┘

   Metadata: Marked, Remapped, Finalized 등

2. Load Barriers
   객체 접근 시 자동으로 재배치 정보 확인
   → Concurrent Compaction 가능

3. Concurrent Everything
   모든 GC 단계가 concurrent (STW 거의 없음)

STW 시간 비교:

G1 GC:   50ms ~ 200ms (Heap 크기에 따라 증가)
ZGC:     < 10ms (Heap 크기와 무관하게 일정)

ZGC는 TB급 Heap에서도 pause time < 10ms 보장

사용 사례:

- 초저지연이 필수인 금융 시스템
- 실시간 게임 서버
- 대용량 In-Memory Database

4. GC 튜닝 실전

4.1 GC 로그 분석

GC 로그 활성화:

# Java 8
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-XX:+PrintGCTimeStamps
-Xloggc:/var/log/gc.log

# Java 9+
-Xlog:gc*:file=/var/log/gc.log:time,uptime,level,tags

로그 예시:

# Young GC
2025-01-26T10:30:00.123+0900: [GC (Allocation Failure)
  [PSYoungGen: 512000K->51200K(614400K)]
  1024000K->563200K(2048000K), 0.0123456 secs]

분석:
- Young Generation: 512MB → 51MB (563MB 크기)
- 전체 Heap: 1GB → 563MB (2GB 크기)
- Pause Time: 12.3ms

# Full GC
2025-01-26T10:35:00.456+0900: [Full GC (Ergonomics)
  [PSYoungGen: 51200K->0K(614400K)]
  [ParOldGen: 512000K->460800K(1433600K)]
  563200K->460800K(2048000K), 0.987654 secs]

분석:
- Young: 51MB → 0MB
- Old: 512MB → 460MB
- 전체: 563MB → 460MB
- Pause Time: 987ms ⚠️ (매우 느림!)

4.2 GC 튜닝 전략

1단계: 현재 상태 파악

# GC 통계 모니터링
jstat -gcutil <pid> 1000

# 출력:
  S0     S1     E      O      M     CCS    YGC     YGCT    FGC    FGCT     GCT
  0.00  92.50  45.30  78.90  95.20  90.10   100    1.234     5    4.567   5.801

# S0, S1: Survivor 사용률
# E: Eden 사용률
# O: Old 사용률
# YGC: Young GC 횟수
# FGC: Full GC 횟수
# GCT: 전체 GC 시간

2단계: 문제 진단

증상 1: Full GC가 빈번 (분당 1회 이상)
원인: Old Generation이 빠르게 차오름
해결:
  1. Heap 크기 증가: -Xmx8g
  2. Young Generation 크기 증가: -Xmn2g
  3. 객체 생명주기 단축 (코드 개선)

증상 2: Young GC pause time이 김 (> 100ms)
원인: Young Generation이 너무 큼
해결:
  1. Young Generation 크기 감소: -Xmn512m
  2. GC 스레드 수 증가: -XX:ParallelGCThreads=8

증상 3: Old Generation 사용률이 계속 증가
원인: 메모리 누수
해결:
  1. Heap Dump 분석: jmap -dump:file=heap.bin <pid>
  2. Eclipse MAT로 누수 객체 확인
  3. 코드 수정 (SoftReference, ThreadLocal.remove() 등)

3단계: GC 알고리즘 선택

시나리오 1: 배치 작업 (Throughput 중시)
→ Parallel GC
-XX:+UseParallelGC -Xms4g -Xmx4g

시나리오 2: 웹 애플리케이션 (Latency 중시, Heap < 32GB)
→ G1 GC
-XX:+UseG1GC -Xms8g -Xmx8g -XX:MaxGCPauseMillis=200

시나리오 3: 실시간 시스템 (Ultra-low Latency, Heap > 32GB)
→ ZGC
-XX:+UseZGC -Xms64g -Xmx64g

4.3 GC 튜닝 실전 예시

Before:

# 문제 상황
-Xms2g -Xmx2g -XX:+UseParallelGC

# GC 로그:
[Full GC ... 1.234 secs]  ← 1초 이상 STW!
[Full GC ... 1.456 secs]
[Full GC ... 1.678 secs]  ← 빈번한 Full GC

분석:

jstat -gcutil <pid> 1000

  S0     S1     E      O      M
  0.00   0.00  12.30  98.50  95.20  ← Old가 98% (위험!)

After:

# 해결책 1: Heap 크기 증가 + G1 GC
-Xms8g -Xmx8g -XX:+UseG1GC -XX:MaxGCPauseMillis=200

# 해결책 2: Young Generation 크기 조정
-Xms4g -Xmx4g -Xmn1g -XX:+UseG1GC

# 결과:
[GC pause (G1 Evacuation Pause) (young), 0.045 secs]  ← 45ms
[GC pause (G1 Evacuation Pause) (mixed), 0.123 secs]  ← 123ms

Full GC 발생 빈도: 분당 10회 → 시간당 1회 이하
응답 시간: p99 2초 → 200ms

5. 메모리 누수 디버깅

5.1 Heap Dump 생성 및 분석

자동 Heap Dump:

# OOM 발생 시 자동으로 Heap Dump 생성
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/log/heapdump.hprof

수동 Heap Dump:

# 실행 중인 프로세스의 Heap Dump
jmap -dump:live,format=b,file=heap.bin <pid>

# 파일 크기 확인
ls -lh heap.bin
# -rw-r--r-- 1 user user 2.3G Jan 26 10:00 heap.bin

Eclipse MAT 분석:

1. File → Open Heap Dump → heap.bin

2. Leak Suspects Report 자동 생성
   → "Problem Suspect 1"
   → "ArrayList에 1,234,567개 객체가 누적"

3. Dominator Tree
   → 가장 많은 메모리를 차지하는 객체 확인

4. Path to GC Roots
   → 왜 객체가 GC되지 않는지 참조 경로 확인

실제 사례:

문제: ArrayList가 2GB 메모리 차지

Path to GC Roots:
CacheManager (static)
  └─ HashMap<String, Object> cache
      └─ ArrayList<Data> dataList (2GB)

원인: static 변수로 cache 유지 → GC 불가능

해결:
private static Map<String, SoftReference<Object>> cache;

요약: GC를 이해하는 최소 감각

GC 기본

  • Reachability: GC Root에서 도달 가능한 객체만 살아남습니다.
  • Generational 가정: 대부분의 객체는 금방 죽고, 오래 사는 객체는 일부입니다.
  • STW(Stop-The-World)는 “피할 수 없는 비용”이므로, 관측하고 줄이는 방향으로 접근합니다.
  • Minor(Young) / Major(Old) / Full GC가 언제 늘어나는지 구분하면 원인 좁히기가 쉬워집니다.

알고리즘 선택(결국 트레이드오프)

  • Throughput(배치) vs Latency(온라인)
  • G1은 “대부분의 온라인 서비스 기본값”으로 자주 쓰이고, ZGC는 더 낮은 pause를 목표로 합니다(운영/힙 크기/버전 전제).

튜닝 전에 먼저 할 것

  • GC 로그/지표를 켜고(관측), 재현 가능한 부하/트래픽에서 비교한다(가설 검증).
  • 플래그보다 코드/구조(할당률/객체 생존/캐시/버퍼)를 먼저 본다.