Java GC의 동작 원리부터 GC 종류별 아키텍처, 튜닝 옵션, 실무 선택 가이드까지 완전히 정리합니다.


1. GC란? 왜 필요한가?

Garbage Collection(GC)은 프로그램이 동적으로 할당한 메모리 중 더 이상 사용하지 않는 객체를 자동으로 탐지하고 회수하는 메커니즘입니다.

수동 메모리 관리의 문제

C/C++처럼 프로그래머가 직접 메모리를 해제하면 두 가지 치명적 버그가 발생합니다.

메모리 누수 (Memory Leak):
  ptr = malloc(100);
  // free(ptr) 깜빡 → 메모리 반환 안 됨 → 힙 고갈

댕글링 포인터 (Dangling Pointer):
  ptr = malloc(100);
  free(ptr);
  *ptr = 42; // 해제된 메모리 접근 → 정의되지 않은 동작

Java는 GC가 객체 회수를 책임지므로 개발자는 비즈니스 로직에만 집중할 수 있습니다. 단, GC가 동작하는 동안 발생하는 Stop-the-World(STW) 일시 정지가 애플리케이션 응답성에 영향을 미칩니다.


2. JVM 메모리 구조

전체 메모리 영역

┌──────────────────────────────────────────────────────────────┐
│                        JVM 메모리                            │
│                                                              │
│  ┌──────────────────────────────────────────────────────┐   │
│  │                      Heap                            │   │
│  │  ┌────────────────────────┐  ┌────────────────────┐  │   │
│  │  │    Young Generation    │  │  Old Generation    │  │   │
│  │  │  ┌──────┬─────┬──────┐ │  │  (Tenured Space)   │  │   │
│  │  │  │ Eden │ S0  │  S1  │ │  │                    │  │   │
│  │  │  └──────┴─────┴──────┘ │  │  오래 살아남은     │  │   │
│  │  │  (새 객체 할당 영역)    │  │  객체들            │  │   │
│  │  └────────────────────────┘  └────────────────────┘  │   │
│  └──────────────────────────────────────────────────────┘   │
│                                                              │
│  ┌──────────────────────┐  ┌──────────────────────────────┐  │
│  │      Metaspace       │  │       Stack (스레드별)        │  │
│  │  클래스 메타데이터   │  │  ┌──────┐ ┌──────┐ ┌──────┐  │  │
│  │  메서드 정보         │  │  │T1    │ │T2    │ │T3    │  │  │
│  │  static 변수         │  │  │Stack │ │Stack │ │Stack │  │  │
│  │  (JVM 내부 → Native) │  │  └──────┘ └──────┘ └──────┘  │  │
│  └──────────────────────┘  └──────────────────────────────┘  │
└──────────────────────────────────────────────────────────────┘

각 영역 설명

영역 위치 내용 GC 대상
Eden Heap/Young 새로 생성된 객체 Minor GC
Survivor S0/S1 Heap/Young Eden에서 살아남은 객체 Minor GC
Old (Tenured) Heap/Old 오래 살아남은 객체 Major/Full GC
Metaspace Native Memory 클래스 메타데이터 Full GC
Stack Thread별 지역 변수, 스택 프레임 GC 대상 아님
Code Cache Native Memory JIT 컴파일된 코드 GC 대상 아님

Metaspace (Java 8+)

Java 7까지는 클래스 정보를 Heap 내의 PermGen(Permanent Generation)에 저장했습니다. PermGen은 크기가 고정되어 있어 클래스가 많으면 OutOfMemoryError: PermGen space 오류가 자주 발생했습니다.

Java 8부터 PermGen을 폐지하고 Metaspace로 대체했습니다. Metaspace는 Native 메모리를 사용하며 필요에 따라 자동으로 확장됩니다.

# Metaspace 크기 제한 설정 (설정 없으면 무제한 확장)
-XX:MetaspaceSize=256m
-XX:MaxMetaspaceSize=512m

3. GC 기본 알고리즘

Mark-and-Sweep

GC의 가장 기본적인 알고리즘입니다. 두 단계로 동작합니다.

Mark 단계: GC Root에서 시작하여 참조 그래프를 탐색, 살아있는 객체에 표시
Sweep 단계: 표시되지 않은 객체를 메모리에서 해제

GC Root:
  - Stack의 지역 변수
  - static 변수
  - JNI 참조

