🎯1주차 Unit 5.2 — Heap의 세대 구조

Psj·2026년 5월 8일

F-lab

목록 보기
38/239

🎯 Unit 5.2 — Heap의 세대 구조 ★★★

F-lab Java 1주차 / Phase 5 / Unit 5.2 본격 학습 자료
9-섹션 마스터 프롬프트 형식으로 깊이 파헤친다.

선수 지식: Unit 5.1 (GC의 기본 개념과 약한 세대 가설)
다음 Unit: 5.3 — GC 알고리즘 4가지

이 Unit의 의미: 약한 세대 가설의 구체적 구현.
"Heap이 어떻게 나뉘어 있고, 객체가 어떤 일생을 사는가" 의 정확한 이해.
Minor GC vs Major GC vs Full GC 의 차이 — 면접 단골.


🌍 1. 세상 속 비유

Heap의 세대 구조 = "병원의 환자 관리"

큰 병원을 상상해보세요. 환자가 들어오면 어떻게 분류할까요?

1층 — 응급실 (Eden):

  • 새 환자가 처음 도착하는 곳
  • 매우 붐빔
  • 대부분 환자는 금방 퇴원 (Garbage)
  • 일부만 입원 필요

2층 — 일반 병동 (Survivor):

  • 응급실에서 살아남은 환자
  • 짧게 입원 후 대부분 퇴원
  • 가끔 장기 입원 필요한 사람만 다음 단계로

3층 — 장기 입원 병동 (Old):

  • 정말 오래 입원하는 환자
  • 거의 안 바뀜
  • 가끔만 큰 정리 (Major GC)

병원의 운영 원리:

  • 응급실은 자주 청소 (들고나는 사람 많음)
  • 일반 병동은 가끔 청소
  • 장기 병동은 거의 안 건드림

이게 자바 Heap의 세대 구조.


더 구체적인 비유 — "이사 시스템"

상황: 아파트가 3개 있다고 상상.

아파트 A (Eden):

  • 신입 거주자들 (새 객체)
  • 매우 빨리 차고 매우 빨리 비움

아파트 B & C (Survivor 0, Survivor 1):

  • A 에서 살아남은 사람들
  • 두 아파트가 번갈아 사용:
    • 짝수 회차: A + B → C 로 이사
    • 홀수 회차: A + C → B 로 이사
  • 일정 횟수 (약 15회) 살아남으면 → 다음 단계

아파트 D (Old):

  • 정말 오래 살 사람들
  • 거의 안 바뀜
  • 가끔만 큰 정리

왜 Survivor가 2개?:

  • 한쪽은 항상 비어있음 (다음 이사 받을 준비)
  • 다른 쪽은 현재 거주
  • → 매번 압축 없이 깔끔하게 이사

이게 Eden + Survivor 0/1 의 운영 원리.


핵심 한 문장

"자바 Heap은 객체의 수명에 따라 영역을 나누고, 다른 GC 전략을 적용한다."

3가지 핵심 영역:

  • Eden: 새 객체의 보금자리
  • Survivor (From/To): 1차 생존자의 임시 거처
  • Old: 장수 객체의 안식처

비유 정리:

비유 요소Heap 적용
응급실Eden
일반 병동 (2개)Survivor 0, Survivor 1
장기 입원 병동Old Generation
응급실 청소Minor GC
장기 병동 청소Major GC
전체 청소Full GC

🔥 2. 탄생 배경

왜 Heap을 나눠야 했나?

Java 초기 (1.0~1.4) 의 GC 는 Heap 전체를 한 번에 청소했습니다.

문제:

  • Heap이 1GB → 1GB 전체 스캔
  • 수백 ms ~ 수 초의 STW
  • 사용자 응답 X

원인 분석:

  • 사실 대부분 객체는 Young (방금 만든 것)
  • 오래된 객체는 거의 변하지 않음
  • 같은 비율로 청소하는 건 비효율

Generational GC 의 등장 (Java 5, 2004)

약한 세대 가설 (Unit 5.1) 활용:

"대부분 객체는 단명, 살아남은 것은 장수"

아이디어:

  • Heap 을 세대 별로 분리
  • Young → 자주 GC, 빠름
  • Old → 가끔 GC, 큰 정리

실측 효과:

  • Heap 1GB 에서도 평균 STW 10ms 이하
  • Throughput 2~3배 향상

Eden + Survivor 의 탄생

처음에는 Young Generation 이 단순했습니다:

  • Young 가득 → 살아있는 객체를 Old 로 복사

문제:

  • 너무 많은 객체가 Old 로 이동
  • Old 가 빨리 가득 → Major GC 자주
  • 비효율

해결 — Survivor 영역 ⭐ :

  • Eden 에서 살아남았다고 바로 Old 로 보내지 말고
  • Survivor 에 임시 거주
  • 일정 기간 더 살아남으면 그때 Old 로
Eden ──→ Survivor (몇 회) ──→ Old
       (필터링)

효과:

  • 진짜 장수 객체만 Old 로
  • Old 가 천천히 참
  • Major GC 빈도 ↓

두 개의 Survivor — Copying GC ⭐

왜 Survivor 가 2개?

: Copying GC 를 활용하기 위해.

Copying GC:

  • 한 영역에서 다른 영역으로 살아있는 객체만 복사
  • 원본 영역은 통째로 비움
  • 단편화 (Fragmentation) 자동 해소

