ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • G1 GC Garbage 가 왜 필요하지? 성능 개선 방법까지 정리
    카테고리 없음 2025. 12. 22. 10:52
    728x90
    반응형

    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는 구현마다 다르지만 큰 틀은 보통:

    1. Mark(표시): 살아있는 객체 찾기(그래프 탐색)
    2. Sweep(회수): 죽은 객체 메모리 반환
    3. Compact/Copy(정리): 단편화를 줄이기 위해 객체를 옮겨 붙이기(옵션/방식에 따라 다름)

     

    4) Young 객체는 어떤 식으로 처리되나

    객체가 생성되는 곳: Eden

    • 대부분의 자바 객체는 Young 영역(Eden) 에서 시작해.
    • “대부분 금방 죽는다”는 통계(Generational hypothesis) 때문에 Young을 자주 청소하는 게 효율적이야.

    Young GC(=Minor GC)가 일어날 때

    Eden이 꽉 차면 Young GC를 수행해. 이때 기본 흐름은:

    1. (STW) 루트 스캔
      • GC Roots(스레드 스택 지역변수, static, JNI 등)에서 시작해서 “살아있는 객체”를 찾음.
    2. 살아있는 객체만 복사(Copy)
      • Eden에서 살아남은 객체를 Survivor로 옮김
      • Survivor에 있던 객체도 살아있으면 다른 Survivor로 옮김(핑퐁)
    3. 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가 발생하는 대표 원인(현업에서 흔함)

    1. Promotion 실패 (Old가 받을 공간 부족)
    • Young GC로 살아남은 객체를 Old로 올려야 하는데 Old에 연속/가용 공간이 부족하면 문제.
    • 이때 “이대로는 못 옮긴다” → 더 큰 정리가 필요 → Full GC로 갈 수 있음.
    1. To-space exhausted (G1 복사 공간 부족)
    • G1은 수집 때 “여기로 복사할 자리(to-space)”가 필요해.
    • 근데 살아있는 객체가 예상보다 많거나 단편화/여유 region이 없으면 “복사할 곳이 없음” → Full GC 위험.
    1. Humongous 객체가 많음(큰 객체)
    • G1은 힙을 region으로 쪼개는데, region 크기보다 큰 객체는 “Humongous”로 특별 취급.
    • 이런 객체가 많아지면 region 사용이 비효율적으로 되고 회수 타이밍도 꼬이면서 Full GC 유발 요인이 됨.
      (예: 큰 byte[]/큰 캐시/큰 JSON/대형 컬렉션)
    1. Concurrent Marking이 제때 못 따라감(Concurrent mode failure)
    • 앱이 객체를 너무 빠르게 만들어 Old가 빨리 차는데,
    • 동시 마킹/회수가 따라가지 못하면 결국 “한 번에 크게 멈춰서 정리”가 필요해짐.

    Full GC가 발생하면 무슨 문제가 생기나?

    면접에서 가장 중요한 포인트 3개:

    1. 긴 STW로 인한 지연 폭발
    • Full GC는 ms가 아니라 수백 ms~수초까지도 갈 수 있음(힙 크기/라이브셋에 따라).
    • 스프링 웹 서비스면:
      • 응답 지연 증가
      • 타임아웃 증가
      • 로드밸런서에서 unhealthy 판정 가능
    1. 처리량(Throughput) 급감
    • 애플리케이션 스레드가 멈춰 있으니 요청을 못 처리함.
    • “평균 TPS”가 아니라 tail latency(p99/p999) 가 크게 악화됨.
    1. 연쇄 장애(캐스케이드)
    • 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.jar

     

     

    Humongous가 뭐냐?

    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
    반응형
Designed by Tistory.