Before Mark:
  [A]→[B]→[C]   [D]→[E]   [F]   [G]→[H]
   ↑
  Root

After Mark (살아있는 객체: A, B, C):
  [A*]→[B*]→[C*]   [D]→[E]   [F]   [G]→[H]

After Sweep:
  [A*]→[B*]→[C*]   [   ]     [ ]   [   ]
                     해제       해제    해제

단점: Sweep 후 메모리 단편화(Fragmentation) 발생. 큰 객체 할당 실패 가능.

Mark-and-Compact

Mark-and-Sweep에 압축(Compact) 단계를 추가합니다.

After Compact:
  [A*][B*][C*][   ][   ][   ][   ][   ]
  ↑──────────── 사용 중 ────────↑── 빈 공간 ──┘

장점: 단편화 없음, 연속 메모리 할당 가능
단점: Compact 과정에서 객체 이동 → 참조 주소 갱신 필요 → STW 시간 길어짐

Copying (복사 알고리즘)

메모리를 두 영역으로 나누어 사용 중인 절반의 살아있는 객체를 다른 절반으로 복사합니다.

From Space:  [A*][B ][C*][D ][E*]   (B, D는 가비지)
To Space:    [   ][   ][   ][   ][   ]

복사 후:
From Space:  [   ][   ][   ][   ][   ]  (전체 비움)
To Space:    [A* ][C* ][E* ][   ][   ]  (살아있는 객체만 복사, 압축됨)

장점: 단편화 없음, 할당 속도 빠름(포인터 하나만 이동). 단점: 메모리 절반만 사용 가능. Young Generation의 Eden ↔ Survivor 복사에 이 방식을 사용합니다.

Reference Counting (Java 미사용)

각 객체에 참조 횟수를 저장하고 0이 되면 즉시 해제합니다. Python, Swift 등에서 사용하지만 Java는 채택하지 않았습니다.

이유: 순환 참조(Circular Reference) 처리 불가

A → B → C → A  (세 객체가 서로 참조)
외부 참조가 없어도 각 카운트가 1 이상 → 영원히 해제 불가

4. Generational GC 가설 — 약한 세대 가설

핵심 가설

“대부분의 객체는 생성 직후 금방 죽는다(짧은 수명을 가진다).”

실제 프로그램에서 객체 생존 패턴을 분석하면 다음 분포를 보입니다.

객체 수명 분포

많음 │*
     │**
     │***
     │****
     │*****
     │*******
     │**************
     │****************************
적음 └──────────────────────────────────────→ 수명(시간)
     짧음                              길음

→ 대부분의 객체는 생성 직후 회수됨
→ 살아남은 객체는 오래 생존하는 경향

이 가설을 바탕으로 메모리를 Young GenerationOld Generation으로 분리합니다.

Young Generation 구조

Young Generation
┌──────────────────────────────────────────────┐
│                                              │
│  ┌───────────────────┐  ┌──────┐  ┌──────┐  │
│  │      Eden         │  │  S0  │  │  S1  │  │
│  │  (새 객체 할당)   │  │(From)│  │(To)  │  │
│  │                   │  │      │  │      │  │
│  │  빠른 TLAB 할당   │  │      │  │      │  │
│  └───────────────────┘  └──────┘  └──────┘  │
│   (전체의 약 80%)         (각 10%)            │
└──────────────────────────────────────────────┘

TLAB: Thread-Local Allocation Buffer
  각 스레드가 Eden의 일부를 독점 사용 → 동기화 없이 빠른 할당

Minor GC vs Major GC vs Full GC

Minor GC (Young GC):
  트리거: Eden 영역이 가득 찼을 때
  대상:   Young Generation만
  STW:    짧음 (수 ms ~ 수십 ms)
  빈도:   자주 발생

Major GC (Old GC):
  트리거: Old Generation이 가득 찼을 때
  대상:   Old Generation
  STW:    Minor GC보다 길음
  빈도:   드물게 발생

Full GC:
  트리거: 힙 전체 부족, System.gc() 호출, Metaspace 부족
  대상:   Young + Old + Metaspace
  STW:    가장 김 (수백 ms ~ 수 초)
  빈도:   가능한 한 피해야 함

객체 승격(Promotion) 과정

1단계: 새 객체 → Eden 할당
   Eden: [Obj-A][Obj-B][Obj-C][Obj-D][Obj-E] ... (가득 참)