Survivor 0 (From) ↔ Survivor 1 (To) 운영:

Round 1:
  [Eden + Survivor 0] → Survivor 1
                        (살아남은 객체 복사)
  Eden 비움, Survivor 0 비움

Round 2:
  [Eden + Survivor 1] → Survivor 0
                        (역방향)
  Eden 비움, Survivor 1 비움

... 반복 ...

핵심:

  • 한 Survivor는 항상 비어있음
  • 매번 다른 쪽으로 복사
  • 자연스러운 압축 효과

이게 Eden + Survivor 0/1 의 정확한 운영 원리.


Card Table 의 정착

문제: Old → Young 참조가 있을 수 있음.

public class OldObject {
    private YoungObject young;  // Old → Young 참조
}

Minor GC 시 Young 만 보면 이 참조를 놓칠 수 있음.

해결 — Card Table (Unit 5.1 미리보기):

  • Old 영역을 카드 (512 byte) 로 나눔
  • Old → Young 참조 발생 시 dirty 마킹
  • Minor GC 시 dirty 카드만 추가 스캔

약한 세대 가설 덕분에 Old → Young 참조는 적음 → 효율적.


핵심 통찰

"Heap의 세대 구조는 약한 세대 가설을 정확히 반영한 정교한 시스템이다."

Eden + Survivor 0/1 + Old 로 분리, Copying GC 로 자동 압축, Card Table 로 세대 간 참조 추적, Promotion 으로 진짜 장수 객체 분리. 모든 결정이 "가설을 어떻게 효율적으로 활용할까" 의 답.

이 구조를 정확히 이해하면 GC 알고리즘 (Unit 5.3), GC 종류 (Unit 5.4), 운영 환경 GC 튜닝까지 자연스럽게 흡수.


💣 3. 없으면 생기는 문제

세대 구조를 모르면 GC 관련 문제 분석이 어렵습니다.

시나리오 1: Minor GC 가 왜 자주 발생?

운영 환경 GC 로그:

GC(0) Pause Young 25M->2M(64M) 5ms
GC(1) Pause Young 27M->3M(64M) 6ms
GC(2) Pause Young 25M->2M(64M) 5ms
... 1초마다 발생 ...

세대 구조 모르면:

  • "Minor GC 가 왜 이리 자주? 문제 아닌가?"
  • 무리하게 알고리즘 변경 시도

세대 구조 알면:

  • "Eden 이 작아서 빨리 참"
  • "Eden 키우면 Minor GC 빈도 ↓"
  • -Xmn4g (Young 영역 4GB)
  • 또는 G1 의 경우 -XX:NewRatio 조정

시나리오 2: Old 영역이 빠르게 참

GC 로그:
GC(0) Pause Young 25M->20M(64M) 5ms  ← Old로 많이 Promotion
GC(1) Pause Young 30M->25M(64M) 6ms
...
GC(50) Pause Full 60M->50M(64M) 200ms ← Full GC 발생

세대 구조 모르면:

  • "왜 Old 가 빨리 차지?"

세대 구조 알면:

  • "Survivor 가 작아서 객체가 너무 빨리 Promotion"
  • "또는 Tenuring Threshold 가 낮음"
  • → Survivor 크기 조정, threshold 조정

시나리오 3: Promotion Failure

GC 로그:
[GC pause (G1 Evacuation Pause) (young) (to-space exhausted)]

의미: Survivor 또는 Old 가 가득 차서 Promotion 실패.

세대 구조 모르면:

  • 의미 파악 불가

세대 구조 알면:

  • "Old가 가득 → Promotion 실패 → Full GC 트리거"
  • 메모리 누수 또는 Heap 부족 가능성
  • → 분석 가능

시나리오 4: 거대 객체의 처리

byte[] hugeArray = new byte[100 * 1024 * 1024];  // 100MB

세대 구조 모르면:

  • "어디 갈까?"

세대 구조 알면:

  • Eden에 안 들어감 (보통 Eden 수십 MB)
  • Humongous Object (G1) 또는 직접 Old 할당
  • → 별도 처리 필요

시나리오 5: 면접 단골 질문

"객체의 일생을 설명해주세요"
"Survivor 가 2개인 이유?"
"Promotion 이 뭔가요?"

답 못함:

  • "음... Eden, Survivor, Old 정도?"
  • → 시니어 자격 의심

잘 답함:

  • 정확한 일생 단계, Copying GC 와 Survivor 의 관계, Tenuring Threshold 등
  • → 시니어 후보 인식

시나리오 6: ILIC 운영 환경 메모리 분석

ILIC 운영 환경:
- Heap: 4GB
- 평균 응답: 50ms
- 가끔 응답: 500ms ~ 1s

분석:
GC 로그 → Major GC 가 가끔 발생 (200~800ms)

세대 구조 모르면:

  • "GC 가 느려서..." 정도
  • 해결책 없음

세대 구조 알면:

  • Old 영역에 객체가 천천히 쌓임
  • Old 가득 시 Major GC + STW
  • → Heap 늘리거나, ZGC 같은 저지연 GC 도입
  • 근본 해결

세대 구조 이해의 중요성

시나리오세대 모르면세대 알면
Minor GC 빈도막막함Eden 크기 조정
Old 빨리 참원인 모름Survivor / Threshold 분석
Promotion Failure메시지 못 읽음정확한 진단
거대 객체처리 모름Humongous 인지
면접탈락시니어 인식
운영 분석표면적근본 해결

