F-LAB JAVA · 2주차 · Phase 4 · G1 GC 심화
🚀 Phase 4 시작 — 운영 환경 메모리 관리의 깊이로
이 Unit을 끝내면 다음을 답할 수 있어야 한다.
참조 카운팅은 가장 직관적이지만, "순환 참조"라는 단 하나의 결함 때문에 JVM이 채택하지 않았다.
대신 JVM은 Reachability Analysis — GC Roots에서 시작해 도달 가능한 객체만 살리는 방식을 쓴다.
이 선택이 G1 GC, ZGC 같은 현대 GC의 모든 진화의 출발점이다.
| 시스템 | 비유 |
|---|---|
| 참조 카운팅 | "이 신문을 구독하는 사람 수" 카운터 |
| Reachability | "독자 명단에서 출발해 추천 인맥 추적" |
신문 카운팅:
명단 추적:
→ JVM이 후자를 선택한 이유.
1. 1주차 GC 복습 + 더 깊이 들어가는 이유
2. 참조 카운팅 — 가장 직관적인 GC
3. 참조 카운팅의 매력 — 즉시 회수
4. 치명적 결함 — 순환 참조 (★ 핵심)
5. 그 외 단점들 — 갱신 비용, 멀티스레드
6. JVM의 대안 — Reachability Analysis
7. ILIC 실무 — 순환 참조 패턴과 회피
8. 흔한 실수 + 디버깅
9. 면접 질문 + 자기 점검
박승제씨가 1주차 Phase 5에서 학습한 GC:
1주차 GC 학습:
- GC가 왜 필요한가 (수동 메모리 관리의 위험)
- Young/Old Generation 분리
- Minor GC vs Major GC
- Mark-and-Sweep 알고리즘
- GC가 발동되면 STW(Stop-The-World)
- GC 종류 (Serial, Parallel, CMS, G1, ZGC) 개요
→ GC의 큰 그림.
운영에서 GC가 진짜 문제가 되는 순간:
🚨 운영 서버에서 갑자기 응답 지연
↓
GC 로그 확인
↓
Full GC가 5초씩 발생
↓
"왜?" 라는 질문
↓
GC 내부 동작 이해 필수
ILIC 같은 실서비스에서 GC는 시스템 안정성의 핵심.
운영 안정성을 책임지려면 1주차 개론으로 부족.
Phase 4 — G1 GC 심화
Unit 4.1 (이번) — 참조 카운팅의 한계
↓ 왜 JVM이 다른 길을 갔나
Unit 4.2 — G1 GC의 등장 배경
↓ 큰 힙 시대의 요구
Unit 4.3 — 리전(Region) 기반 레이아웃
↓ G1의 혁신
Unit 4.4 — 거대 리전(Humongous Region)
↓ 큰 객체 처리
Unit 4.5 — 우선순위 기반 회수
↓ "Garbage First" 의 의미
이번 Unit은 출발점.
"왜 단순한 참조 카운팅을 안 쓰고 복잡한 길을 갔나?"
각 객체에 참조 카운터(reference counter) 를 둠.
객체 생성: 카운터 = 0
참조 추가: 카운터 +1
참조 제거: 카운터 -1
카운터 = 0: 즉시 회수
<Shipment a = new Shipment(); // 카운터 = 1
Shipment b = a; // 카운터 = 2
b = null; // 카운터 = 1
a = null; // 카운터 = 0 → 즉시 회수
각 시점의 메모리:
Step 1: new Shipment() 직후
Heap: Shipment 객체 (RC=1)
↑
a
Step 2: Shipment b = a;
Heap: Shipment 객체 (RC=2)
↑ ↑
a b
Step 3: b = null;
Heap: Shipment 객체 (RC=1)
↑
a b(null)
Step 4: a = null;
Heap: Shipment 객체 (RC=0) → 즉시 회수!
↓
메모리 해제
a(null) b(null)
→ 단순하고 직관적.
→ 카운터를 매번 갱신만 하면 됨.
참조 카운팅을 사용하는 언어/플랫폼:
Python (CPython): 주 GC 메커니즘
Swift / Objective-C: ARC (Automatic Reference Counting)
PHP: 주 GC
Perl: 주 GC
Rust: Rc<T>, Arc<T> 명시적
C++ shared_ptr: 라이브러리 차원
JVM, V8 (Node.js), .NET 등 메이저 런타임:
"JVM은 왜 이 단순한 방식을 안 쓰는가?"
답: 순환 참조 + 갱신 비용 + 멀티스레드 의 3가지 문제.
이번 Unit에서 차례로 본다.
참조 카운팅:
객체가 참조 잃은 순간 → 즉시 메모리 해제
→ 예측 가능한 시점
→ STW 없음
→ 지연 짧음
JVM (Reachability):
객체가 참조 잃어도 → 다음 GC 사이클까지 살아있음
→ 언제 회수될지 알 수 없음
→ GC 사이클 시 STW 발생
→ 지연 발생
실시간 게임:
- 60 FPS 유지 (16ms마다 화면 갱신)
- GC가 5ms 멈추면 화면 끊김 발생
- → STW 없는 참조 카운팅 매력적
iOS 앱 (Swift/Objective-C):
- 사용자 인터랙션 매 순간 반응성 중요
- → Apple이 ARC 선택한 이유
참조 카운팅:
- 객체 회수 즉시
- 메모리 피크 낮게 유지
- 임베디드 시스템에 적합
Reachability:
- 객체 회수 지연
- 일시적으로 메모리 피크 높음
- 큰 메모리 환경 가정
위 장점들이 명확함에도 불구하고 JVM이 안 쓴 이유:
한 줄 답: 순환 참조의 메모리 누수가 치명적.
다음 섹션에서 본다.
class Shipment {
Cargo cargo;
}
class Cargo {
Shipment shipment;
}
void method() {
Shipment s = new Shipment();
Cargo c = new Cargo();
s.cargo = c; // s → c
c.shipment = s; // c → s (순환!)
}
// 메서드 종료 후
Step 1: new Shipment()
s_obj (RC=1) ← s
Step 2: new Cargo()
s_obj (RC=1) ← s
c_obj (RC=1) ← c
Step 3: s.cargo = c;
s_obj (RC=1) ← s
c_obj (RC=2) ← c, s.cargo
Step 4: c.shipment = s;
s_obj (RC=2) ← s, c.shipment
c_obj (RC=2) ← c, s.cargo
Step 5: method() 종료. s, c 지역변수 사라짐.
s_obj (RC=1) ← c.shipment만 남음
c_obj (RC=1) ← s.cargo만 남음
→ 둘 다 카운터가 0이 아님!
→ 회수 불가
→ 메모리 누수
메서드 안:
Stack: Heap:
┌──────┐ ┌──────────┐
│ s │ ──────────────────► s_obj │
└──────┘ │ RC=2 │
│ cargo ───┼──┐
┌──────┐ └──────────┘ │
│ c │ ────┐ │
└──────┘ │ ┌──────────┐ │
└─────────────► c_obj │ │
│ RC=2 │ │
│ shipment │◄─┘
└──────────┘
메서드 종료 후:
Stack: (비어있음)
Heap:
┌──────────┐
│ s_obj │
│ RC=1 │ ← c.shipment만 가리킴
│ cargo ───┼──┐
└──────────┘ │
│
┌──────────┐ │
│ c_obj │ │
│ RC=1 │◄─┘
│ shipment ┼──┐
└──────────┘ │
▲────────┘
│
이 둘은 서로만 가리킴
바깥에서 아무도 가리키지 않지만
RC=0이 안 되어서 영원히 메모리 차지
→ 메모리 누수. 참조 카운팅의 결정적 약점.
@Entity
public class Shipment {
@OneToMany(mappedBy = "shipment")
private List<Cargo> cargoes;
}
@Entity
public class Cargo {
@ManyToOne
private Shipment shipment;
}
JPA의 양방향 관계 → 사실상 순환 참조.
public class ShipmentEventBus {
private List<ShipmentListener> listeners = new ArrayList<>();
public void register(ShipmentListener l) {
listeners.add(l);
}
}
public class ShipmentService implements ShipmentListener {
private ShipmentEventBus bus;
public ShipmentService(ShipmentEventBus bus) {
this.bus = bus;
bus.register(this); // ← 순환!
}
}
bus가 service 가리키고, service가 bus 가리킴 → 순환.
public class CargoNode {
private CargoNode parent;
private List<CargoNode> children;
}
자식이 부모를 가리키고, 부모가 자식 리스트를 가짐 → 순환.
→ 실무 코드에서 순환 참조는 매우 흔함.
→ JVM이 참조 카운팅을 안 쓴 이유.
CPython은 참조 카운팅 + 추가 메커니즘:
주 메커니즘: 참조 카운팅 (즉시 회수)
보조 메커니즘: Cycle Detector (정기적으로 순환 검출)
Cycle Detector:
→ 참조 카운팅의 한계를 인정하고 두 번째 메커니즘 추가.
Swift는 개발자가 명시:
class Shipment {
var cargo: Cargo?
}
class Cargo {
weak var shipment: Shipment? // ← 약한 참조
}
weak:
→ 순환 참조를 개발자가 의식하고 처리.
→ 부담 증가.
JVM은 이 부담을 개발자에게 주지 않는 방식 선택 (Reachability).
참조 카운팅은 모든 참조 할당에서 카운터 갱신.
Shipment a = new Shipment(); // RC++ on new obj
Shipment b = a; // RC++
Shipment c = b; // RC++
c = null; // RC--
b = null; // RC--
a = null; // RC-- → 0 → 회수
각 할당마다:
비용 누적:
여러 스레드가 같은 객체 참조 변경 시:
Thread A: obj의 RC를 5 → 6
Thread B: obj의 RC를 5 → 4 (해제)
만약 동시에 시작했다면?
- 둘 다 5를 읽음
- A가 6 씀, B가 4 씀
- 마지막 쓰기가 이김 → RC 부정확
해결: 원자적 연산 (atomic increment/decrement).
→ 멀티코어 시대의 큰 부담.
각 객체가 4~8 bytes의 카운터 필드 보유
1억 개 객체 = 400MB ~ 800MB의 카운터 전용 메모리
작은 객체에서 비율이 큼:
Java의 Object Header: 16 bytes
+ 카운터 4 bytes = 20 bytes
→ Object Header 자체도 큰데, 카운터까지 추가하면 객체 1개의 메모리 효율 떨어짐.
1. ★ 순환 참조 → 메모리 누수 (가장 치명적)
2. 매 참조 할당마다 카운터 갱신 (성능)
3. 멀티스레드 동기화 비용
4. 객체마다 카운터 필드 추가 메모리
이 4가지 단점이 JVM이 다른 길을 선택하게 만들었음.
참조 카운팅: "이 객체를 가리키는 사람이 몇 명?"
Reachability: "이 객체가 GC Roots에서 도달 가능한가?"
GC Roots = 반드시 살아있어야 하는 출발점들.
거기서 도달 가능한 객체 = live.
도달 불가능한 객체 = garbage.
JVM이 GC Roots로 보는 것들:
1. Stack의 지역 변수
- 활성 스레드의 모든 스택 프레임의 LVA
- "지금 실행 중인 메서드들이 들고 있는 객체들"
2. Static 변수
- 모든 클래스의 static 필드
- "Method Area의 Static Zone"
3. JNI 참조
- 네이티브 코드(C/C++)가 들고 있는 자바 객체
4. 활성 스레드 객체 자체
5. 동기화 모니터를 가진 객체
6. JVM 시스템 클래스 (Class loader, etc.)
1. Mark Phase (마킹):
- 모든 GC Roots부터 시작
- 도달 가능한 객체에 "표시" (mark)
- 그래프 탐색 (BFS/DFS)
2. Sweep Phase (청소):
- Heap을 순회
- 마킹 안 된 객체 = garbage
- 메모리 해제
GC Roots 시작점들
↓
도달 가능한 객체들 마킹
↓
A ⇄ B 가 있다면:
- GC Roots에서 A나 B로 가는 경로 있나?
- 있으면 → 둘 다 마킹 → 살아있음
- 없으면 → 둘 다 마킹 안 됨 → garbage
순환이든 아니든 상관없음. 도달 가능성만 본다.
시각화:
GC Roots
│
▼
┌──────┐
│ A │ ⇄ ┌──────┐
└──────┘ │ B │
└──────┘
→ GC Roots → A → B 도달 가능 → 둘 다 살아있음
→ 만약 GC Roots → A 연결 끊기면 → A, B 둘 다 도달 불가 → 둘 다 garbage
| 항목 | 참조 카운팅 | Reachability (JVM) |
|---|---|---|
| 순환 참조 | ❌ 누수 | ✓ 해결 |
| 회수 시점 | 즉시 | GC 사이클까지 지연 |
| 갱신 비용 | 매 참조마다 | 0 (할당 시) |
| 멀티스레드 | 어려움 | 차라리 쉬움 (GC 시점만) |
| STW | 없음 | 있음 |
| 알고리즘 복잡도 | 단순 | 복잡 |
→ JVM은 순환 참조 해결 + 갱신 비용 0 의 이점을 선택.
→ 단점: STW 지연. 이걸 해결하는 게 G1, ZGC 등의 진화.
JVM이 참조 카운팅 대신 어떤 방식을 쓰는가?
답: Reachability Analysis (또는 Tracing GC).
JVM은 Reachability라 순환 참조 자체는 메모리 누수 안 만듦.
Shipment s = new Shipment();
Cargo c = new Cargo();
s.cargo = c;
c.shipment = s;
// 순환, 하지만 GC Roots에서 끊기면 둘 다 회수됨
→ Java/Kotlin 개발자는 순환 참조를 신경 쓸 필요 없음.
→ Swift/Python 개발자와 큰 차이.
JVM의 메모리 누수는 다른 패턴:
public class ShipmentCache {
private static final Map<Long, Shipment> cache = new HashMap<>();
public static void add(Long id, Shipment s) {
cache.put(id, s);
}
}
문제:
cache 는 static → Method Area의 Static Zone해결:
public class UserContext {
private static final ThreadLocal<User> CURRENT = new ThreadLocal<>();
public static void set(User u) { CURRENT.set(u); }
public static User get() { return CURRENT.get(); }
}
문제:
해결:
try {
UserContext.set(user);
process();
} finally {
UserContext.CURRENT.remove(); // 반드시
}
eventBus.register(listener);
// ... listener를 어디다 둔 채로 ...
eventBus가 listener 들고 있음 → listener를 가리키는 다른 객체도 함께 살아있음.
해결:
eventBus.unregister(listener) 호출Tomcat redeploy 시 옛 ClassLoader 누수:
@Entity
public class Shipment {
@OneToMany(mappedBy = "shipment", fetch = FetchType.EAGER)
private List<Cargo> cargoes;
}
@Entity
public class Cargo {
@ManyToOne(fetch = FetchType.EAGER)
private Shipment shipment;
}
문제는 메모리 누수가 아니라:
해결:
@JsonManagedReference / @JsonBackReference# 1. Heap dump 생성
jmap -dump:format=b,file=heap.hprof <PID>
# 또는 OOM 시 자동
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/log/
# 2. Eclipse MAT (Memory Analyzer Tool)
# - "Histogram": 클래스별 인스턴스 수
# - "Dominator Tree": 메모리 잡고 있는 객체
# - "GC Roots": 회수 안 되는 이유 추적
# 3. JFR (Java Flight Recorder)
java -XX:StartFlightRecording=filename=app.jfr -jar app.jar
운영에서 OOM 발생 시:
1. ☐ Heap dump 확보
2. ☐ MAT로 Histogram 분석
→ 의외로 많은 클래스 찾기
3. ☐ Dominator Tree 확인
→ 메모리 잡고 있는 큰 객체
4. ☐ "Path to GC Roots" 추적
→ 왜 회수 안 되는지
5. ☐ 의심되는 패턴:
- static 컬렉션 (캐시?)
- ThreadLocal 미정리
- Listener 미등록
- JDBC Connection 미반환
- ClassLoader 누수 (Metaspace)
6. ☐ 코드 수정
7. ☐ 재배포 후 모니터링
// 1. static 컬렉션은 Caffeine 등 라이브러리 사용
private static final Cache<Long, Shipment> cache =
Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofHours(1))
.build();
// 2. ThreadLocal은 반드시 try-finally
try {
UserContext.set(user);
return service.process();
} finally {
UserContext.clear();
}
// 3. Listener는 약한 참조 또는 명시적 해제
private final List<WeakReference<ShipmentListener>> listeners = ...;
// 4. JDBC는 try-with-resources
try (Connection conn = dataSource.getConnection()) {
// ...
} // ← 자동 close
// 5. JPA 양방향은 LAZY + 명시적 fetch
@OneToMany(fetch = FetchType.LAZY)
private List<Cargo> cargoes;
면접관: "GC가 어떻게 동작하나요?"
❌ "참조 카운터가 0이 되면 회수합니다"
✅ "Reachability Analysis. GC Roots에서 도달 가능 여부 판단"
→ Java의 GC는 참조 카운팅 아님. 이걸 헷갈리면 면접에서 즉시 감점.
class A { B b; }
class B { A a; }
A a = new A();
B b = new B();
a.b = b;
b.a = a;
질문: "이건 메모리 누수인가?"
❌ "예. 순환이라서 회수 안 됨"
✅ "아니오. JVM은 Reachability라 GC Roots에서 끊기면 회수됨"
면접관: "Python GC와 차이는?"
✅ "Python은 참조 카운팅 + Cycle Detector. JVM은 Reachability"
"Python은 즉시 회수, JVM은 GC 사이클 시 회수"
"각각 장단점 다름"
// ❌ 모든 객체를 WeakReference로
private final Map<Long, WeakReference<Shipment>> cache = new HashMap<>();
문제:
해결:
public class Shipment {
@Override
protected void finalize() {
// 자원 정리?
}
}
문제:
해결:
# ❌ 운영 GC 옵션을 카피해서 적용
java -XX:+UseParallelGC -XX:+UseG1GC ... # 충돌
해결:
# 1. GC 로그
-Xlog:gc*:file=gc.log
# 2. JFR (Java Flight Recorder)
-XX:StartFlightRecording=duration=60s,filename=app.jfr
# 3. Heap dump
-XX:+HeapDumpOnOutOfMemoryError
# 4. 라이브 분석
jcmd <PID> GC.run
jcmd <PID> GC.heap_info
| Q | 핵심 답변 |
|---|---|
| 참조 카운팅 동작 원리? | 객체마다 카운터. 0이면 회수 |
| JVM이 참조 카운팅 안 쓰는 이유? | 순환 참조 누수. 갱신 비용. 멀티스레드 |
| 순환 참조 시나리오? | A → B, B → A. 둘 다 RC=1로 남음 |
| JVM의 대안? | Reachability Analysis. GC Roots에서 도달 가능성 |
| GC Roots란? | Stack 지역변수, static 필드, JNI 참조, 활성 스레드 등 |
| Python의 GC? | 참조 카운팅 + Cycle Detector |
| Swift의 ARC? | 참조 카운팅 + weak/unowned 참조 |
| Java에서 순환 참조 문제? | 메모리 누수 X (Reachability). toString/JSON 무한 루프 |
| Java의 진짜 메모리 누수 패턴? | static 컬렉션, ThreadLocal, Listener, ClassLoader |
| Reachability의 단점? | STW. GC 사이클 시 멈춤 |
1. 참조 카운팅은 직관적이지만 치명적
2. JVM의 선택: Reachability Analysis
3. Java 메모리 누수는 다른 패턴
이번 Unit에서 JVM이 Reachability를 선택한 이유를 봤다면, 다음은 그 Reachability를 어떻게 효율적으로 수행할 것인가.
✅ Phase 1 — 자바 변수 ↔ 메모리 매핑 (1.1 ~ 1.6 완주)
✅ Phase 2 — JVM 메서드 실행 메커니즘 (2.1 ~ 2.4 완주)
✅ Phase 3 — 바이트코드와 상수 풀 (3.1 ~ 3.4 완주, 정점)
🚀 Phase 4 — G1 GC 심화
✅ Unit 4.1 참조 카운팅의 한계 ← 여기
⏭ Unit 4.2 G1 GC의 등장 배경
⏭ Unit 4.3 리전 기반 레이아웃
⏭ Unit 4.4 거대 리전
⏭ Unit 4.5 우선순위 기반 회수
⏭ Phase 5 — 컬렉션 내부 구조
⏭ Phase 6 — Reflection & Iterator
⏭ Phase 7 — Buffer