2단계: Minor GC 발생
   살아있는 객체(A, C, E) → Survivor S0로 복사 (age = 1)
   Eden: 전체 비움
   S0:   [A(age=1)][C(age=1)][E(age=1)]

3단계: 다음 Minor GC
   살아있는 객체(A, E) → S1로 복사 (age = 2)
   B, C 등 죽은 객체: 회수
   S0: 비움
   S1: [A(age=2)][E(age=2)]

4단계: age가 임계값(-XX:MaxTenuringThreshold, 기본 15) 도달 시
   → Old Generation으로 승격(Promote)

Old Generation:
   [A(promoted)][E(promoted)] ... 장수 객체들

5. GC 종류별 상세 설명

Serial GC

단일 스레드로 GC를 수행합니다. GC 동안 애플리케이션 스레드가 모두 멈춥니다.

Serial GC 동작:

[App Thread 1] ██████████|  GC  |██████████
[App Thread 2] ██████████|  GC  |██████████
[App Thread 3] ██████████|  GC  |██████████
[GC Thread]              |██████|

Mark → Sweep → Compact (단일 GC 스레드)
# Serial GC 활성화
-XX:+UseSerialGC
항목 내용
적합 환경 단일 코어, 소규모 힙(~수백 MB), 클라이언트 앱
일시 정지 길음
처리량 낮음
메모리 오버헤드 최소

Parallel GC (Throughput GC)

멀티 스레드로 Minor GC를 수행합니다. Java 8까지의 기본 GC였습니다.

Parallel GC 동작:

[App Thread 1] ██████████|      GC       |████████
[App Thread 2] ██████████|      GC       |████████
[GC Thread 1]            |██████████████|
[GC Thread 2]            |██████████████|
[GC Thread 3]            |██████████████|
[GC Thread 4]            |██████████████|

복수의 GC 스레드가 병렬 처리 → STW는 있지만 단시간
-XX:+UseParallelGC
-XX:ParallelGCThreads=8      # GC 스레드 수
-XX:GCTimeRatio=19           # GC 시간 비율 (1/(1+19) = 5% 목표)
-XX:MaxGCPauseMillis=200     # 최대 일시 정지 목표 (ms)
항목 내용
적합 환경 배치 처리, 대용량 데이터 처리 (응답 시간보다 처리량 중요)
일시 정지 중간 (Serial보다 짧음)
처리량 높음

CMS (Concurrent Mark-Sweep) GC — Deprecated

애플리케이션 스레드와 GC 스레드가 동시에(Concurrent) 실행되어 STW를 최소화합니다. Java 9에서 Deprecated, Java 14에서 완전 제거되었습니다.

CMS GC 단계:

Phase 1: Initial Mark (STW — 짧음)
  GC Root 직접 참조 객체만 표시

Phase 2: Concurrent Mark (동시)
  [App] ████████████████████████████████
  [GC]  ────────── Mark 탐색 ───────────
  → 애플리케이션과 동시 실행

Phase 3: Remark (STW — 중간)
  동시 실행 중 변경된 참조 재확인

Phase 4: Concurrent Sweep (동시)
  [App] ████████████████████████████████
  [GC]  ────────── Sweep ────────────────
  → 애플리케이션과 동시 실행

CMS 단점:

  • Compact 미수행 → 단편화 심각 → 결국 Full GC (STW) 발생
  • 높은 CPU 사용률 (GC 스레드가 CPU 지속 소비)
  • Floating Garbage (동시 실행 중 발생한 새 가비지는 다음 사이클에 처리)

G1 GC (Garbage First) — Java 9+ 기본

Region 기반으로 힙을 동일한 크기의 블록으로 나누어 관리합니다. 각 Region은 역할이 동적으로 변합니다.

G1 GC 힙 구조 (예: 2048개 Region)

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

E: Eden Region
S: Survivor Region
O: Old Region
H: Humongous Region (큰 객체)
빈 Region: 언제든 역할 변경 가능

G1 GC 동작 단계:

1. Young GC (STW):
   Eden + Survivor Region → 살아있는 객체를 새 Survivor/Old Region으로 복사
   회수된 Region → 비워서 재사용