세대 구조는 자바 운영의 필수.


✅ 4. 해결책 — 세대 구조의 정확한 이해

Heap 전체 구조 ⭐

┌─────────────────────────────────────────┐
│              Heap                        │
│                                          │
│  ┌──────────────────────────────────┐   │
│  │     Young Generation (작음)       │   │
│  │                                   │   │
│  │  ┌────────────┐                   │   │
│  │  │   Eden     │  (보통 8/10)      │   │
│  │  └────────────┘                   │   │
│  │  ┌────┐  ┌────┐                   │   │
│  │  │ S0 │  │ S1 │  (각 1/10)        │   │
│  │  └────┘  └────┘                   │   │
│  └──────────────────────────────────┘   │
│                                          │
│  ┌──────────────────────────────────┐   │
│  │     Old Generation (큼)            │   │
│  │     (장수 객체)                     │   │
│  └──────────────────────────────────┘   │
└─────────────────────────────────────────┘

기본 비율 (HotSpot JVM):

  • Young : Old = 1 : 2 (Young 1/3)
  • Eden : S0 : S1 = 8 : 1 : 1 (Eden 80%)

JVM 옵션으로 조정:

-Xms2g -Xmx2g           # 전체 Heap 2GB
-Xmn800m                # Young 800MB (또는 -XX:NewSize)
-XX:NewRatio=2          # Young : Old = 1 : 2 (기본)
-XX:SurvivorRatio=8     # Eden : S = 8 : 1 (기본)

객체의 일생 ⭐⭐⭐ (가장 중요)

객체가 만들어지고 사라지는 전체 흐름:

단계 1: 출생 — Eden에 할당

Customer alice = new Customer("Alice");
//                ↑ Eden 에 할당

메모리:

[Eden]
  Customer ("Alice")  ← 새 객체 위치
[S0] (비어있음)
[S1] (비어있음)
[Old] (다른 객체들)

단계 2: Eden 가득 → Minor GC

Eden 이 가득 차면 Minor GC 트리거:

Minor GC 시작:
1. Eden + S0 (또는 S1) 의 살아있는 객체 식별
2. S1 (또는 S0) 으로 복사
3. Eden, 원본 Survivor 비움

예시:

Before Minor GC:
[Eden] Object A (Live), Object B (Garbage), Object C (Live)
[S0]   (비어있음)
[S1]   (비어있음)

After Minor GC:
[Eden] (비어있음)
[S0]   (비어있음)
[S1]   Object A (age=1), Object C (age=1)  ← 1차 생존

Object B 는 Garbage → 사라짐.


단계 3: From ↔ To 스위칭

다음 Minor GC:

Before:
[Eden] Object D, Object E, Object F (모두 Live)
[S0]   (비어있음)
[S1]   Object A (age=1), Object C (age=1)

GC 진행:
[Eden + S1] → S0 으로 복사 (방향 바뀜!)

After:
[Eden] (비어있음)
[S0]   Object A (age=2), Object C (age=2), 
       Object D (age=1), Object E (age=1), Object F (age=1)
[S1]   (비어있음)

핵심:

  • 살아남은 객체의 age 1 증가
  • 항상 한쪽 Survivor는 비어있음

단계 4: Promotion — Old로 이주 ⭐

객체의 age 가 Tenuring Threshold (기본 15) 도달:

Object A 가 15회 살아남음:
[S0]   Object A (age=15) ← Threshold 도달!
        ↓ Promotion
[Old]  Object A
[S0]   비움

Tenuring Threshold:

  • 기본값: 15
  • JVM 옵션: -XX:MaxTenuringThreshold=15
  • 동적으로 조정될 수도 있음

단계 5: Old 가득 → Major GC (Full GC)

Old 영역이 가득 차면:

Major GC (또는 Full GC):
1. Old 의 살아있는 객체 식별
2. Mark-Sweep-Compact 진행
3. 단편화 해소

효과:

  • 큰 STW (수백 ms ~ 수 초)
  • 사용자 응답 영향
  • 최대한 피해야 할 GC

전체 일생 그림 ⭐

[탄생]
   ↓
Eden ──Minor GC──→ Survivor (age 1)
                       ↓ Minor GC
                   Survivor (age 2)
                       ↓ ... (15회)
                       ↓
                       └──Promotion──→ Old
                                          ↓ Major GC
                                       (수거)

3가지 GC 비교 ⭐⭐ (면접 단골)

Minor GC

대상: Young Generation (Eden + Survivor)

빈도: 자주 (수 초마다)

시간: 빠름 (보통 ~10ms)

STW: 짧음

알고리즘: Copying GC

관찰 가능:

GC(0) Pause Young 25M->2M(64M) 5ms

Major GC

대상: Old Generation 만

빈도: 가끔 (분~시간 단위)

시간: 느림 (수백 ms ~ 수 초)

STW: 김 (또는 Concurrent)

알고리즘: Mark-Sweep-Compact (또는 Concurrent)

관찰 가능:

GC(50) Pause Old 100M->80M(150M) 500ms

Full GC

대상: Heap 전체 (Young + Old + Metaspace)

빈도: 매우 가끔 (또는 의도적 트리거)

시간: 매우 느림 (초 단위)

STW: 매우 김

원인:

  • Old 가득 + Promotion 실패
  • System.gc() 호출
  • Metaspace 부족

