-
G1 GC Garbage 가 왜 필요하지? 성능 개선 방법까지 정리카테고리 없음 2025. 12. 22. 10:52728x90반응형
Garbage Collection이란?
Garbage Collection(GC) 은 프로그램이 동적으로 할당한 메모리(힙 heap) 중에서,
더 이상 어떤 코드에서도 접근할 수 없는 객체(garbage) 를 자동으로 찾아서 회수(해제) 하는 런타임 메모리 관리 기법입니다.- 개발자가 free() 같은 수동 해제를 안 해도 되고
- 메모리 누수/이중 해제 같은 오류를 크게 줄입니다.
1) CS 관점에서 “왜 필요한가?”
운영체제/컴구 관점에서 프로세스 메모리는 한정되어 있고, 힙은 계속 커질 수 있어요.
동적 할당을 수동으로 관리하면 대표적으로:
- 메모리 누수(leak): 안 쓰는데도 해제를 못 해서 점점 메모리 고갈
- use-after-free / double free: 해제한 메모리를 다시 참조/또 해제 → 크래시/보안 문제
- 단편화(fragmentation): 작은 빈 공간이 여기저기 생겨 큰 할당이 어려움
GC는 이런 문제를 줄이는 대신,
- “언제 회수할지/얼마나 멈출지”를 런타임이 결정해야 해서
- 성능(throughput) vs 지연(latency) 트레이드오프가 생깁니다.
- 특히 웹서버는 Stop-The-World(STW) 가 길어지면 요청 지연이 커짐.
2) “가비지”를 어떻게 판단하나? (핵심 원리)
자바 GC는 보통 도달 가능성(Reachability) 기준으로 판단합니다.
- GC Roots에서 출발해서(스레드 스택의 지역변수, static 참조, JNI 참조 등)
- 참조 그래프를 따라가며 도달 가능한 객체는 살아있는 것(live)
- 어디에서도 도달 못 하면 가비지
이게 CS에서 말하는 그래프 탐색(Mark) 개념이에요.
3) GC가 실제로 하는 일 3단계
GC는 구현마다 다르지만 큰 틀은 보통:
- Mark(표시): 살아있는 객체 찾기(그래프 탐색)
- Sweep(회수): 죽은 객체 메모리 반환
- Compact/Copy(정리): 단편화를 줄이기 위해 객체를 옮겨 붙이기(옵션/방식에 따라 다름)
4) Young 객체는 어떤 식으로 처리되나
객체가 생성되는 곳: Eden
- 대부분의 자바 객체는 Young 영역(Eden) 에서 시작해.
- “대부분 금방 죽는다”는 통계(Generational hypothesis) 때문에 Young을 자주 청소하는 게 효율적이야.
Young GC(=Minor GC)가 일어날 때
Eden이 꽉 차면 Young GC를 수행해. 이때 기본 흐름은:
- (STW) 루트 스캔
- GC Roots(스레드 스택 지역변수, static, JNI 등)에서 시작해서 “살아있는 객체”를 찾음.
- 살아있는 객체만 복사(Copy)
- Eden에서 살아남은 객체를 Survivor로 옮김
- Survivor에 있던 객체도 살아있으면 다른 Survivor로 옮김(핑퐁)
- Eden은 통째로 “비었다”고 처리
- 죽은 객체는 굳이 하나하나 free 안 하고, Eden 영역 포인터를 통째로 리셋하듯 재사용
“나이(Age)”와 승격(Promotion)
- Survivor에서 몇 번(보통 임계값) 살아남으면 Old로 승격(Promotion) 됨.
- 승격은 “오래 살 것 같은 객체”를 Old로 보내서 Young을 가볍게 유지하려는 전략.
핵심 요약
- Young GC는 “살아있는 것만 복사하고 나머지는 버린다” 방식이라 빠른 편
- 하지만 살아있는 객체가 많아질수록(복사량↑) pause가 늘어남
2) Stop-The-World(STW)는 언제 발생하나?
STW는 “GC 때문에 애플리케이션 스레드(요청 처리 스레드 등)가 잠깐 멈추는 시간”이야.
G1에서도 STW는 완전히 없어지지 않고, 특정 단계에서 필요해.(A) Young GC 자체가 STW
- Eden이 차면 Young GC를 위해 잠깐 멈춤(STW).
- 이때 살아있는 객체 복사/참조 정리 등을 안전하게 해야 해서 멈춰야 함.
(B) G1의 Concurrent Marking 과정 중 일부도 STW
G1은 Old를 “동시(concurrent)”로 마킹하지만, 완전 100% 동시는 아님. 대표적으로:
- Initial Mark (STW)
- “마킹을 시작할 기준점”을 잡는 짧은 멈춤
- 보통 Young GC에 끼워넣어서 같이 처리하는 식으로 최적화됨
- Remark (STW)
- concurrent marking 동안 앱이 계속 객체 참조를 바꾸기 때문에(쓰기)
- 마지막에 “변경분 정산”이 필요해서 짧게 멈춤
- Cleanup (일부 STW/일부 동시)
- 마킹 결과로 “회수할 region 후보” 정리
(C) Mixed GC(Young + 일부 Old)도 STW
- G1은 Old 전체를 한 번에 크게 청소하는 대신,
- 가비지가 많은 Old region 일부만 Young GC에 섞어(“Mixed”) 조금씩 회수함.
- Mixed도 수행은 STW로 진행되지만, 목표는 “짧게 여러 번”이야.
3) Full GC는 언제 발생하고, 뭐가 문제인가?
Full GC란?
대부분 상황에서 G1은 Young/Mixed/Concurrent로 버티려고 하는데,
어떤 이유로 “점진적 방식이 실패” 하면 Full GC로 가는 케이스가 있어.Full GC는 보통:
- 힙 전체(특히 Old 포함)를 크게 멈추고(STW 크게) 정리/압축(compaction)하는 형태가 되고,
- 서비스 지연을 크게 만들기 때문에 “가능하면 피하는 이벤트”야.
Full GC가 발생하는 대표 원인(현업에서 흔함)
- Promotion 실패 (Old가 받을 공간 부족)
- Young GC로 살아남은 객체를 Old로 올려야 하는데 Old에 연속/가용 공간이 부족하면 문제.
- 이때 “이대로는 못 옮긴다” → 더 큰 정리가 필요 → Full GC로 갈 수 있음.
- To-space exhausted (G1 복사 공간 부족)
- G1은 수집 때 “여기로 복사할 자리(to-space)”가 필요해.
- 근데 살아있는 객체가 예상보다 많거나 단편화/여유 region이 없으면 “복사할 곳이 없음” → Full GC 위험.
- Humongous 객체가 많음(큰 객체)
- G1은 힙을 region으로 쪼개는데, region 크기보다 큰 객체는 “Humongous”로 특별 취급.
- 이런 객체가 많아지면 region 사용이 비효율적으로 되고 회수 타이밍도 꼬이면서 Full GC 유발 요인이 됨.
(예: 큰 byte[]/큰 캐시/큰 JSON/대형 컬렉션)
- Concurrent Marking이 제때 못 따라감(Concurrent mode failure)
- 앱이 객체를 너무 빠르게 만들어 Old가 빨리 차는데,
- 동시 마킹/회수가 따라가지 못하면 결국 “한 번에 크게 멈춰서 정리”가 필요해짐.
Full GC가 발생하면 무슨 문제가 생기나?
면접에서 가장 중요한 포인트 3개:
- 긴 STW로 인한 지연 폭발
- Full GC는 ms가 아니라 수백 ms~수초까지도 갈 수 있음(힙 크기/라이브셋에 따라).
- 스프링 웹 서비스면:
- 응답 지연 증가
- 타임아웃 증가
- 로드밸런서에서 unhealthy 판정 가능
- 처리량(Throughput) 급감
- 애플리케이션 스레드가 멈춰 있으니 요청을 못 처리함.
- “평균 TPS”가 아니라 tail latency(p99/p999) 가 크게 악화됨.
- 연쇄 장애(캐스케이드)
- GC pause 동안 요청이 쌓임 → 큐/스레드풀 포화 → 타임아웃/재시도 폭증
- 재시도는 트래픽을 더 늘려서 더 많은 객체 생성 → GC 더 자주 → 악순환
1) 재현 전략 2가지 (실무에서 흔한 유형)
A. Humongous allocation + Old 압박 → Full GC
- G1에서 Region 크기(기본 1~32MB) 보다 큰 객체(예: 큰 byte[])를 자주 만들면 Humongous로 처리됨.
- Humongous가 누적되면 회수가 꼬이고, to-space 부족 / evacuation 실패로 Full GC가 날 수 있음.
B. 메모리 누수(캐시에 무한 누적) → Old 꽉 참 → Full GC 반복
- static Map/List에 요청마다 큰 객체를 저장해 Old가 계속 증가
- Mixed GC로도 못 따라가면 결국 Full GC.
Controller + 재현 로직
package demo; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import java.util.ArrayList; import java.util.List; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ThreadLocalRandom; /** * ⚠️ GC/Full GC 재현용. 운영 절대 금지. */ @RestController public class GcReproController { // (B) 의도적 누수: 요청마다 큰 객체를 저장해서 Old를 채움 private static final ConcurrentHashMap<Long, byte[]> LEAK = new ConcurrentHashMap<>(); /** * http://localhost:8080/leak?mb=8&count=200 * - mb: 한 덩어리 byte[] 크기(MB) * - count: 몇 개 만들지 * * 효과: * 1) 큰 byte[]를 많이 만들어 humongous allocation 유도 (G1에서 흔함) * 2) 일부를 static map에 저장해서 Old를 지속적으로 압박 -> Full GC 가능성 증가 */ @GetMapping("/leak") public String leak(@RequestParam(defaultValue = "8") int mb, @RequestParam(defaultValue = "200") int count) { int bytes = mb * 1024 * 1024; List<byte[]> temp = new ArrayList<>(count); for (int i = 0; i < count; i++) { // (A) 큰 객체 생성 (humongous 후보) byte[] arr = new byte[bytes]; // 랜덤하게 건드려서 실제로 메모리에 커밋되도록(최적화 방지) int idx = ThreadLocalRandom.current().nextInt(arr.length); arr[idx] = 1; temp.add(arr); // (B) 일부는 누수시켜 Old를 계속 채움 if (i % 3 == 0) { LEAK.put(System.nanoTime(), arr); } } long leakSize = LEAK.mappingCount(); return "Allocated temp=" + count + " arrays of " + mb + "MB, leaked entries=" + leakSize; } /** * 누수 제거 (테스트 중 정리용) * http://localhost:8080/clear */ @GetMapping("/clear") public String clear() { LEAK.clear(); return "LEAK cleared"; } }3) 실행 방법 (GC 로그 켜기: Java 11)
(1) 힙을 작게 줘서 빨리 재현 (권장)
아래처럼 실행하면 GC 로그가 gc.log로 남는다.
java \ -Xms256m -Xmx256m \ -XX:+UseG1GC \ -XX:MaxGCPauseMillis=50 \ -XX:InitiatingHeapOccupancyPercent=30 \ -Xlog:gc*,safepoint:file=gc.log:time,uptime,level,tags \ -jar build/libs/0.0.1-SNAPSHOT.jarHumongous가 뭐냐?
G1 Region 하나의 50% 이상을 차지하는 “너무 큰 객체”
object_size ≥ region_size × 0.5(2) 네 JVM에서의 기준 수치 (로그 발췌)
📌 로그 발췌
[gc,heap,exit] region size 1024K🧠 해석
- Region 크기 = 1MB
- Humongous 기준 = 512KB 이상 객체
즉,
new byte[600 * 1024]; // Humongous new byte[2 * 1024 * 1024]; // Humongous (2 region 차지)(3) Humongous의 핵심 특징 (이게 중요)
항목일반 객체Humongous 객체할당 위치 Eden Old 바로 할당 GC 방식 복사(Evacuation) 복사 안 함 회수 시점 Young/Mixed GC Full GC에서만 가능 위험성 낮음 힙 파편화 + GC 무력화 대부분의 현대 JVM(특히 G1 GC)에서 Humongous 객체는 “처음부터 Old(=Humongous) 영역에 바로 할당”되는 것으로 이해하면 맞아요. 즉 Young(Eden/Survivor)에서 나이 먹고 승격(promotion) 되는 흐름이 아니라, 처음 할당 시점부터 Old 쪽(Humongous region)으로 잡힙니다.
아래는 G1 기준으로 예시를 들어볼게요. (Humongous의 기준은 “region 크기의 50% 이상”)
전제: G1의 region과 Humongous 기준
- G1은 힙을 동일 크기의 Region으로 쪼갭니다(대개 1~32MB).
- 어떤 객체의 크기가 RegionSize의 1/2 이상이면 Humongous로 분류됩니다.
- 예: RegionSize=4MB이면 2MB 이상 객체가 humongous
2️⃣ 네 로그에서 Humongous가 처음 드러나는 순간
(1) Humongous 할당 트리거
📌 로그 발췌
GC(24) Pause Young (Concurrent Start) (G1 Humongous Allocation)🧠 해석
- GC가 발생한 이유(reason) 가 명확히 적혀 있음
- 원인: Humongous 객체를 새로 만들려고 함
👉 이 한 줄만 봐도 실무자는 바로 이렇게 생각함:
“아, 큰 객체를 계속 만들고 있구나”
(2) Humongous가 줄었는지 확인 (생존 여부 판단)
📌 로그 발췌
GC(24) Humongous regions: 36->36🧠 해석
GC(24) => 해당 24번 일련번호 GC가 돌았따는 의미 결국 36-> 36은
GC가 돌았지만 그대로이다 라는뜻
📌 GC 로그에서:
- A->A = 전부 살아 있음
- A->0 = 정상 회수
👉 여기서 1차 결론:
“큰 객체들이 GC 이후에도 계속 참조되고 있다”
3️⃣ 상황 악화: Humongous 폭증 (치명적 신호)
(1) 수치 폭증 확인
📌 로그 발췌
GC(26) Humongous regions: 189->189 GC(26) ... 241M->241M(256M)🧠 해석
- Humongous region = 189개
- region = 1MB → 약 189MB
- GC 전후 힙 사용량 변화 ❌
👉 실무적 판단:
“GC가 할 일이 없다 = 힙 대부분이 살아있는 큰 객체”
(2) 이 시점에서 GC 구조적으로 무슨 일이 벌어지나?
- 힙 전체: 256MB
- Humongous만: ~189MB
- 나머지 Eden / Survivor / To-space 공간이 물리적으로 부족
➡️ Evacuation 불가능 상태로 진입
4️⃣ 새로운 용어: To-space exhausted
(1) 로그 발췌
GC(27) To-space exhausted
(2) To-space란?
GC 중 살아있는 객체를 “옮겨 담을 목적지 메모리”
G1 Young GC는:
From-space (기존) → To-space (새 공간)으로 객체를 복사함.
(3) exhausted의 의미
“옮길 자리가 없다”
즉:
- Old + Humongous가 힙을 점유
- To-space 확보 실패
- Young GC 전략 완전 붕괴
👉 실무에서 이 로그는 이렇게 읽음:
“다음은 무조건 Full GC다”
5️⃣ 실제로 발생한 Full GC (Humongous 원인 명시)
(1) Full GC 로그 발췌
GC(28) Pause Full (G1 Humongous Allocation)👉 Full GC 원인까지 명확히 기록됨
- 이유: Humongous Allocation
2) Full GC 결과 확인
GC(28) Humongous regions: 198->198 GC(28) 254M->236M(256M)🧠 해석
- Humongous 그대로
- 회수량 약 18MB
- 살아있는 큰 객체는 그대로
(3) 연속 Full GC (완전 실패)
GC(29) Pause Full (G1 Humongous Allocation) GC(29) 236M->236M(256M)👉 회수량 0
👉 JVM 입장: 더 이상 할 수 있는 게 없음➡️ OOM 확정 코스
1) “1초 STW 발생” 로그를 예시로 조작(시나리오)
네가 준 로그는 Full GC가 25ms 수준이었지.
여기서는 학습을 위해 “운영에서 가끔 보는 패턴”으로 STW 1.2초가 찍힌 것처럼 예시를 만든다고 가정할게.예시(조작된) 핵심 라인:
[2025-12-22T15:41:07.837+0900][13.006s][info][gc] GC(999) Pause Full (G1 Humongous Allocation) 6144M->5900M(8192M) 1080.165ms [2025-12-22T15:41:07.837+0900][13.006s][info][safepoint] Total time for which application threads were stopped: 1.2034399 seconds, Stopping threads took: 0.0100238 seconds여기서 인간은 이렇게 결론 내림:
- “Pause Full”이 1.08s → 이미 위험
- safepoint stop이 1.20s → SLA에 치명적인 STW 사건
- 원인 태그가 G1 Humongous Allocation → 큰 객체/큰 배열/대용량 버퍼/대용량 response 생성 의심
Step A. “STW 1초 이상” 탐지 자동화(코드)
운영에서는 보통
- GC 로그 파일을 수집(파일/STDOUT)
- 파서로 “STW >= 1s” 이벤트를 만들고
- 알림(Slack/Email/Prometheus Alert)로 연결함
여기서는 Spring Boot 내부에서(학습용) “GC 로그 문자열을 받아 분석”하는 컨트롤러를 만든다.
2-A-1) STW 감지기(파서) 코드
- safepoint 라인의 Total time ... stopped: X seconds를 파싱해서 threshold 비교
- 또는 GC 라인의 Pause Full ... 1080.165ms도 함께 파싱해서 보조 지표로 씀
package com.devlab.www.fo.handler.api.v1.na.test; import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.*; import java.util.*; import java.util.regex.Matcher; import java.util.regex.Pattern; @RequestMapping("/api/v1/na/gc") @RestController public class GcLogAnalyzeController { // safepoint stop time (seconds) private static final Pattern SAFEPOINT_STOP_SEC = Pattern.compile( "Total time for which application threads were stopped: ([0-9.]+) seconds"); // Full GC pause time (ms) private static final Pattern FULL_GC_PAUSE_MS = Pattern.compile( "Pause Full \\(G1 Humongous Allocation\\).*? ([0-9.]+)ms"); @PostMapping(value = "/analyze", consumes = MediaType.TEXT_PLAIN_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) public Map<String, Object> analyze(@RequestBody String gcLog, @RequestParam(defaultValue = "1.0") double stwThresholdSec) { List<Map<String, Object>> stwEvents = new ArrayList<>(); String[] lines = gcLog.split("\\R"); for (int i = 0; i < lines.length; i++) { String line = lines[i]; Matcher sp = SAFEPOINT_STOP_SEC.matcher(line); if (sp.find()) { double stoppedSec = Double.parseDouble(sp.group(1)); if (stoppedSec >= stwThresholdSec) { Map<String, Object> evt = new LinkedHashMap<>(); evt.put("type", "SAFEPOINT_STW"); evt.put("stwSeconds", stoppedSec); evt.put("lineNo", i + 1); evt.put("line", line); // 근처에서 Full GC 원인도 같이 잡아보는(단순 휴리스틱) // 바로 위/아래 몇 줄을 훑어서 Full GC pause 있으면 같이 기록 Double fullPauseMs = findNearbyFullGcPauseMs(lines, i, 15); if (fullPauseMs != null) evt.put("nearbyFullGcPauseMs", fullPauseMs); stwEvents.add(evt); } } } Map<String, Object> res = new LinkedHashMap<>(); res.put("stwThresholdSec", stwThresholdSec); res.put("stwEventCount", stwEvents.size()); res.put("events", stwEvents); res.put("hint", "SAFEPOINT_STW가 잡혔다면, 원인 후보: Full GC, allocation failure, humongous allocation, CPU throttling 등을 함께 확인하세요."); return res; } private Double findNearbyFullGcPauseMs(String[] lines, int idx, int window) { int start = Math.max(0, idx - window); int end = Math.min(lines.length - 1, idx + window); for (int i = start; i <= end; i++) { Matcher m = FULL_GC_PAUSE_MS.matcher(lines[i]); if (m.find()) return Double.parseDouble(m.group(1)); } return null; } }2-A-2) 테스트 방법
조작된 로그를 텍스트로 POST:
curl -X POST "http://localhost:8080/api/v1/na/gc/analyze?stwThresholdSec=1.0" \ -H "Content-Type: text/plain" \ --data-binary @gc.log여기서 결과 JSON에 stwEventCount > 0이면 “1초 이상 STW 발생”이 검출된 거야.
728x90반응형