2. Concurrent Marking Cycle (동시):
   2-1. Initial Mark (STW — Young GC와 함께): GC Root 직접 참조 표시
   2-2. Root Region Scan (동시): Survivor Region 참조 스캔
   2-3. Concurrent Mark (동시): 전체 힙 참조 탐색
   2-4. Remark (STW): SATB(Snapshot-At-The-Beginning) 처리
   2-5. Cleanup (STW + 동시): 회수 가능 Region 목록 작성

3. Mixed GC (STW):
   Young Region + 회수 효율 높은 Old Region 선택적 수집
   → "Garbage First": 가비지 비율 높은 Region 우선 처리

Humongous 객체:

// Region 크기의 50% 이상인 객체는 Humongous Region에 배치
// Region 크기 = 힙 크기 / 2048 (1MB ~ 32MB)
// 예: 힙 4GB → Region 크기 2MB → 1MB 이상 객체가 Humongous

byte[] hugeArray = new byte[2 * 1024 * 1024]; // 2MB → Humongous

// Humongous 객체는 Old에 직접 할당 → Young GC로 회수 안 됨
// 짧게 사는 큰 객체는 GC 부담 증가 → 가능하면 분할

G1 GC 주요 옵션:

-XX:+UseG1GC                      # G1 활성화 (Java 9+는 기본값)
-XX:MaxGCPauseMillis=200          # 목표 최대 일시 정지 시간 (ms)
-XX:G1HeapRegionSize=16m          # Region 크기 (1~32MB, 2의 거듭제곱)
-XX:G1NewSizePercent=5            # Young Generation 최소 비율
-XX:G1MaxNewSizePercent=60        # Young Generation 최대 비율
-XX:G1MixedGCLiveThresholdPercent=85  # Mixed GC 포함 Old Region 기준
-XX:InitiatingHeapOccupancyPercent=45 # Concurrent Mark 시작 힙 점유율

Remembered Set과 Card Table:

Old → Young 참조 추적 문제:
Young GC 시 Old Region도 GC Root로 스캔해야 하면 비용 증가

해결: Card Table + Remembered Set
┌─────────────────────────────────────────────────────┐
│  Old Region                                         │
│  ┌────┬────┬────┬────┐  Card Table (512 byte 단위)  │
│  │Card│Card│Card│Card│  각 Card에 dirty 비트 표시    │
│  └────┴────┴──┬─┴────┘                             │
│               │ Old 객체가 Young 객체 참조           │
└───────────────┼─────────────────────────────────────┘
                ↓
         Young Region의 Remembered Set에 기록
         → Young GC 시 RS만 확인하면 됨 (전체 Old 스캔 불필요)

ZGC (Z Garbage Collector) — Java 15+ Production

목표: 최대 일시 정지 1ms 미만 (힙 크기와 무관).

-XX:+UseZGC

ZGC 핵심 기술:

1. Colored Pointer (색상 포인터)

일반 64비트 포인터:
  [0000 0000 ... 실제 주소 (42비트) ...]

ZGC 포인터:
  [0000 0000 ... 실제 주소 (42비트) ... | Finalizable | Remapped | Marked1 | Marked0]
                                           ↑ 상위 4비트를 메타데이터로 활용

객체 주소 자체에 GC 상태를 저장하여 추가 메모리 없이 동시 처리를 구현합니다.

2. Load Barrier (부하 장벽)

// 코드상 단순 참조 접근:
Object obj = someField;

// JIT 컴파일 후 (Load Barrier 삽입):
Object obj = someField;
if (obj의 포인터 색상이 현재 GC 뷰와 불일치) {
    obj = GC가 알고 있는 최신 주소로 수정; // 힙 이동 후 참조 갱신
}

Load Barrier가 모든 객체 참조 접근 시 동작하여 동시 이동(Concurrent Relocation) 중에도 올바른 주소를 보장합니다.

ZGC 동작 단계:

모든 단계가 동시 실행 (STW는 극히 짧은 3번만)

Pause Mark Start (STW — <1ms): GC Root 표시 시작
Concurrent Mark:                힙 전체 동시 표시
Pause Mark End (STW — <1ms):   표시 완료 처리
Concurrent Prepare for Reloc:  이동할 Region 선택
Pause Relocate Start (STW — <1ms): 이동 시작
Concurrent Relocate:            객체 동시 이동
Concurrent Remap:               포인터 업데이트

대용량 힙 지원:

# ZGC는 TB 단위 힙도 지원
-Xmx16t   # 16 Terabytes
-XX:+UseZGC