경고: Full GC 가 자주 발생 = 메모리 문제 ⚠️


비교 표 ⭐

Minor GCMajor GCFull GC
대상YoungOld전체
빈도자주가끔매우 가끔
시간빠름 (~ms)느림 (~수백 ms)매우 느림 (~초)
STW짧음매우 김
알고리즘CopyingMark-Sweep-Compact다양
발생 트리거Eden 가득Old 가득다양

Survivor가 2개인 이유 ⭐ (자기 점검 Q1)

1개라면?:

  • Survivor 안에서 자체 GC 어려움
  • 단편화 발생

2개로 운영:

  • 한쪽은 항상 비어있음 (To)
  • 다른 쪽이 현재 사용 (From)
  • Minor GC 시 [Eden + From] → [To]
  • 다음에는 역할 바뀜

효과:

  • 자동 압축 (Copying GC)
  • 단편화 없음
  • 간단한 알고리즘

Q1 답: Copying GC 활용 + 단편화 자동 해소.


거대 객체 처리 ⭐ (자기 점검 Q2)

거대 객체 = Survivor 영역보다 큰 객체.

문제:

  • Survivor 에 안 들어감
  • 매 Minor GC 마다 복사 비용 큼

해결:

1. 직접 Old 할당

  • 큰 객체는 Eden 도 거치지 않고 바로 Old 로
  • JVM 옵션: -XX:PretenureSizeThreshold=N

2. G1 의 Humongous Object

  • G1 GC 는 Region 기반
  • Region 의 절반 이상 차지하면 Humongous
  • 별도 영역에서 관리
[G1 Heap]
  ├── Eden Region
  ├── Survivor Region
  ├── Old Region
  └── Humongous Region  ← 큰 객체

Q2 답: 직접 Old 할당 또는 Humongous 영역.


🏗️ 5. 내부 동작 원리

Minor GC 의 자세한 흐름

// 시작 상태:
// Eden:  [A][B][C][D][E]  (모두 새 객체, B는 Garbage)
// S0:    (비어있음)
// S1:    (비어있음)
// Old:   [oldX][oldY]

Step 1: GC 트리거

  • Eden 가득 → Minor GC 시작
  • STW 시작

Step 2: GC Root 식별

  • Stack 의 지역변수
  • Old 의 객체 (Card Table 의 dirty 카드)
  • static 필드

Step 3: 마킹 (Reachability 분석)

  • Live: A, C, D, E
  • Garbage: B

Step 4: Copying — 살아있는 객체를 S1 로

After:
Eden: (비어있음)
S0:   (비어있음)
S1:   [A age=1][C age=1][D age=1][E age=1]
Old:  [oldX][oldY]

Step 5: STW 종료

  • Application Thread 재개

Step 6: 다음 GC

  • Eden 다시 가득 차면
  • 이번엔 [Eden + S1] → S0
  • 매번 방향이 바뀜

Promotion의 정확한 메커니즘 ⭐

// Object A: age=14
// Tenuring Threshold = 15

// Minor GC 발생:
// A 가 살아남음 → age=15
// → Threshold 도달!
// → Old 로 Promotion

[Survivor]
  [A age=15] → 사라짐
[Old]
  [oldX][oldY][A] ← 새로 들어옴

Adaptive Tenuring:

  • JVM 이 Survivor 사용량을 보고 동적 조정
  • Survivor 가 50% 넘으면 Threshold 낮춤
  • 빠른 Promotion
# 조정 옵션
-XX:MaxTenuringThreshold=15        # 최대 15회
-XX:TargetSurvivorRatio=50         # Survivor 목표 50%

Card Table의 동작 ⭐ (재확인)

문제: Old → Young 참조가 있으면 Minor GC 시 Old 도 봐야 함.

예시:

public class FareCache {
    private static List<Fare> cache = new ArrayList<>();  // Old (long-lived)
    
    public void add(Fare fare) {  // fare 는 Young
        cache.add(fare);
        // Old 객체 cache 가 Young 객체 fare 참조
    }
}

Card Table:

  • Old 영역을 512 byte 카드 로 나눔
  • 1GB Old → 약 200만 카드
  • 각 카드 1 byte → 2MB Card Table

dirty 마킹:

// cache.add(fare) 실행 시 — Write Barrier 발생
//   (JVM 이 자동 처리)

// JVM:
// "cache 객체가 fare (Young) 참조 추가"
// → cache 가 있는 카드를 dirty 마킹

Minor GC 시:
1. Stack, static 의 GC Root 스캔
2. dirty 카드만 Old 에서 추가 스캔
3. 모든 Live 객체 마킹

효과:

  • Old 전체 (수 GB) 스캔 회피
  • dirty 카드만 (수 MB) 스캔
  • Minor GC 빠름 유지

Write Barrier ⭐

Write Barrier = 객체 참조 변경 시 JVM 이 자동 실행하는 코드.

역할:

  • Old → Young 참조 발생 시 Card Table dirty 마킹
  • G1 의 Region 간 참조 추적
  • Concurrent GC의 일관성 유지

비용:

  • 매 참조 변경마다 약간의 오버헤드
  • 그러나 Card Table 의 효과로 충분히 가치

영역별 객체 수 패턴

전형적인 자바 애플리케이션:

Eden:  매우 많은 객체 (수만~수십만)
       대부분 곧 Garbage

S0/S1: 적은 객체 (수백~수천)
       Eden 의 1~10%

