F-LAB JAVA · 2주차 · Phase 4 · G1 GC 심화
이 Unit을 끝내면 다음을 답할 수 있어야 한다.
거대 객체는 G1의 리전 모델을 깨는 "골치 아픈 손님"이다.
리전의 절반보다 큰 객체는 일반 리전에 못 들어가서 Humongous 리전을 차지한다.
연속된 메모리가 필요해서 단편화가 발생하고, GC 효율이 떨어진다.
ILIC 운영에서는 이런 객체를 만들지 않거나 작게 쪼개는 것이 정답.
| 시스템 | 비유 |
|---|---|
| 일반 객체 | 일반 손님 — 한 객실(리전) 사용 |
| 거대 객체 | VIP 단체 — 여러 객실을 연속으로 합쳐서 사용 |
| 단편화 | 객실 1, 2, 3은 비어있는데 4번 손님 있어서 단체에게 못 줌 |
| Old gen 직행 | VIP는 처음부터 스위트 룸 (Eden 거치지 않음) |
→ 호텔 운영자(GC)는 일반 손님 청소가 쉽지만, VIP 단체는 한 번 들어오면 청소 어려움.
1. 거대 객체의 정의
2. Humongous 리전의 동작
3. 왜 별도 처리하는가
4. GC 효율에 미치는 영향
5. ILIC 실무 — 거대 객체 발생 패턴
6. 거대 객체 회피 전략
7. 운영 모니터링
8. 흔한 실수 + 디버깅
9. 면접 + 자기 점검
거대 객체 (Humongous Object) 정의:
객체 크기 > 리전 크기 / 2
리전 크기별 거대 객체 기준:
| 리전 크기 | 거대 객체 기준 |
|---|---|
| 1 MB | > 512 KB |
| 2 MB | > 1 MB |
| 4 MB | > 2 MB |
| 8 MB | > 4 MB |
| 16 MB | > 8 MB |
| 32 MB | > 16 MB |
→ ILIC 일반 환경 (Heap 4GB → 리전 2MB)에선 1MB 초과 객체가 거대 객체.
리전 절반이 기준인 이유:
일반 리전의 동작:
- 객체 여러 개가 같은 리전에 함께 들어감
- Eden 리전 = 작은 객체들의 모음
만약 객체 크기가 리전의 절반보다 크면:
- 한 리전에 거의 1개만 들어감 (나머지 절반 낭비)
- 메모리 효율 ↓
→ 차라리 별도 처리하자
4MB 리전 환경에서:
이런 객체가 거대 객체:
byte[] data = new byte[3_000_000]; // 3MB 배열 (> 2MB)
String huge = veryLongQuery; // 2MB 넘는 String
Map<Long, Shipment> cache = new HashMap<>();
// ... 100만 항목 추가
// → HashMap 내부 배열이 거대해질 수 있음
ArrayList<Shipment> all = new ArrayList<>();
// ... 수십만 항목
// → 내부 배열이 거대화
주의: "거대 객체"는 객체 자체의 메모리 크기.
// 객체 본체는 작음 (참조만 보유)
List<Shipment> shipments = new ArrayList<>(1_000_000);
shipments.add(new Shipment());
// ... 1만 건
// ArrayList 객체 자체는?
// - Header 16 bytes
// - elementData 참조 8 bytes
// - size 등 + Padding
// → 작은 객체 (수십 bytes)
// 그러나 elementData 배열은?
// - Object[1_000_000] 크기 = 약 8MB
// → 이 배열이 거대 객체!
→ "List가 거대" 아니라 "List 내부 배열이 거대".
ILIC 코드에서 거대 객체 후보 식별:
// 후보 1: 큰 byte 배열
byte[] csvData = readCsvFile(largFile); // 5MB CSV → 거대 객체
// 후보 2: 큰 컬렉션 내부 배열
List<Cargo> manyCargoes = new ArrayList<>(500_000); // 내부 배열 4MB+
// 후보 3: 큰 HashMap
Map<Long, Shipment> cache = new HashMap<>();
for (Shipment s : loadAll()) { // 수십만 건
cache.put(s.getId(), s); // 내부 table이 거대화
}
// 후보 4: 큰 String
StringBuilder report = new StringBuilder();
for (...) {
report.append(...); // 거대 String 생성 가능
}
String result = report.toString(); // 거대 String 객체
→ 박승제씨가 ILIC 코드에서 의심할 패턴들.
3MB 객체를 2MB 리전 환경에 할당:
┌────────┐ ┌────────┐
│ Region │ │ Region │
│ 2MB │ │ 2MB │
└────────┘ └────────┘
→ 객체가 한 리전에 안 들어감
→ 두 리전을 연속으로 차지
표기:
H = Humongous Start (거대 객체 시작)
HC = Humongous Continued (연속)
3MB 객체 (2MB 리전 환경):
┌────┬────┬────┐
│ H │HC │... │ ← H 리전이 시작, HC가 이어짐
└────┴────┴────┘
5MB 객체:
┌────┬────┬────┐
│ H │HC │HC │ ← 3개 리전 연속
└────┴────┴────┘
거대 객체는 반드시 연속된 리전에 저장.
힙 상태:
┌─┬─┬─┬─┬─┬─┬─┬─┬─┬─┐
│E│O│ │E│ │O│ │ │ │E│ ← 빈 리전이 흩어져 있음
└─┴─┴─┴─┴─┴─┴─┴─┴─┴─┘
5MB 객체 할당 시도:
- 3개 연속 빈 리전 필요
- "2, 3, 4번 리전"이 연속이지만 2번이 차있음
- "6, 7, 8번 리전" 연속 → 여기 할당
만약 연속된 빈 리전 없으면?
→ Full GC 발동 → 메모리 정리 후 재시도.
→ G1의 약속 깨짐.
거대 객체는 일반 객체와 다른 라이프사이클:
일반 객체:
Eden → Survivor → ... → Old (승격)
거대 객체:
바로 Old gen으로 (Eden 안 거침)
→ 짧은 수명 가정 무시
이유:
Java 8u40 이전:
- 거대 객체는 Concurrent Cycle에서만 회수
- Young GC에서 안 건드림
- 빠르게 누적 → Full GC 위험 ↑
Java 8u40 이후 (현재):
- 거대 객체도 Young GC에서 회수 가능
- 참조 없으면 즉시 정리
- "Eager Reclamation" 도입
박승제씨가 Java 17 쓴다면 Eager Reclamation 활성화됨.
→ 거대 객체 누수 위험 줄어듦.
일반 리전:
- 여러 객체가 함께 거주
- Eden 리전 = 수십~수백 개 객체
- 복사(evacuation) 가능
- 압축(compaction) 가능
Humongous 리전:
- 객체 1개 (또는 연속 리전 N개에 1개)
- 복사 비용 매우 큼
- 압축 어려움
일반 객체 복사:
- 100 byte 객체 1개 복사: 100ns
- Young 리전 1개 (1MB) 복사: ~1ms
거대 객체 복사:
- 5MB 객체 복사: ~50ms (50,000배!)
- Young GC 시간 폭발
→ 거대 객체는 복사하지 말고 그 자리에 둔다.
시간 흐름에 따른 힙 상태:
T1: 거대 객체 A (3리전) 할당
┌────┬─┬─┬─┬─┬─┐
│ A │ │ │ │ │ │
└────┴─┴─┴─┴─┴─┘
T2: 거대 객체 B (2리전) 할당
┌────┬─┬───┬─┬─┐
│ A │ │ B │ │ │
└────┴─┴───┴─┴─┘
T3: A 회수됨
┌────┬─┬───┬─┬─┐
│ . │ │ B │ │ │ ← A 자리 비었지만 다른 위치
└────┴─┴───┴─┴─┘
T4: 4리전 연속 필요한 거대 객체 C 할당 시도
- 빈 리전 5개 있지만 4개 연속 X
- → Full GC 발동
→ 외부 단편화 문제.
G1의 약속:
"Pause < 200ms"
거대 객체 때문에 약속 깨지는 경우:
1. 거대 객체 evacuation
→ 큰 데이터 복사 → STW 길어짐
2. 연속 리전 부족 → Full GC
→ STW 수 초
3. Humongous 누적 → Concurrent Cycle 따라가지 못함
→ Mixed GC 효율 ↓
→ 거대 객체가 G1을 무너뜨리는 3가지 경로.
1. STW 시간 증가
- 거대 객체 처리 비용
2. 빈 공간 낭비
- 거대 객체 < 리전 절반 + 일부 = 일부 낭비
- 예: 3MB 객체 + 2MB 리전 = 2개 리전 사용 (4MB)
- 1MB는 낭비
3. 외부 단편화
- 연속 리전 부족
- Full GC 위험
4. Mixed GC 효율 ↓
- Humongous는 우선순위 회수 대상이지만
- 다른 Old 리전 회수 효과 분산
5. Concurrent Cycle 부담
- 거대 객체 마킹 비용
일반 Young GC:
[gc] GC(100) Pause Young (Normal) (G1 Evacuation Pause) 100M->10M(256M) 15ms
Humongous Allocation 발생 시:
[gc] GC(101) Pause Young (Concurrent Start) (G1 Humongous Allocation) ...
↑ "Humongous Allocation" 표시!
→ 거대 객체 할당으로 GC 발동
→ 일반보다 STW 길 수 있음
// ❌ 거대 캐시
public class ShipmentCache {
private static final Map<Long, Shipment> cache = new HashMap<>();
public static void putAll(List<Shipment> all) {
for (Shipment s : all) {
cache.put(s.getId(), s); // 100만 건 추가
}
}
}
문제:
해결:
// ✓ Caffeine 같은 라이브러리 (내부적으로 분할)
private static final Cache<Long, Shipment> cache =
Caffeine.newBuilder()
.maximumSize(100_000) // 한정된 크기
.expireAfterWrite(Duration.ofHours(1))
.build();
Caffeine은 내부적으로 여러 segment로 분할 → 거대 객체 발생 X.
거대 객체가 많으면 G1의 효율이 어떻게 되는가?
답:
1. STW 시간 증가 (복사 비용)
2. 외부 단편화 → Full GC 위험
3. Concurrent Cycle 부담
4. Mixed GC 효율 저하
5. 시간 예측 어려움 (약속 깨짐)
→ 거대 객체는 가능한 한 만들지 말 것.
// ❌ 5MB CSV 전체를 메모리에
public void importCsv(File csvFile) throws IOException {
byte[] data = Files.readAllBytes(csvFile.toPath()); // 거대 객체!
String content = new String(data, UTF_8); // 또 거대 객체
for (String line : content.split("\n")) {
process(line);
}
}
해결:
// ✓ 스트림으로 한 줄씩
public void importCsv(File csvFile) throws IOException {
try (Stream<String> lines = Files.lines(csvFile.toPath())) {
lines.forEach(this::process);
}
}
→ 1주차 NIO Unit과 연결. Buffer로 한 줄씩 처리.
// ❌ 100만 건 한꺼번에
public List<Shipment> findAll() {
return entityManager.createQuery("SELECT s FROM Shipment s", Shipment.class)
.getResultList(); // 100만 건 → ArrayList 내부 배열 거대화
}
해결:
// ✓ 페이징 + 스트림
public void processAll() {
int page = 0;
int size = 1000;
while (true) {
List<Shipment> batch = repository.findAll(
PageRequest.of(page, size)).getContent();
if (batch.isEmpty()) break;
batch.forEach(this::process);
page++;
}
}
// ❌ 큰 보고서 생성
public String generateReport(List<Shipment> shipments) {
StringBuilder sb = new StringBuilder();
for (Shipment s : shipments) { // 10만 건
sb.append(s.toCsvLine());
}
return sb.toString(); // 거대 String!
}
해결:
// ✓ Stream + Writer
public void writeReport(List<Shipment> shipments, Writer out) throws IOException {
try (BufferedWriter writer = new BufferedWriter(out)) {
for (Shipment s : shipments) {
writer.write(s.toCsvLine());
writer.newLine();
}
}
}
→ 메모리에 안 쌓고 즉시 출력. 거대 객체 안 생김.
// ❌ 100만 건을 한 JSON으로
public String toJson(List<Shipment> all) {
return objectMapper.writeValueAsString(all); // 거대 String!
}
해결:
// ✓ Streaming Writer
public void toJson(List<Shipment> all, OutputStream out) throws IOException {
try (JsonGenerator gen = objectMapper.createGenerator(out)) {
gen.writeStartArray();
for (Shipment s : all) {
objectMapper.writeValue(gen, s);
}
gen.writeEndArray();
}
}
→ 메모리 압박 없이 큰 JSON 생성.
// ❌ 이미지 메모리에 저장
public class ImageCache {
private static final Map<String, byte[]> cache = new HashMap<>();
public static byte[] get(String id) {
return cache.computeIfAbsent(id, ImageCache::load);
}
private static byte[] load(String id) {
return Files.readAllBytes(Paths.get(id)); // 5MB 이미지!
}
}
해결:
ILIC가 마주칠 진짜 거대 객체 후보:
1. PDF 생성 결과 (몇 MB)
2. Excel 일괄 다운로드 (XLSX 파일)
3. 대량 배치 처리의 중간 결과
4. 외부 API 응답 (큰 JSON)
5. 보고서 캐싱
→ 요청 처리 중에는 가능한 한 스트림으로.
→ 저장이 필요하면 외부 스토리지.
원칙: 데이터를 메모리에 다 적재하지 말 것
도구:
- Files.lines() (텍스트)
- InputStream / OutputStream
- Stream API
- Spring Data JPA Stream
- JSON Streaming (Jackson)
// 데이터베이스에서 한 번에 가져오지 말 것
@Query("SELECT s FROM Shipment s")
List<Shipment> findAll(); // ❌
@Query("SELECT s FROM Shipment s")
Page<Shipment> findAll(Pageable pageable); // ✓
// ❌ 한꺼번에
public void processAll(List<Shipment> all) {
all.forEach(this::process);
}
// ✓ 청크 단위
public void processAll(Iterable<Shipment> all) {
Iterables.partition(all, 1000).forEach(chunk -> {
chunk.forEach(this::process);
});
}
// 거대 컬렉션이 예상되면 미리 크기 지정
List<Shipment> list = new ArrayList<>(1_000_000);
이게 정말 좋은가? 상황에 따라.
장점:
단점:
→ 데이터가 진짜 1백만 건 들어갈 거면 OK.
→ 막연한 예상이면 기본 크기로.
// 큰 데이터를 JVM Heap 밖에 저장
ByteBuffer buffer = ByteBuffer.allocateDirect(10 * 1024 * 1024); // 10MB Off-heap
// 또는 Chronicle, Mapdb 같은 라이브러리
장점:
단점:
# 큰 객체가 정상 사용 사례라면
-XX:G1HeapRegionSize=32m
장점:
단점:
→ 매우 큰 객체가 정상 패턴일 때만.
우선순위:
1. 스트리밍 / 페이지네이션 (코드 수정)
2. Off-Heap 또는 외부 스토리지
3. 마지막 수단: 리전 크기 조정
박승제씨가 ILIC에서 만나는 대부분 거대 객체는
1번으로 해결.
# Java 11+ 통합 로그
java -Xlog:gc+humongous=trace:file=gc.log -jar app.jar
# 또는 일반 GC 로그에서
grep -i "Humongous" /var/log/gc.log
발견 패턴:
[gc] GC(123) Pause Young (Concurrent Start) (G1 Humongous Allocation) ...
[gc,heap] GC(123) Humongous regions: 5->5
# JFR 시작
jcmd <PID> JFR.start duration=120s filename=app.jfr
# JDK Mission Control로 분석
# - "Memory Allocation" 탭에서 Humongous 확인
jmap -dump:format=b,file=heap.hprof <PID>
# Eclipse MAT:
# 1. Histogram → "Shallow Heap" 정렬
# 2. 가장 큰 객체 식별
# 3. 어떤 클래스인지 확인 (보통 byte[], char[], Object[])
# 4. "List Objects → outgoing references"로 추적
# Prometheus + JMX Exporter 사용
# G1 Humongous 메트릭:
# jvm_gc_g1_humongous_regions
# jvm_gc_g1_humongous_objects
# 알림 규칙:
# Humongous 리전 수 > 10
# → 경고 발송
ILIC 운영 정기 체크 (GC 측면):
☐ GC 로그에서 "Humongous Allocation" 빈도
☐ Full GC 발생 여부 (이상적: 0회)
☐ Old gen 사용률 추세
☐ Mixed GC 빈도 정상 (시간당 1~3회)
☐ Young GC 평균/P99 STW
☐ Concurrent Cycle 정상 완료
☐ Metaspace 사용률
시나리오: 갑자기 Full GC 발생
1. GC 로그 확인:
tail -50 gc.log | grep "Pause"
결과:
GC(456) Pause Full (G1 Humongous Allocation)
↑ Humongous 때문에 Full GC!
2. 원인 추적:
- 어떤 코드가 거대 객체 만드는지?
- Heap dump로 식별
- Histogram에서 byte[] 등 큰 객체 확인
3. 해결:
- 코드 수정 (스트림 처리로 변경)
- 또는 리전 크기 늘림 (임시)
4. 검증:
- 재배포 후 GC 로그 모니터링
- Humongous Allocation 없어졌는지 확인
List<Shipment> list = new ArrayList<>();
for (int i = 0; i < 1_000_000; i++) {
list.add(new Shipment());
}
거대 객체는?:
ArrayList 객체 자체: 작음Object[] 배열: 거대 객체 (8MB+)→ 거대 객체는 내부 배열.
→ HashMap도 마찬가지.
GC 로그에 Humongous Allocation 자주 나옴
→ "어 이게 정상인가?" → 무시
❌ 절대 무시 X. 잠재적 시한폭탄.
→ 즉시 코드 검토.
# ❌ Humongous 회피하려고
-XX:G1HeapRegionSize=32m
장점: 16MB 객체도 정상 처리
단점: 일반 객체 효율 ↓, 정지시간 변동 ↑
→ 코드 수정이 먼저. 마지막 수단으로만 리전 조정.
// ❌ 매 요청마다
@GetMapping("/report")
public byte[] downloadReport() {
byte[] hugeReport = generateReport(); // 5MB
return hugeReport;
}
요청 100건/초 = 초당 500MB 거대 객체 생성.
→ GC 폭발.
해결:
Java 8u40 이전:
거대 객체 GC 어려움
Concurrent Cycle만 회수
Java 8u40 이후 (현재):
Eager Reclamation
Young GC도 거대 객체 회수
→ 박승제씨가 Java 17 쓰면 자동 활성화.
→ Java 8 구버전 쓰는 시스템 마이그레이션 검토.
ByteBuffer buf = ByteBuffer.allocateDirect(10 * 1024 * 1024);
→ 적극 활용. 1주차 NIO Unit과 연결.
# 1. GC 로그 (가장 기본)
-Xlog:gc*,gc+humongous=trace:file=gc.log
# 2. JFR
jcmd <PID> JFR.start duration=60s filename=hum.jfr
# 3. Heap dump + MAT
jmap -dump:live,format=b,file=heap.hprof <PID>
# 4. 실시간 통계
jstat -gc <PID> 1000 # 1초마다 GC 통계
| Q | 핵심 답변 |
|---|---|
| 거대 객체 기준? | 리전 크기 / 2 초과 |
| 거대 객체가 별도 처리되는 이유? | 일반 리전 비효율. 복사 비용. 압축 어려움 |
| Humongous Continued란? | 거대 객체가 여러 리전 차지할 때 이어지는 부분 |
| 거대 객체의 라이프사이클? | 바로 Old gen으로. Eden 안 거침 |
| Java 8u40의 변화? | Eager Reclamation. Young GC도 거대 객체 회수 |
| 거대 객체가 GC 효율에 미치는 영향 5가지? | STW 증가, 단편화, Concurrent 부담, Mixed 효율, 예측 불가 |
| ILIC에서 거대 객체 발생 패턴? | 대용량 파일, 큰 리스트, 빅 String, 캐시 |
| 회피 전략 3가지? | 스트리밍, 페이지네이션, 청크 처리 |
| 리전 크기 늘리면? | 거대 객체 줄지만 일반 객체 효율 ↓ |
| 운영 모니터링 방법? | GC 로그 Humongous, JFR, Heap dump |
1. 거대 객체 = 리전 / 2 초과 객체
2. 5가지 GC 효율 저해
3. ILIC 실무 — 만들지 말 것
이번 Unit에서 거대 객체의 특수 처리를 봤다면, 다음은 G1의 마지막 핵심 — Garbage First 알고리즘.
→ Phase 4 마지막 Unit.
🚀 Phase 4 — G1 GC 심화
✅ Unit 4.1 참조 카운팅의 한계
✅ Unit 4.2 G1 GC의 등장 배경
✅ Unit 4.3 리전 기반 레이아웃
✅ Unit 4.4 거대 리전 ← 여기
⏭ Unit 4.5 우선순위 기반 회수 (Phase 4 완주)
✅ Phase 1 — 자바 변수 ↔ 메모리 매핑 (1.1 ~ 1.6 완주)
✅ Phase 2 — JVM 메서드 실행 메커니즘 (2.1 ~ 2.4 완주)
✅ Phase 3 — 바이트코드와 상수 풀 (3.1 ~ 3.4 완주)
🚀 Phase 4 — G1 GC 심화 (4/5 진행)
⏭ Phase 5 — 컬렉션 내부 구조
⏭ Phase 6 — Reflection & Iterator
⏭ Phase 7 — Buffer