Generational ZGC (Java 21):

Java 21부터 ZGC도 Young/Old Generation을 분리하는 Generational 모드를 지원합니다.

-XX:+UseZGC -XX:+ZGenerational  # Java 21
# Java 23부터 Generational이 기본값

Shenandoah GC

RedHat이 개발한 동시 압축(Concurrent Compaction) GC입니다.

-XX:+UseShenandoahGC

ZGC와의 차이점:

Brooks Pointer (브룩스 포인터):

Shenandoah 객체 레이아웃:
┌─────────────────────────────────┐
│  Brooks Pointer (간접 주소)      │ ← 헤더에 추가된 포인터
│  Object Header                  │
│  Object Fields                  │
└─────────────────────────────────┘

이동 시:
  Old Location의 Brooks Pointer → New Location 가리킴
  모든 스레드가 old를 통해 new에 접근 → 동시 이동 가능
  이후 점진적으로 직접 참조 업데이트
항목 ZGC Shenandoah
개발사 Oracle RedHat
포인터 기법 Colored Pointer Brooks Pointer
Load Barrier 있음 있음
메모리 오버헤드 낮음 약간 높음 (헤더 추가)
대용량 힙 매우 강함 강함
OpenJDK 포함 Java 15+ Java 12+

6. GC 종류별 비교 표

GC 최대 일시 정지 처리량 메모리 오버헤드 구현 복잡도 적합 환경
Serial 초 단위 가능 낮음 최소 매우 낮음 단일 코어, 임베디드
Parallel 수십~수백 ms 최고 낮음 낮음 배치, 과학 계산
CMS 수십 ms (Mark/Remark) 높음 중간 높음 (Deprecated, 사용 지양)
G1 수십~수백 ms (목표 설정) 높음 중간 중간 범용, 4GB+ 힙
ZGC <1ms (목표) 약간 낮음 약간 높음 높음 저지연, TB급 힙
Shenandoah <10ms (목표) 약간 낮음 약간 높음 높음 저지연, 범용

7. GC 튜닝 핵심 옵션

힙 크기 설정

-Xms2g                  # 초기 힙 크기 2GB
-Xmx8g                  # 최대 힙 크기 8GB
# 실무 팁: Xms == Xmx로 설정 시 동적 조정 비용 제거
#           컨테이너에서는 MaxRAMPercentage 사용 권장
-XX:MaxRAMPercentage=75 # 컨테이너 메모리의 75%를 힙에 할당

Young/Old 비율 조정

-XX:NewRatio=2          # Old:Young = 2:1 → Young이 힙의 1/3
-XX:NewSize=512m        # Young Generation 초기 크기
-XX:MaxNewSize=2g       # Young Generation 최대 크기
-XX:SurvivorRatio=8     # Eden:Survivor = 8:1:1 → Eden이 Young의 80%

GC 종류 선택

-XX:+UseSerialGC        # Serial GC
-XX:+UseParallelGC      # Parallel GC
-XX:+UseG1GC            # G1 GC (Java 9+ 기본)
-XX:+UseZGC             # ZGC (Java 15+)
-XX:+UseShenandoahGC    # Shenandoah

G1 GC 튜닝

-XX:MaxGCPauseMillis=200         # 목표 최대 STW 시간
-XX:G1HeapRegionSize=16m         # Region 크기
-XX:InitiatingHeapOccupancyPercent=45  # Concurrent Cycle 시작 임계값
-XX:G1ReservePercent=10          # 승격 실패 방지용 예비 힙 비율
-XX:ConcGCThreads=4              # 동시 GC 스레드 수

GC 로그 설정

# Java 9+ 권장 방식
-Xlog:gc*:file=gc.log:time,uptime,level,tags:filecount=5,filesize=20m

# 주요 태그
-Xlog:gc                    # 기본 GC 요약
-Xlog:gc*                   # 모든 GC 관련 로그
-Xlog:gc+heap               # 힙 사용량 포함
-Xlog:gc+age                # 객체 나이 분포

# Java 8 (구 방식)
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-Xloggc:/var/log/app/gc.log
-XX:+UseGCLogFileRotation
-XX:NumberOfGCLogFiles=5
-XX:GCLogFileSize=20m

8. GC 로그 분석

G1 GC 로그 읽기