Old:   적당한 객체 (수천~수만)
       살아남은 것들

객체 수의 분포:

  • Eden: 80%
  • Survivor: 5%
  • Old: 15%

약한 세대 가설 의 실제 모습.


Region-Based Heap (G1 GC)

기존 세대 구조의 진화 — G1 GCRegion 기반:

[Heap]
├── Region 0 (Eden)
├── Region 1 (Eden)
├── Region 2 (Survivor)
├── Region 3 (Old)
├── Region 4 (Humongous)
├── Region 5 (Free)
├── Region 6 (Eden)
├── Region 7 (Old)
└── ... (수백 개 Region)

특징:

  • Region 마다 역할 동적 변경
  • 수백 개 Region 으로 분할 (보통 1~32MB)
  • Garbage가 많은 Region 부터 GC
  • → 더 효율적인 메모리 활용

Unit 5.4 에서 자세히.


💻 6. 실전 코드 예시

예시 1: 객체의 일생 관찰

public class GenerationDemo {
    public static void main(String[] args) {
        Runtime runtime = Runtime.getRuntime();
        
        for (int i = 0; i < 1000; i++) {
            // 새 객체 — Eden 에
            byte[] data = new byte[1024 * 100];  // 100KB
            
            if (i % 100 == 0) {
                System.out.printf("Iteration %d, Free memory: %d MB%n",
                    i, runtime.freeMemory() / (1024 * 1024));
            }
            
            // i % 100 == 0 일 때만 살려둠 (다른 컬렉션에 참조)
            if (i % 100 == 0) {
                staticList.add(data);  // Old 로 가게 만듦
            }
        }
    }
    
    private static List<byte[]> staticList = new ArrayList<>();  // GC Root
}

JVM 옵션 추가:

java -Xmx256m -Xms256m \
     -Xlog:gc*:stdout:time,uptime,level,tags \
     GenerationDemo

관찰:

  • Minor GC 자주 발생 (Eden 가득)
  • 일부 객체는 Survivor → Old 이동
  • 운영 시 Old 가 점점 차는 모습

예시 2: GC 로그 분석

java -Xms512m -Xmx512m \
     -Xlog:gc*:file=gc.log:time,uptime,level,tags \
     YourApp

로그 예시:

[0.150s] GC(0) Pause Young (G1 Evacuation Pause)
         15M->5M(512M) 8.123ms
         
[1.234s] GC(1) Pause Young (G1 Evacuation Pause)
         15M->6M(512M) 7.456ms

[5.678s] GC(2) Pause Young (G1 Mixed GC)  ← Old도 일부 GC
         50M->10M(512M) 25.123ms

[60.123s] GC(3) Pause Full (Allocation Failure)
          200M->50M(512M) 350.456ms  ← Full GC! ⚠️

해석:

  • Minor GC: 빠름 (~10ms)
  • Mixed GC: G1의 Young + Old 부분
  • Full GC: 위험 신호 — 분석 필요

예시 3: ILIC 운임 처리의 GC 영향

@Service
public class FareReportService {
    
    // ❌ 메모리 폭발 가능
    public byte[] generateAllFaresReport() {
        List<Fare> all = repository.findAll();  // 100만 건?
        // → Eden 폭발 → Survivor 거쳐 Old 까지
        // → Heap 가득 → OOM 또는 Full GC
        
        return generatePdf(all);
    }
    
    // ✅ Streaming
    public void generateReportStream(OutputStream out) {
        try (Stream<Fare> stream = repository.findAllAsStream()) {
            stream.forEach(fare -> {
                appendToPdf(out, fare);
                // fare 는 사용 후 Eden 의 Garbage 가 됨
                // → Minor GC 로 빠르게 회수
            });
        }
        // 메모리 소비 거의 없음
    }
}

GC 측면:

  • Streaming → 거의 모든 객체가 Young 에서 죽음
  • 약한 세대 가설의 모범 사례
  • Minor GC 만으로 처리

예시 4: Promotion 빠르게 — 위험 패턴

@Component
public class FareCache {
    private final Map<Long, Fare> cache = new ConcurrentHashMap<>();
    
    @Scheduled(fixedRate = 1000)  // 1초마다
    public void refresh() {
        List<Fare> recent = repository.findRecent();
        recent.forEach(f -> cache.put(f.getId(), f));
        // ⚠️ Fare 들이 곧바로 Old 로 Promotion
        // → cache 가 GC Root 이기 때문
        // → Old 영역 빠르게 참
        // → Major GC 빈번
    }
}

문제:

  • cache 는 long-lived (Spring Bean)
  • cache.put() → fare 가 cache 에서 참조됨
  • → fare 가 Survivor 거쳐 Old 로
  • → Old 영역 압박

개선:

@Component
public class FareCache {
    private final Cache<Long, Fare> cache = 
        Caffeine.newBuilder()
            .expireAfterWrite(Duration.ofMinutes(5))  // 만료
            .maximumSize(1000)                          // 크기 제한
            .build();
    // → 적절한 크기 유지
    // → Old 영역 안정
}

예시 5: Survivor 크기 모니터링

# JVM 옵션
java -XX:+PrintTenuringDistribution \
     -Xlog:gc*:stdout \
     YourApp

출력:

Desired survivor size 524288 bytes, new threshold 7 (max 15)
- age 1:    245664 bytes,    245664 total
- age 2:    156784 bytes,    402448 total
- age 3:     89456 bytes,    491904 total
- age 4:     45120 bytes,    537024 total  ← 초과!

