본 내용은 JVM 밑바닥까지 파헤치기 책을 읽으며 공부한 내용입니다.
출처: https://www.yes24.com/product/goods/126114513
JVM 밑바닥까지 파헤치기 - 예스24
“자바 가상 머신의 깊숙한 내부를 향해 떠나는 흥미진진한 모험”C·C++를 사용해 주로 프로그래밍을 하던 시절 까다로운 메모리 관리와 플랫폼 이식성 문제는 개발자들에게 적지 않은 부담이
www.yes24.com
GC 동작 원리를 이해하고 나면 이제 "어떻게 회수할 것인가?"라는 핵심 질문에 답할 차례예요. 저도 처음엔 "GC는 그냥 자동으로 메모리 정리해주는 거 아닌가?" 했는데, 실제로는 수십 년간 발전해온 정교한 알고리즘들의 집합체더라고요.
오늘은 『JVM 밑바닥까지 파헤치기』 3장을 통해 GC 알고리즘의 진화 과정과 각각의 특징을 실무 관점에서 파헤쳐보겠습니다.
🏗️ GC 알고리즘의 큰 그림
GC 알고리즘 분류
🎯 GC 알고리즘 계보
├── 📊 참조 카운팅 GC (Reference Counting)
│ └── Python, Swift 등에서 사용
└── 🔍 추적 GC (Tracing GC) ← 자바가 사용
├── 마크-스윕 (Mark-Sweep)
├── 마크-카피 (Mark-Copy)
└── 마크-컴팩트 (Mark-Compact)
자바가 추적 GC를 선택한 이유:
- ✅ 순환 참조 문제 해결
- ✅ 멀티스레드 환경에서 안정성
- ✅ 다양한 최적화 기법 적용 가능
🎂 세대별 컬렉션 이론 - GC의 핵심 철학
세대별 GC의 3가지 가설
현대 JVM의 GC는 모두 세대별 컬렉션 이론에 기반해요. 이 이론의 핵심은 3가지 가설입니다.
1. 약한 세대 가설 (Weak Generational Hypothesis)
"대다수 객체는 일찍 죽는다"
실무에서 정말 놀라운 통계가 있어요. 핫스팟 JVM 연구 결과, 신세대 객체의 98%가 첫 번째 GC에서 회수됩니다!
public class ObjectLifecycleExample {
public void shortLivedObjects() {
// 이런 객체들은 대부분 금방 죽어요
String temp = "임시 문자열";
StringBuilder sb = new StringBuilder();
List<Integer> tempList = new ArrayList<>();
// 메서드 끝나면 모두 GC 대상!
} // 약 95%의 객체가 여기서 죽음
private static final List<String> longLivedData = new ArrayList<>(); // 오래 살아남음
}
2. 강한 세대 가설 (Strong Generational Hypothesis)
"GC에서 살아남은 횟수가 늘어날수록 더 오래 살 가능성이 커진다"
public class SurvivorExample {
// GC를 여러 번 살아남은 객체들
private static final Map<String, Object> cache = new ConcurrentHashMap<>(); // 오래 살아남음
private static final Logger logger = LoggerFactory.getLogger(SurvivorExample.class); // 오래 살아남음
public void businessLogic() {
// 이미 여러 GC를 살아남은 객체들은 계속 살아남을 확률이 높음
cache.put("key", "value");
logger.info("비즈니스 로직 실행");
}
}
이런 객체들은 한 번 살아남으면 계속 살아남을 확률이 높아요. 마치 "생존자는 더 강하다"는 원리와 비슷하죠.
3. 세대 간 참조 가설 (Intergenerational Reference Hypothesis)
"세대 간 참조의 개수는 같은 세대 안에서의 참조보다 훨씬 적다"
구세대 객체가 신세대 객체를 참조하는 경우는 드물어요. 대부분의 참조는 같은 세대 내에서 발생합니다.
public class CrossGenerationReference {
// 구세대 객체
private static final UserService userService = new UserService();
public void processUser() {
// 신세대 객체
User newUser = new User("김철수", 25);
// 구세대 → 신세대 참조 (드물게 발생)
userService.process(newUser);
// 대부분의 참조는 같은 세대 내에서 발생
String name = newUser.getName(); // 신세대 내 참조
int age = newUser.getAge(); // 신세대 내 참조
}
}
힙 영역 분할과 GC 종류
🏠 JVM 힙 구조 (세대별)
├── 🐣 신세대 (Young Generation)
│ ├── Eden 영역 - 새 객체 탄생지
│ ├── Survivor 0 - 첫 번째 생존자 공간
│ └── Survivor 1 - 두 번째 생존자 공간
└── 👴 구세대 (Old Generation)
└── Tenured - 오래된 객체들의 거주지
GC 종류별 특징:
GC 종류 | 대상 영역 | 발생 빈도 | 소요 시간 | 특징 |
---|---|---|---|---|
Minor GC | 신세대만 | 자주 | 짧음 | 대부분 객체 회수 |
Major GC | 구세대만 | 가끔 | 김 | CMS 전용 |
Mixed GC | 신세대 + 구세대 일부 | 가끔 | 보통 | G1 전용 |
Full GC | 전체 힙 + 메타스페이스 | 드물게 | 매우 김 | 최후의 수단 |
기억 집합 (Remembered Set) - 세대 간 참조 해결책
// 기억 집합의 개념적 구현
public class RememberedSetConcept {
// 구세대에서 신세대로의 참조를 기록
private static Set<OldObject> rememberedSet = new HashSet<>();
static class OldObject {
YoungObject reference; // 구세대 → 신세대 참조
void setReference(YoungObject obj) {
this.reference = obj;
// 세대 간 참조 발생시 기억 집합에 기록
rememberedSet.add(this);
}
}
// Minor GC 시 기억 집합만 확인하면 됨
public static void minorGC() {
// 전체 구세대를 스캔하지 않고
// 기억 집합에 있는 객체들만 GC Root로 추가
for (OldObject obj : rememberedSet) {
// 이 객체가 참조하는 신세대 객체는 살려둠
}
}
}
🏷️ 마크-스윕 알고리즘 - GC의 할아버지
동작 원리
1960년 존 맥카시가 제안한 최초의 GC 알고리즘이에요. 이름 그대로 "표시하고 쓸어담는" 방식입니다.
1단계: 마크 (Mark)
- GC Root에서 시작해서 도달 가능한 모든 객체에 "살아있음" 표시
- 도달 불가능한 객체는 표시되지 않음
2단계: 스윕 (Sweep)
- 힙을 처음부터 끝까지 훑으면서 표시되지 않은 객체들을 회수
- 표시된 객체들은 다음 GC를 위해 마크 초기화
마크-스윕의 문제점
1. 실행 효율의 불안정성
객체 수가 많아질수록 GC 시간이 비례해서 증가해요. 1000개 객체면 1초, 100만 개 객체면 1000초... 이런 식으로요. 😅
2. 메모리 파편화 문제
이게 진짜 심각한 문제예요!
GC 전: [A][B][C][D][E][F][G][H]
GC 후: [A][ ][C][ ][E][ ][G][ ]
↑ ↑ ↑ ↑
메모리 조각들
총 여유 공간은 충분한데 연속된 큰 공간이 없어서 큰 객체를 할당할 수 없는 상황이 발생해요. 마치 주차장에 자리는 많은데 모두 떨어져 있어서 버스를 주차할 수 없는 것과 같죠.
실무에서의 파편화 문제:
// 이런 패턴이 파편화를 심화시켜요
List<byte[]> smallObjects = new ArrayList<>();
// 작은 객체들을 많이 생성
for (int i = 0; i < 10000; i++) {
smallObjects.add(new byte[1024]); // 1KB 객체들
}
// 일부만 참조 해제 (체스판 패턴으로)
for (int i = 0; i < smallObjects.size(); i += 2) {
smallObjects.set(i, null);
}
// 이제 큰 객체 할당 시도하면 실패할 수 있음!
byte[] largeObject = new byte[1024 * 1024]; // OutOfMemoryError 가능
📋 마크-카피 알고리즘 - 파편화 해결사
동작 원리
메모리를 반으로 나누어 사용하는 혁신적 아이디어입니다.
- From Space: 현재 객체들이 할당되어 있는 공간
- To Space: 비어있는 공간
GC가 발생하면 From Space의 살아있는 객체들만 To Space로 복사하고, From Space는 통째로 비워버려요. 그 다음 두 공간의 역할을 바꿉니다.
마크-카피의 장점
1. 높은 효율성
신세대에서는 정말 효율적이에요! 98%의 객체가 죽으니까 2%만 복사하면 되거든요.
- 마크-스윕이라면: 1000개 객체 모두 검사
- 마크-카피: 20개 객체만 복사
- 효율성: 50배 차이!
2. 메모리 파편화 완전 해결
복사 과정에서 객체들이 메모리 한쪽 끝에서부터 차곡차곡 쌓이므로 파편화가 전혀 발생하지 않아요.
GC 전 From: [A][ ][C][ ][E][ ][G][ ]
GC 전 To: [ ]
GC 후 From: [A][C][E][G][ ] ← 완전히 압축됨!
GC 후 To: [ ]
마크-카피의 단점과 해결책
메모리 50% 낭비 문제
이게 가장 큰 단점이에요. 메모리의 절반을 항상 비워둬야 하니까요.
하지만 신세대에서는 이 단점이 상쇄됩니다:
- 객체 생존율이 2%로 매우 낮음
- 파편화 해결 효과가 매우 큼
- 메모리 50% 낭비보다 얻는 이익이 더 큼!
핫스팟 JVM의 최적화 - Eden + Survivor 구조
핫스팟 JVM은 Eden : Survivor0 : Survivor1 = 8 : 1 : 1 비율로 구성해요.
동작 과정:
- 새 객체는 Eden에 할당
- Eden 가득 참 → Minor GC 발생
- 생존 객체를 Survivor0으로 복사 (age = 1)
- 다음 Minor GC
- Eden + Survivor0 생존자를 Survivor1으로 복사 (age++)
- age가 임계값(기본 15) 도달시 구세대로 승격
메모리 효율성: 낭비율이 50%에서 10%로 대폭 개선됩니다!
🗜️ 마크-컴팩트 알고리즘 - 구세대의 선택
동작 원리
마크-스윕의 파편화 문제를 해결하면서도 메모리를 절약하는 방법입니다.
1단계: 마크 (Mark)
- 마크-스윕과 동일하게 살아있는 객체들을 표시
2단계: 컴팩트 (Compact)
- 살아있는 객체들을 한쪽으로 이동시켜 메모리를 압축
- 모든 참조를 새로운 주소로 업데이트
GC 전: [A][ ][C][ ][E][ ][G][ ]
마크: [✓][ ][✓][ ][✓][ ][✓][ ]
컴팩트: [A][C][E][G][ ]
↑연속된 여유 공간
마크-컴팩트의 장단점
장점: 메모리 효율성
- 메모리 사용률: 100% (마크-카피는 50%)
- 파편화 완전 해결
- 구세대에 최적: 객체 생존율이 높은 구세대에서 메모리 절약이 중요
단점: Stop-The-World와 성능 오버헤드
이게 정말 큰 문제예요. 구세대에 100만 개 객체가 있고 70%가 생존한다면:
- 70만 개 객체를 모두 이동해야 함
- 모든 참조 포인터를 업데이트해야 함
- 모든 애플리케이션 스레드가 중지되어야 함 (Stop-The-World)
트레이드오프:
- 처리량(Throughput) ↑ vs 지연시간(Latency) ↓
- 메모리 효율성 ↑ vs 응답성 ↓
🎯 실무에서의 GC 알고리즘 선택
세대별 알고리즘 매핑
신세대 (Young Generation):
- 알고리즘: 마크-카피 (Eden + Survivor)
- 이유: 객체 생존율 낮음 (2%), 파편화 해결 중요
- 특징: 빠른 GC, 메모리 50% 낭비는 감수
구세대 (Old Generation):
- 알고리즘: 마크-컴팩트 또는 마크-스윕
- 이유: 객체 생존율 높음 (70-90%), 메모리 효율성 중요
- 특징: 느린 GC, 메모리 효율성 우선
GC별 구세대 알고리즘
GC 종류 | 구세대 알고리즘 | 특징 |
---|---|---|
Parallel Old | 마크-컴팩트 | 처리량 우선, 긴 일시정지 |
CMS | 마크-스윕 → 파편화시 마크-컴팩트 | 낮은 지연시간, 파편화 문제 |
G1 | 마크-카피 (Region 단위) | 균형잡힌 성능 |
ZGC/Shenandoah | 동시 마크-컴팩트 | 매우 낮은 지연시간 |
성능 특성 비교
처리량 중심 애플리케이션 (배치 처리, 데이터 분석):
- Parallel GC 추천
- 마크-컴팩트로 메모리 효율성 극대화
- 긴 일시정지 시간은 감수
지연시간 중심 애플리케이션 (웹 서비스, 실시간 시스템):
- G1, ZGC, Shenandoah 추천
- 동시 또는 증분 컴팩션으로 일시정지 시간 최소화
- 약간의 처리량 손실은 감수
💡 알고리즘 선택 가이드
애플리케이션 특성별 추천
🎯 GC 알고리즘 선택 가이드
📊 대용량 배치 처리
└── Parallel GC (마크-컴팩트)
├── 장점: 최고 처리량
└── 단점: 긴 일시정지
🌐 웹 애플리케이션
└── G1 GC (Region 기반 마크-카피)
├── 장점: 균형잡힌 성능
└── 단점: 복잡한 튜닝
⚡ 실시간 시스템
└── ZGC/Shenandoah (동시 마크-컴팩트)
├── 장점: 매우 낮은 지연시간
└── 단점: 높은 CPU 사용량
메모리 크기별 추천
작은 힙 (< 4GB):
- Parallel GC 또는 Serial GC
- 단순하고 효율적
중간 힙 (4GB - 32GB):
- G1 GC 추천
- 예측 가능한 일시정지 시간
큰 힙 (> 32GB):
- ZGC 또는 Shenandoah 고려
- 힙 크기에 관계없이 일정한 일시정지 시간
🔮 GC 알고리즘의 미래
현재 트렌드
1. 동시성 강화:
- Stop-The-World 시간 최소화
- 애플리케이션과 GC의 동시 실행
2. 지역성 활용:
- Region 기반 메모리 관리
- 부분적 컬렉션으로 효율성 향상
3. 하드웨어 최적화:
- 멀티코어 CPU 활용
- NUMA 아키텍처 고려
차세대 기술들
ZGC (Z Garbage Collector):
- 10ms 이하 일시정지 보장
- 힙 크기에 관계없이 일정한 성능
- 컬러드 포인터 기술 활용
Shenandoah:
- 동시 컴팩션 구현
- 낮은 지연시간과 높은 처리량 양립
- Red Hat에서 개발
Epsilon GC:
- No-Op GC (아무것도 하지 않는 GC)
- 성능 테스트와 메모리 할당 패턴 분석용
- 극한의 성능이 필요한 특수 상황용
🎉 마무리
GC 알고리즘의 진화는 컴퓨터 과학 발전의 축소판이에요:
1960년대: 마크-스윕으로 시작
1970년대: 마크-카피로 효율성 개선
1980년대: 마크-컴팩트로 메모리 효율성 확보
1990년대: 세대별 GC로 실용성 확보
2000년대: 동시 GC로 지연시간 개선
2010년대: Region 기반으로 확장성 확보
현재: 동시 컴팩션으로 완전체 추구
핵심 교훈:
- 🎯 완벽한 GC는 없다 - 모든 것은 트레이드오프
- 📊 애플리케이션 특성을 파악하라 - 처리량 vs 지연시간
- 🔧 측정하고 튜닝하라 - 이론보다 실측이 중요
- 🚀 기술은 계속 발전한다 - 새로운 GC 기술에 관심을 가져라
JVM 밑바닥까지 파헤치기 - 예스24
“자바 가상 머신의 깊숙한 내부를 향해 떠나는 흥미진진한 모험”C·C++를 사용해 주로 프로그래밍을 하던 시절 까다로운 메모리 관리와 플랫폼 이식성 문제는 개발자들에게 적지 않은 부담이
www.yes24.com
'개발 > JVM' 카테고리의 다른 글
[JVM 밑바닥까지 파헤치기] CMS 가비지 컬렉터 완전 분석 - 동시성의 선구자 🔄 (0) | 2025.07.04 |
---|---|
[JVM 밑바닥까지 파헤치기] JVM 클래식 가비지 컬렉터 완전 정복 🗑️ (0) | 2025.07.03 |
[JVM 밑바닥까지 파헤치기] 가비지 컬렉터의 객체 생존 여부 판단 방식 🚽 (0) | 2025.06.30 |
[JVM 밑바닥까지 파헤치기] 메모리 관리 또는 런타임 데이터 영역 📁 (0) | 2025.06.29 |
[JVM 밑바닥까지 파헤치기] JVM의 역사 🚀 (1) | 2025.06.28 |