[2026-05-01T10:00:01.000+0900][1234ms][info][gc] GC(42) Pause Young (Normal) (G1 Evacuation Pause) 512M->256M(2048M) 45.123ms

분석:
  GC(42)             : 42번째 GC
  Pause Young        : Minor GC (Young Generation 수집)
  Normal             : 정상 Young GC (Concurrent Cycle 아님)
  G1 Evacuation Pause: G1의 Young GC 명칭
  512M->256M(2048M)  : 힙 512MB → 256MB (전체 2048MB)
  45.123ms           : STW 시간 45ms
[gc] GC(43) Pause Young (Concurrent Start) ... 52.000ms
[gc] GC(43) Concurrent Mark Cycle
[gc] GC(43) Concurrent Mark Cycle 245.000ms
[gc] GC(44) Pause Remark ... 8.000ms
[gc] GC(45) Pause Cleanup ... 3.000ms
[gc] GC(45) Pause Young (Mixed) ... 62.000ms

분석:
  Concurrent Start   : Concurrent Marking Cycle 시작
  Mixed              : Young + 일부 Old Region 수집

ZGC 로그 읽기

[gc] GC(10) Garbage Collection (Warmup) 2048M(100%)->256M(12%)

[gc] GC(10) Pause Mark Start    0.021ms  ← STW (매우 짧음)
[gc] GC(10) Concurrent Mark    243.000ms ← 동시 실행
[gc] GC(10) Pause Mark End       0.018ms  ← STW
[gc] GC(10) Concurrent Process  12.000ms
[gc] GC(10) Pause Relocate Start 0.019ms  ← STW
[gc] GC(10) Concurrent Relocate 85.000ms  ← 동시 실행

GC 분석 도구

GCEasy (온라인):

https://gceasy.io
GC 로그 파일 업로드 → 자동 분석 → 권고사항 제공
주요 제공 정보:
  - GC 발생 빈도 및 패턴
  - STW 시간 분포
  - 힙 사용량 추이
  - 잠재적 메모리 누수 탐지

GCViewer (로컬 도구):

java -jar gcviewer-1.36.jar gc.log
# 타임라인 그래프, 통계 제공

JVM 내장 도구:

# JVM 실행 중 GC 정보 확인
jstat -gcutil <pid> 1000    # 1초 간격으로 GC 통계 출력
jstat -gc <pid> 1000        # 힙 크기 및 GC 횟수/시간
jcmd <pid> GC.run           # GC 강제 실행
jcmd <pid> VM.native_memory # 네이티브 메모리 사용량

9. Stop-the-World 최소화 전략

1. 객체 수명 단축

// 나쁜 예 — 불필요한 Long-lived 객체
public class Cache {
    private static final Map<String, byte[]> cache = new HashMap<>();
    // static Map에 byte[]를 계속 추가 → Old Generation 점유 증가
}

// 좋은 예 — 약한 참조 + 크기 제한
public class BoundedCache {
    private final Map<String, SoftReference<byte[]>> cache =
        new LinkedHashMap<>(100, 0.75f, true) {
            @Override
            protected boolean removeEldestEntry(Map.Entry e) {
                return size() > 100; // 최대 100개 유지
            }
        };
}

2. 힙 크기 적절히 설정

힙이 너무 작음:
  GC 빈도 증가 → Full GC 증가 → 전체 STW 시간 증가

힙이 너무 큼:
  GC 당 STW 시간 증가 (더 많은 객체 스캔)
  불필요한 메모리 낭비

권장: 실제 워킹셋의 2~3배 설정

3. 객체 풀링 (신중하게)

// 대용량 임시 버퍼는 풀링으로 GC 부담 감소
public class BufferPool {
    private final Queue<byte[]> pool = new ConcurrentLinkedQueue<>();
    private static final int BUFFER_SIZE = 64 * 1024; // 64KB

    public byte[] acquire() {
        byte[] buf = pool.poll();
        return buf != null ? buf : new byte[BUFFER_SIZE];
    }

    public void release(byte[] buf) {
        // 풀 크기 제한 (메모리 누수 방지)
        if (pool.size() < 100) {
            pool.offer(buf);
        }
    }
}

4. 대용량 배열 처리 주의

// Humongous 객체 발생 방지 (G1 GC 기준)
// G1 Region 크기(기본 자동 계산) > 객체 크기/2 이면 Humongous