해석:

  • "Desired survivor size 512KB"
  • age 4 까지 누적 537KB → 초과
  • → Adaptive Tenuring 으로 Threshold 낮아짐 (15 → 7)
  • → 빠른 Promotion

→ Old 영역 압박 신호.


예시 6: 메모리 누수 패턴

// ❌ static 컬렉션 — Old 에 무한 누적
public class FareLogger {
    private static List<String> logs = new ArrayList<>();
    
    public static void log(String message) {
        logs.add(LocalDateTime.now() + " - " + message);
        // logs 가 무한 증가
    }
}

GC 관점에서의 문제:

  • logs 는 static → GC Root
  • 추가된 String 들이 Old 로 계속 Promotion
  • → Old 가득 → Full GC 시도
  • → 그러나 logs 자체가 Live → 회수 불가
  • 메모리 누수

해결:

public class FareLogger {
    private static final int MAX_SIZE = 1000;
    private static final Deque<String> logs = new ArrayDeque<>(MAX_SIZE);
    
    public static synchronized void log(String message) {
        if (logs.size() >= MAX_SIZE) {
            logs.pollFirst();  // 오래된 것 제거
        }
        logs.addLast(LocalDateTime.now() + " - " + message);
    }
}

예시 7: ILIC 의 GC 친화적 패턴

@Service
public class FareCalculator {
    
    // ✅ 짧은 일생의 객체만
    public Money calculate(Fare fare, Customer customer) {
        // 모두 메서드 안에서만 사용 → Eden 에서 죽음
        Money base = Money.of(fare.getAmount());
        Money discount = base.multiply(customer.getDiscountRate())
                              .divide(100);
        Money tax = base.subtract(discount).multiply(0.1);
        return base.subtract(discount).add(tax);
    }
}

GC 패턴:

  • Money 객체들이 즉시 사용 후 버려짐
  • Eden → Survivor 도 안 가고 곧바로 Garbage
  • Minor GC 로 빠른 회수
  • Old 영역 영향 X

약한 세대 가설 의 모범 사례.


⚠️ 7. 주의사항 & 흔한 실수

실수 1: "Eden 만 알고 Survivor 모름"

Q: "객체의 일생?"
A: "Eden → Old 입니다" ❌

정답: Eden → Survivor (여러 회) → Old

Survivor 는 필터링 역할. 진짜 장수 객체만 Old 로.


실수 2: "Survivor 가 1개"

Q: "Survivor 가 몇 개?"
A: "1개요" ❌

정답: 2개 (S0, S1 또는 From, To)

Copying GC 위해 둘이 번갈아 사용.


실수 3: Major GC 와 Full GC 혼동

Q: "Major GC 와 Full GC 차이?"
A: "같은 거 아닌가요?" ❌

정답:

  • Major GC: Old 만
  • Full GC: 전체 (Young + Old + Metaspace)

자주 혼용되지만 엄밀히는 다름.


실수 4: Tenuring Threshold 가 항상 15

Q: "객체가 Old 로 가는 조건?"
A: "15회 살아남으면" ❌ (부정확)

정답:

  • MaxTenuringThreshold = 15 (기본)
  • 그러나 Adaptive Tenuring 으로 동적 조정
  • Survivor 가 가득 차면 Threshold 낮아짐
  • 7~15 범위에서 변동

실수 5: "Eden 에 무한 객체 만들 수 있음"

for (int i = 0; i < Integer.MAX_VALUE; i++) {
    Fare fare = new Fare();
    fares.add(fare);  // ⚠️ 누적
}

문제:

  • fares 가 살아있어 fare 들이 Old 로 Promotion
  • Old 가득 → Full GC → OOM

해결: 처리 후 참조 끊기, Stream 사용 등.


실수 6: Survivor 크기 무시

java -Xmx2g -XX:NewSize=1g  # Young 1GB
                              # 그런데 SurvivorRatio 는 기본 8
                              # → Eden 800MB, S0/S1 각 100MB

가능한 문제:

  • Survivor 100MB 가 부족할 수 있음
  • → 빠른 Promotion → Old 압박

튜닝 시 고려:

-XX:SurvivorRatio=8  # Eden:S0:S1 = 8:1:1 (기본)
-XX:SurvivorRatio=4  # Eden:S0:S1 = 4:1:1 (Survivor 더 큼)

실수 7: Full GC 가 정상이라고 생각

운영: Full GC 가 매 분마다 발생
개발자: "GC 잘 돌고 있네"

진실: Full GC 는 위험 신호 ⚠️

정상:

  • Minor GC: 자주 (수 초마다 OK)
  • Major GC: 가끔 (분~시간)
  • Full GC: 매우 가끔 (시간~일 단위)

매 분 Full GC = 메모리 누수 가능성 높음.


🔗 8. 연관 개념 맵

Phase 5 (GC) 내 흐름

[Unit 5.1: GC 기본 + 약한 세대 가설] ✓
        ↓
[Unit 5.2: Heap 의 세대 구조] ← 지금 여기 ★
        ↓
[Unit 5.3: GC 알고리즘 4가지]
        ↓
[Unit 5.4: GC 종류와 선택 기준]

Phase 4 와의 통합

학습세대 구조 관점
Unit 4.1 (Heap)세대 구조의 무대
Unit 4.2 (Pass by Value)객체 참조 → 세대 추적
Unit 5.1 (약한 세대 가설)세대 구조의 이유