// 나쁜 예 — 매 요청마다 큰 배열 생성
byte[] buffer = new byte[10 * 1024 * 1024]; // 10MB, Humongous!

// 좋은 예 — 청크 단위 처리
int chunkSize = 512 * 1024; // 512KB
for (int offset = 0; offset < totalSize; offset += chunkSize) {
    byte[] chunk = new byte[Math.min(chunkSize, totalSize - offset)];
    process(chunk, data, offset);
}

5. GC 친화적 자료구조

// int[] vs List<Integer> 비교
int[] primitiveArray = new int[1_000_000];     // 4MB, GC 부담 최소
List<Integer> boxedList = new ArrayList<>(1_000_000); // ~16MB + 객체 오버헤드

// 대량 기본 타입 데이터는 배열 또는 primitive 특화 컬렉션 사용
// (Eclipse Collections, Trove, Koloboke 등)

10. 메모리 누수 패턴

static 컬렉션

// 메모리 누수 — static Map에 무한정 추가
public class EventRegistry {
    // static Map은 애플리케이션 생명주기 동안 유지
    private static final Map<String, List<Object>> handlers = new HashMap<>();

    public static void register(String event, Object handler) {
        handlers.computeIfAbsent(event, k -> new ArrayList<>()).add(handler);
        // remove() 없으면 영원히 쌓임
    }
}

// 해결: 명시적 remove 또는 WeakHashMap 사용
private static final Map<String, List<Object>> handlers = new WeakHashMap<>();

리스너/콜백 미해제

// 메모리 누수 — 등록한 리스너를 해제하지 않음
public class MyService {
    public void start() {
        eventBus.subscribe("user.created", this::onUserCreated);
        // 서비스 종료 시 unsubscribe 안 하면
        // MyService 인스턴스가 GC 대상에서 제외됨
    }

    @PreDestroy
    public void stop() {
        eventBus.unsubscribe("user.created", this::onUserCreated); // 반드시 해제
    }
}

ThreadLocal 미정리

// 메모리 누수 — 스레드 풀에서 ThreadLocal 미정리
public class RequestFilter {
    private static final ThreadLocal<UserContext> CONTEXT = new ThreadLocal<>();

    public void doFilter(Request req, Response res, FilterChain chain) {
        CONTEXT.set(new UserContext(req.getUserId()));
        try {
            chain.doFilter(req, res);
        } finally {
            CONTEXT.remove(); // 반드시! 스레드 풀 스레드는 재사용되므로
        }
    }
}

클래스로더 누수

// 메모리 누수 — 동적 클래스로더와 static 참조
public class PluginLoader {
    // static 컬렉션이 동적으로 로드된 클래스를 참조
    private static final List<Class<?>> loadedClasses = new ArrayList<>();

    public void loadPlugin(URL[] urls) {
        URLClassLoader loader = new URLClassLoader(urls);
        Class<?> pluginClass = loader.loadClass("com.example.Plugin");
        loadedClasses.add(pluginClass); // 클래스로더가 GC 불가 → Metaspace 누수

        // 해결: 플러그인 언로드 시 loadedClasses에서 제거 + loader.close()
    }
}

내부 클래스 참조

// 메모리 누수 — 비정적 내부 클래스가 외부 클래스를 암묵적으로 참조
public class Outer {
    private final byte[] largeData = new byte[10 * 1024 * 1024]; // 10MB

    public Runnable createTask() {
        // 비정적 내부 클래스 → Outer 인스턴스에 대한 암묵적 참조 보유
        return new Runnable() {
            @Override
            public void run() {
                // largeData를 직접 사용하지 않아도 Outer가 GC 불가
            }
        };
    }
}

// 해결: static 내부 클래스 또는 람다 + 명시적 캡처
public Runnable createTask() {
    byte[] needed = this.largeData; // 필요한 것만 캡처
    return () -> process(needed);   // Outer 전체를 참조하지 않음
}

메모리 누수 진단 도구

# 힙 덤프 생성
jmap -dump:format=b,file=heap.hprof <pid>
# OOM 발생 시 자동 덤프
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/log/app/heap.hprof

# 힙 덤프 분석
# Eclipse MAT (Memory Analyzer Tool): https://eclipse.dev/mat/
# VisualVM: jvisualvm 명령

# 실시간 모니터링
jcmd <pid> VM.native_memory summary
jstat -gcutil <pid> 2000  # 2초 간격

11. 실무 GC 선택 가이드