Phase 4-5 가 자연스럽게 연결.


미래 주차와의 연결

5주차 (Spring DI):

  • Bean 의 Singleton → Old Generation
  • Prototype → Young 또는 Old

11-12주차 (JPA):

  • 영속성 컨텍스트 → Old (트랜잭션 내)
  • 1차 캐시 → 트랜잭션 종료 시 GC

13-14주차 (DB/Cache):

  • Redis vs JVM 캐시 — Heap 영향 차이
  • Caffeine 같은 만료 캐시

4주차 (동시성):

  • 멀티스레드 + GC 의 상호작용

JVM 옵션 (튜닝 시 사용) ⭐

# Heap 크기
-Xms2g -Xmx2g                     # 초기/최대 Heap

# Young 크기
-Xmn800m                          # Young 800MB
-XX:NewRatio=2                    # Young : Old = 1 : 2
-XX:NewSize=400m                  # Young 초기
-XX:MaxNewSize=800m               # Young 최대

# Survivor 비율
-XX:SurvivorRatio=8               # Eden : S = 8 : 1 (기본)

# Promotion
-XX:MaxTenuringThreshold=15       # 최대 15회
-XX:TargetSurvivorRatio=50        # Survivor 목표 50%

# 거대 객체
-XX:PretenureSizeThreshold=1m     # 1MB 이상은 직접 Old

# 모니터링
-XX:+PrintTenuringDistribution    # 세대별 분포 출력
-Xlog:gc*                         # GC 로그

면접 단골 질문 매핑

질문이 Unit에서의 답
"객체의 일생?"Eden → Survivor (15회) → Old
"Survivor 가 2개인 이유?"Copying GC + 단편화 해소
"Minor vs Major vs Full GC?"대상 영역과 빈도/시간 차이
"Promotion 이 뭔가요?"Tenuring Threshold 도달 시 Old 이동
"거대 객체 처리?"직접 Old 또는 Humongous (G1)
"Card Table?"Old → Young 참조 추적

📝 9. 핵심 요약 — 3줄 정리

1️⃣ Heap은 Young (Eden + S0 + S1) 과 Old 로 나뉜다.

약한 세대 가설을 활용해 객체의 수명에 따라 영역 분리. Young 비율 보통 1/3, Eden:S0:S1 = 8:1:1. Eden 에 새 객체 → Minor GC 시 살아남으면 Survivor → 일정 횟수 살아남으면 Old 로 Promotion. 이 구조가 약한 세대 가설의 정확한 구현.

2️⃣ Survivor가 2개인 것은 Copying GC 를 위함이다.

한쪽 (From) 은 사용, 다른 쪽 (To) 은 비어있음. Minor GC 시 [Eden + From] 의 살아있는 객체를 [To] 로 복사 후 원본 비움. 다음에는 역할 바뀜. 자동 압축 효과 + 단편화 해소. 객체의 age 가 MaxTenuringThreshold (기본 15) 도달 시 Old 로 Promotion.

3️⃣ Minor GC vs Major GC vs Full GC 를 정확히 구별해야 한다.

Minor GC = Young 만 (자주, 빠름, ~ms), Major GC = Old 만 (가끔, 느림, ~수백 ms), Full GC = 전체 (매우 가끔, 매우 느림, ~초). Full GC 가 자주 발생 = 메모리 누수 신호 ⚠️. Card Table 로 Old → Young 참조를 추적해 Minor GC 효율 유지. 거대 객체는 직접 Old 할당 또는 Humongous (G1).


🎓 학습 자기 점검

기본 이해

  • Heap의 세대 구조를 그릴 수 있다 (Eden, S0, S1, Old)
  • 객체의 일생 5단계를 설명할 수 있다
  • Minor/Major/Full GC 의 차이를 표로 그릴 수 있다
  • Tenuring Threshold 의 의미를 안다

실전 적용

  • ILIC 코드의 객체가 어느 영역에 갈지 예측할 수 있다
  • GC 로그를 보고 Minor/Major/Full GC 를 구별할 수 있다
  • Survivor 크기 모니터링을 활성화할 수 있다
  • static 컬렉션의 Old 영역 누적 위험을 안다

면접 대비 (3-5분 답변)

  • "객체의 일생?" 답변 가능
  • "Survivor 가 2개인 이유?" 답변 가능
  • "Minor vs Major vs Full GC?" 답변 가능
  • "Promotion 이 뭔가요?" 답변 가능

자기 점검 질문 답변

Q1: Survivor 가 두 개 (From, To) 로 나뉘어 있는 이유는?

한 줄 답: Copying GC 를 활용하기 위해. 한쪽은 항상 비어있어 살아있는 객체를 그쪽으로 복사하면 자동 압축 + 단편화 해소 효과.

상세 설명:

1개 Survivor 의 문제

만약 Survivor 가 1개라면:

[Eden] [Survivor]
  새 객체   살아남은 객체

Minor GC:
- Eden 의 살아있는 객체를 Survivor 로 복사?
- 그런데 Survivor 안에는 이미 객체들 있음
- → 충돌! 또는 복잡한 압축 로직 필요

문제:

  • Survivor 의 살아있는 객체와 Eden 의 살아있는 객체를 어떻게 합칠 것인가
  • 단편화 (빈 자리) 처리 어려움
  • 복잡한 GC 알고리즘 필요

2개 Survivor 의 우아한 해결

[Eden] [Survivor 0 (From)] [Survivor 1 (To)]
  새 객체   현재 거주              비어있음

Minor GC 시:
1. [Eden + Survivor 0] 의 살아있는 객체 식별
2. 모두 [Survivor 1] 로 복사
3. [Eden] 비움
4. [Survivor 0] 비움

After:
[Eden] [Survivor 0]      [Survivor 1]
  비움   비움             현재 거주

다음 Minor GC:
1. [Eden + Survivor 1] → [Survivor 0]
2. 역할 바뀜!

핵심 효과 ⭐ :

1. Copying GC 의 단순함

  • 한 영역 → 다른 영역으로 단순 복사
  • 살아있는 객체만 → 작업량 적음
  • "전체를 비움" → 단편화 X

2. 자동 압축 (Compact)

  • Copying 자체가 압축 효과
  • 별도 Compact 단계 불필요
  • 메모리 단편화 자연스럽게 해소

3. 빠른 GC

  • 살아있는 객체 적음 (대부분 단명)
  • 복사 비용 낮음
  • → Minor GC ~10ms

비유 — 두 개의 호텔 방

손님 (객체) 이 호텔에 머무는 상황:

1개 방만 있다면:

  • 청소 시 손님과 짐을 어떻게?
  • 청소하면서 동시 사용 어려움

2개 방 있다면 (자바 방식):

  • 방 A 에 손님들 (현재 거주)
  • 청소 시: 손님들을 방 B 로 옮김
  • 방 A 통째로 청소 (빈 방 청소는 쉬움)
  • 다음 청소 때: 방 B → 방 A

간단하고 효율적.


결론

"Survivor 가 2개인 것은 Copying GC 의 자연스러운 요구.
한 영역에서 다른 영역으로의 단순 복사가 가장 효율적이고,
자동 압축 효과 + 단편화 해소 라는 부수 효과까지."


Q2: 거대 객체 (Survivor 영역보다 큰) 는 어떻게 처리되는가?

한 줄 답: 직접 Old 영역에 할당 또는 Humongous Region (G1 GC).

상세 설명:

거대 객체의 정의

byte[] hugeArray = new byte[100 * 1024 * 1024];  // 100MB

거대 객체 = Survivor 영역보다 큰 객체.

왜 문제인가?:

1. Survivor 에 안 들어감

  • Survivor 가 50MB 인데 객체가 100MB
  • → 복사 불가

2. 매 Minor GC 마다 큰 비용

  • 만약 Eden 에 들어갔다면
  • Minor GC 시 Survivor 로 복사 시도 → 실패
  • 또는 복사하면 매우 느림

처리 방법 1: 직접 Old 할당 (Pretenuring)

JVM 옵션:

-XX:PretenureSizeThreshold=1m    # 1MB 이상은 직접 Old

동작:

byte[] huge = new byte[10 * 1024 * 1024];  // 10MB
// Eden 거치지 않고 → Old 직접 할당

효과:

  • Minor GC 영향 X
  • Survivor 복사 비용 X

단점:

  • Old 영역 빠르게 참
  • Major GC 빈도 ↑

처리 방법 2: G1 의 Humongous Object

G1 GC 는 Region 기반:

[Heap]
  ├── Region 0 (Eden, 1MB)
  ├── Region 1 (Eden, 1MB)
  ├── Region 2 (Survivor, 1MB)
  ├── Region 3 (Old, 1MB)
  ├── Region 4 (Humongous, 1MB)  ← 큰 객체
  └── ... 수백 개

Humongous Object 정의:

  • Region 의 절반 이상 차지하는 객체
  • 별도의 Humongous Region 에 배치
  • 연속된 여러 Region 차지 가능

예시:

Region 크기 1MB
객체 크기 3MB
→ Humongous Region 으로 3개 연속 사용

처리 방법 3: Region 크기 조정 (G1)

JVM 옵션:

-XX:G1HeapRegionSize=4m    # Region 4MB

효과:

  • 같은 객체가 Humongous 가 안 될 수도 있음
  • 일반 Old Region 으로 처리

ILIC 적용 시 고려

상황: 운임 보고서 PDF 생성

@Service
public class FareReportService {
    public byte[] generateMonthlyReport() {
        // 100MB PDF 생성?
        byte[] pdfData = generatePdf(...);  // 거대 객체
        return pdfData;
    }
}

문제:

  • pdfData 가 100MB
  • Eden 에 안 들어가거나, Survivor 거치며 큰 비용

해결:
1. Streaming:

public void streamReport(OutputStream out) {
    generatePdfStream(out);  // 메모리에 다 안 올림
}
  1. Pretenuring:

    • JVM 옵션 조정 → 큰 객체 직접 Old
  2. G1 + Humongous:

    • 자동으로 Humongous Region 에 배치

결론

"거대 객체는 일반 객체와 다른 경로로 처리.
Pretenuring (직접 Old) 또는 G1의 Humongous Region.
가능하면 Streaming 등으로 거대 객체 자체를 만들지 않는 것이 최선."


다음 Unit으로

  • GC 알고리즘 4가지 를 학습할 준비 완료
  • Mark-Sweep, Mark-Compact, Copying, Generational 의 차이가 궁금하다
  • Reference Counting 이 자바에 안 쓰이는 이유를 만날 준비 완료
















profile
Software Developer

0개의 댓글