워크로드별 추천

배치 처리 / 데이터 파이프라인 (처리량 최우선):
  → Parallel GC (-XX:+UseParallelGC)
  → 이유: CPU 최대 활용, STW 있어도 처리량이 중요

일반 웹 서비스 API (균형형, 힙 4GB~16GB):
  → G1 GC (-XX:+UseG1GC, Java 9+ 기본)
  → -XX:MaxGCPauseMillis=200 설정
  → 이유: 튜닝 옵션 풍부, 검증된 안정성

저지연 서비스 (실시간 거래, 게임 서버, 힙 8GB 이상):
  → ZGC (-XX:+UseZGC)
  → 이유: <1ms STW, 힙 크기 확장에도 지연 안정적

저지연 + 범용 (ZGC보다 CPU 효율 우선):
  → Shenandoah (-XX:+UseShenandoahGC)
  → 이유: ZGC와 유사하나 CPU 오버헤드 측면에서 다름

마이크로서비스 / 컨테이너 (소규모, 힙 512MB 미만):
  → Serial GC 또는 G1 GC
  → -XX:+UseSerialGC (초소형 컨테이너)
  → GraalVM Native Image (JVM GC 제거, 빠른 시작)

선택 플로우차트

애플리케이션 특성 분석
         │
         ▼
힙 크기가 1GB 미만?
  YES → Serial GC 또는 G1
  NO  ↓
         ▼
처리량이 최우선인가? (배치, 분석)
  YES → Parallel GC
  NO  ↓
         ▼
STW < 100ms가 필수인가?
  YES → ZGC 또는 Shenandoah
  NO  ↓
         ▼
→ G1 GC (기본 선택)
   -XX:MaxGCPauseMillis=200

컨테이너 환경 필수 설정

# Docker/Kubernetes 컨테이너에서 JVM 메모리 올바르게 인식
# Java 10+ 권장 설정
-XX:+UseContainerSupport          # 컨테이너 메모리 인식 (기본 활성화)
-XX:MaxRAMPercentage=75           # 컨테이너 메모리의 75%를 힙에 할당
-XX:InitialRAMPercentage=50       # 초기 힙 = 50%

# 예: 컨테이너 메모리 2GB → 최대 힙 1.5GB 자동 설정

프로파일링 기반 튜닝 절차

1. 기본 설정으로 부하 테스트 실행
   wrk / JMeter / k6 등으로 목표 TPS 부하 인가

2. GC 로그 수집
   -Xlog:gc*:file=gc.log:time,uptime:filecount=5,filesize=20m

3. GCEasy 또는 GCViewer로 분석
   확인 항목:
     - Full GC 발생 여부 (있으면 힙 크기 조정 또는 메모리 누수 의심)
     - Minor GC 빈도 (너무 잦으면 Young Generation 크기 증가)
     - Minor GC STW 시간 (목표치 초과 시 Survivor 비율 조정)
     - Old Generation 증가 추세 (지속 증가 시 메모리 누수 의심)

4. 파라미터 조정 → 재측정 반복

5. 안정화 후 운영 배포

정리

GC 선택 요약

┌──────────────────────────────────────────────────────────────┐
│  처리량 최우선   │  Parallel GC  │ -XX:+UseParallelGC        │
│  범용 균형형     │  G1 GC        │ -XX:+UseG1GC (기본값)     │
│  저지연 필수     │  ZGC          │ -XX:+UseZGC               │
│  저지연 대안     │  Shenandoah   │ -XX:+UseShenandoahGC      │
│  소규모/임베디드 │  Serial GC    │ -XX:+UseSerialGC          │
└──────────────────────────────────────────────────────────────┘

GC 튜닝 3원칙:
  1. 측정 먼저 — GC 로그 없이 튜닝하지 않는다
  2. 한 번에 하나씩 — 파라미터 하나 변경 후 측정
  3. 힙 크기가 첫 번째 — Xmx 설정이 가장 큰 영향

메모리 누수 방지 체크리스트:
  □ static 컬렉션에 add() 후 remove() 쌍 확인
  □ 리스너/콜백 등록 후 해제 코드 확인
  □ ThreadLocal 사용 후 remove() 확인
  □ try-with-resources로 스트림/연결 자동 해제
  □ -XX:+HeapDumpOnOutOfMemoryError 항상 활성화

카테고리:

업데이트: