[JAVA]GC(Garbage Collection)

이권민·2025년 10월 6일

JAVA

목록 보기
2/3

목차

  1. 가비지 컬렉션 개요
  2. Reachable / Unreachable & GC Root
  3. Generational Hypothesis (GC 설계의 두 가지 가설)
  4. Heap 세대 구조
    • Young Generation (Eden, Survivor 0/1)
    • Old Generation
    • (참고) Permanent → Metaspace
  5. GC 동작 과정
    • STW (Stop-The-World)
    • Mark and Sweep (+Compaction)
  6. Minor GC vs Major(Full) GC
  7. Java는 Memory Leak이 절대 일어나지 않는가?
  8. OOM 발생 원인과 영역별 특징
  9. 대표적인 GC 알고리즘
  10. 참고

가비지 컬렉션 개요

가비지 컬렉션(GC): Heap 영역에서 동적으로 할당했던 메모리 중 필요 없어진 객체(garbage)를 주기적으로 식별·회수하는 프로세스

  • C/C++: GC가 없어 프로그래머가 수동 할당/해제. 해제 안하면 메모리 누수 발생, 해제한 메모리에 접근 시 댕글링 포인터 오류 발생
  • Java: 가비지 컬렉터가 메모리 관리를 대행 → 한정된 메모리를 효율적으로 사용, 개발자는 해제 로직 부담 감소
  • Python, JavaScript, Go 등 다수 언어도 GC 내장
🔎 댕글링 포인터(Dangling Pointer)
  • 포인터가 여전히 해제된 메모리 영역을 가리키는 것

Reachable / Unreachable & GC Root

garbage 판별 기준: Reachability (객체의 레퍼런스 여부)

  • Reachable: GC Root에서 직·간접 참조 가능한 객체(사용 중)
  • Unreachable: GC Root에서 더 이상 접근 불가수집 대상
    • Heap Area 객체의 메모리 주소를 가지고 있는 참조 변수가 삭제되는 현상이 발생 -> 참조하고 있지 않은 객체(Unreachable)들이 발생

🔎 GC Root
  • Stack: 지역 변수, 매개변수
  • Static 영역: static 변수 (method area)
  • JNI: 네이티브 코드에서 보유한 참조 (Native Method Stack)
  • Thread: 실행 중인 스레드 객체

GC 동작 과정

Mark and Sweep (+ Compaction)

  • Mark: GC Root로부터 그래프 순회, Reachable 객체 마킹
  • Sweep: 마킹되지 않은 Unreachable 객체 제거
  • Compaction(압축): 제거로 생긴 빈 공간을 뭉쳐 단편화(외부 단편화) 해소(연속 공간 확보, 메모리 할당 속도 향상, 일부 GC만 수행)

STW (Stop-The-World)

  • GC 중 JVM이 GC 관련 Thread를 제외한 모든 애플리케이션 스레드 중지 -> 오버헤드 발생
  • 참조 그래프를 안전하게 분석하기 위해 필요
  • 잦거나 긴 STW는 성능 저하 → 어플리케이션 사용성 유지하면서 효율적으로 GC 실행 -> STW 최소화가 GC 튜닝 핵심

Generational Hypothesis (GC 설계의 두 가지 가설)

1) 대부분의 객체는 금방 Unreachable 상태가 된다.(Weak Generational Hypothesis)
2) 오래된 객체 → 새로운 객체로의 참조는 매우 드물다. (Strong Generational Hypothesis)

→ 객체는 대부분 일회성이고, 메모리에 오래 남아있는 경우는 드물다. 젊은 -> 젊은 or 젊은 -> 오래된 참조가 대부분(minor GC 우선적으로 수행)
→ 효율적인 메모리관리를 위해 생존 기간에 따라 Heap을 물리적으로 분리
Young / Old 두 영역으로 설계

🔎 카드 테이블
  • old 영역의 객체가 young 영역의 객체 참조 시 카드테이블에 따로 기록 후 minor GC 시 young 영역과 카드테이블만 확인

Heap 세대 구조

Young Generation

  • 새롭게 생성된 객체가 할당(Allocation)되는 영역
  • 대부분의 객체가 금방 Unreachable 상태가 되기 때문에, 많은 객체가 Young 영역에 생성되었다가 사라진다.
  • 대부분 금방 사라짐 → 이 영역의 GC를 Minor GC라고 함
  • 효율적인 GC관리를 위해 Eden, survivor 0, survivor 1 영역으로 나눔

Eden

  • new 로 생성된 객체 저장
  • GC 후 살아남은 객체 → Survivor로 이동

Survivor 0 / Survivor 1

  • 최소 1번 이상 GC를 통과한 객체 저장
  • 항상 둘 중 하나는 비어 있어야 함 (카피/스왑 전략)
ℹ️ age 값 & Promotion
  • age: Survivor에서 생존 횟수 (객체 헤더에 저장, HotSpot 기본 임계값 31)
  • age가 임계값에 도달하거나 Survivor 용량 정책에 따라 Old로 Promotion

Old Generation

  • Young에서 오래 Reachable 상태를 유지한 객체가 복사/승격(Promotion) 되어 존재
  • 크기가 크고 GC 빈도는 낮음 → Major GC(=Full GC) 대상

Old가 큰 이유: 수명이 짧은 객체는 큰 공간을 필요로 하지 않으며 Young에서 대부분 수집되고, 큰 객체는 처음부터 Old에 할당되는 경우도 있기 때문


(참고) method area 영역 Permanent → Metaspace

  • Java 7까지: 힙영역, Permanent(permgen) 영역에 클래스 메타정보
  • Java 8부터: Metaspace(네이티브 메모리)로 이전

Minor GC vs Major(Full) GC

  • Minor GC: Young 영역(작음) 수집 → 짧고 빈번, 애플리케이션 영향 상대적으로 적음
  • Major/Full GC: Old 영역(큼) 수집 → 느리고 STW 길어짐, CPU 부하 큼
🔁 Young → Old까지 흐름(요약)
  1. Eden에 객체 생성
  2. Eden이 가득 차면 Minor GC → Mark
  3. 살아남은 객체는 Survivor로 이동, unreachable은 sweep
  4. 생존 객체 age++
  5. 다시 Eden이 가득 차면 반복. 생존 객체는 S0 ↔ S1로 카피
  6. age 임계 또는 정책 기준에 도달하면 Old로 Promotion
  7. Old가 부족해지면 Major/Full GC 발생

Java는 Memory Leak이 절대 일어나지 않는가?

GC는 참조가 끊긴(Unreachable) 객체만 회수.
여전히 참조 중인 객체는 “Reachable”로 간주되어 GC 불가메모리 누수 발생 가능

💥 누수 패턴 예시

1️⃣ static 변수가 객체를 계속 참조

정적 컬렉션은 프로그램 종료 시까지 유지되므로, 불필요한 참조를 제거하지 않으면 GC 불가.

import java.util.ArrayList;
import java.util.List;

public class StaticLeak {
    private static final List<byte[]> cache = new ArrayList<>();

    public static void main(String[] args) {
        // static 변수에 객체 참조 유지 → GC 불가능
        for (int i = 0; i < 10_000; i++) {
            cache.add(new byte[1024 * 256]); // 256KB
        }
        System.out.println("Cache size: " + cache.size());

        // ✅ 개선 예시
        cache.clear();              // 참조 해제
        // 또는 cache = new WeakHashMap<>(); 처럼 약참조 기반 구조 사용(상황에 따라)
    }
}
  • 강참조: new로 만든 객체를 변수에 담는 것. 메모리 누수의 원인
  • 약참조: GC가 돌 때 이 객체에 약참조만 남아 있으면 바로 수거 가능, 캐시, 임시 데이터 등에 자주 사용. ex) WeakHashMap

2️⃣ 지역 변수/매개변수를 전역 컬렉션에 저장

지역 변수 자체는 스택 프레임이 끝나면 사라지지만, 전역 컬렉션이 참조하면 GC 불가

import java.util.ArrayList;
import java.util.List;

public class LocalToGlobalLeak {
  private static final List<Object> holder = new ArrayList<>();

  static void process(byte[] buf) {
      holder.add(buf); // 전역 컬렉션이 참조 → GC 불가
  }

  public static void main(String[] args) {
      while (true) {
          byte[] buf = new byte[1024 * 128];
          process(buf);

          // ✅ 개선: 적시에 제거
          if (holder.size() > 1000) {
              holder.clear();
              System.gc();
              break;
          }
      }
      System.out.println("Holder size: " + holder.size());
  }
} 

3️⃣ JNI Global Reference 해제 누락

NewGlobalRef로 만든 전역 참조는 반드시 DeleteGlobalRef로 해제해야 함.

#include <jni.h>

static jobject g_ref = NULL;

JNIEXPORT void JNICALL Java_Leak_createGlobal(JNIEnv* env, jclass cls, jobject obj) {
   g_ref = (*env)->NewGlobalRef(env, obj); // 전역 참조(사실상 GC Root)
}

JNIEXPORT void JNICALL Java_Leak_releaseGlobal(JNIEnv* env, jclass cls) {
   if (g_ref != NULL) {
       (*env)->DeleteGlobalRef(env, g_ref); // 해제 누락 시 누수
       g_ref = NULL;
   }
}

// Leak.java
public class Leak {
   static { System.loadLibrary("native"); }
   private static native void createGlobal(Object o);
   private static native void releaseGlobal();

   public static void main(String[] args) {
       Object big = new byte[1024 * 1024];
       createGlobal(big);   // 네이티브 전역 참조로 고정
       // ... 네이티브에서 big을 계속 붙잡고 있으면 GC 불가
       releaseGlobal();     // ✅ 반드시 해제해야 함
   }
}

4️⃣ 캐시(Map) 적치 후 해제 누락 (+ WeakHashMap 오해)

WeakHashMap은 키만 약참조. 값이 키를 강참조하면 항목이 수거되지 않을 수 있음.

import java.util.Map;
import java.util.WeakHashMap;

public class WeakHashMapGotcha {
  static class Key {}
  static class Value { Object hold; Value(Object o) { this.hold = o; } }

  public static void main(String[] args) {
      Map<Key, Value> map = new WeakHashMap<>();
      Key k = new Key();
      Value v = new Value(k); // value가 key를 강참조
      map.put(k, v);
      k = null;               // 키 강참조 제거

      System.gc();
      // 여전히 map -> v -> key 경로가 존재 → 수거 안 될 수 있음
      System.out.println("Map size (may still be 1): " + map.size());

      // ✅ 개선: 값이 키를 참조하지 않도록 설계, 만료정책 있는 Caffeine/Guava Cache 사용
      map.clear();
  }
}

5️⃣ 스트림/자원 close() 누락 (파일/소켓/DB)

네이티브 핸들은 GC가 즉시 해제하지 못할 수 있으므로 명시적 종료가 중요.

import java.io.FileInputStream;
import java.io.IOException;

public class StreamLeak {
  public static void main(String[] args) throws IOException {
      // ❌ 누수 예시: 예외 발생 시 close 누락 가능
      FileInputStream fis = new FileInputStream("large.dat");
      byte[] data = fis.readAllBytes();
      // fis.close(); // 누락 시 핸들 누수

      // ✅ 권장: try-with-resources 사용
      try (FileInputStream in = new FileInputStream("large.dat")) {
  // 자동 close()
          byte[] buf = in.readAllBytes();
          System.out.println(buf.length);
      }
  }
}
  • 네이티브 핸들: 운영체제(OS)가 관리하는 파일, 소켓, DB 커넥션, 그래픽 리소스 등의 자원을 가리키는 식별자.

6️⃣ equals()/hashCode() 잘못 구현 → Map에 데이터 누적

동등성 규약 위반 시 같은 논리 키가 중복 저장됨.

import java.util.HashMap;
import java.util.Map;

public class BadKeyLeak {
  static class BadKey {
      String id;
      BadKey(String id) { this.id = id; }
      // equals(), hashCode() 미구현 → 객체마다 다른 키로 취급
  }

  public static void main(String[] args) {
      Map<BadKey, byte[]> map = new HashMap<>();
      for (int i = 0; i < 100_000; i++) {
          map.put(new BadKey("SAME"), new byte[1024]); // 사실상 같은 개념의 키
      }
      System.out.println("Map size: " + map.size()); // 비정상적으로 큼

      // ✅ 개선: 규약 준수
      // class GoodKey { String id; ... equals/hashCode 일관 구현 }
  }
}

7️⃣ 키가 불변(Immutable)이 아님

키가 변경되면 해시 버킷 위치가 달라져 탐색/제거가 불가능해짐.

import java.util.HashMap;
import java.util.Map;
import java.util.Objects;

public class MutableKeyLeak {
    static class Key {
        String name;
        Key(String name) { this.name = name; }
        @Override public int hashCode() { return Objects.hash(name); }
// 저장위치 결정
        @Override public boolean equals(Object o) {
            return o instanceof Key && Objects.equals(name, ((Key) o).name);
        }
    }
// 객체, 타입, 값 비교
    public static void main(String[] args) {
        Map<Key, String> map = new HashMap<>();
        Key k = new Key("A");
        map.put(k, "value");
        k.name = "B"; // 🔥 키 변경(불변성 깨짐)

        System.out.println(map.get(new Key("A"))); // null
        System.out.println("Map size: " + map.size()); // 제거도 못 하고 남음

        // ✅ 개선: 키는 String/UUID/record 등 불변 타입 사용
    }
}

8️⃣ 자료구조 구현 오류(참조 해제 누락)

직접 구현한 연결 구조에서 내부 필드 참조를 지우지 않으면 누수.

public class CustomListLeak {
  static class Node {
      Object data;
      Node next;
  }

  private Node head;

  void add(Object o) {
      Node n = new Node();
      n.data = o;
      n.next = head;
      head = n;
  }

  void clearBad() {
      // ❌ 연결만 끊고 내부 참조 유지 → 데이터 수거 지연/불가
      while (head != null) {
          head = head.next; // data 참조가 남아있음
      }
  }

  void clearGood() {
      // ✅ 내부 참조까지 해제
      while (head != null) {
          Node tmp = head;
          head = head.next;
          tmp.data = null; // 내부 참조 제거
          tmp.next = null;
      }
  }
}

9️⃣ 래퍼 과다 생성/Autoboxing 남용

원시 타입 대신 래퍼를 반복 생성하면 불필요한 객체가 폭증(수거 가능하나 힙 압박).

public class WrapperLeak {
  public static void main(String[] args) {
      Long sum = 0L; // 오토박싱 경로 주의
      for (long i = 0; i < 1_000_000; i++) {
          sum += i; // 매 반복마다 Long 객체 생성 가능
      }
      System.out.println(sum);

      // ✅ 개선: 원시 타입 사용
      long sum2 = 0L;
      for (long i = 0; i < 1_000_000; i++) {
          sum2 += i; // 객체 생성 없음
      }
      System.out.println(sum2);
  }
}
  • 원시타입: 객체가 아닌 값 저장
  • 래퍼 클래스(Wrapper Class): 원시 타입을 객체로 감싼 클래스.
  • Autoboxing & Unboxing: 원시 타입 ↔ 래퍼 클래스 간 자동 변환을 해주는 기능. Autoboxing은 매번 새로운 객체를 생성할 수 있음 → 메모리 낭비.

OOM 발생 원인과 영역별 특징

  • Young: Eden/S0/S1보다 큰 객체, 과도한 객체 생성 또는 빠른 할당 속도 → OOM
  • Old: Promotion 누적 + 강한 참조 유지로 공간 고갈 → OOM
  • Metaspace: 클래스 로딩 과다, bytecode weaving 남용/라이브러리 버그 → OOM
  • Native(OS): 스레드/스택, NIO 등 네이티브 메모리 고갈 → OOM

Old에서 누적 → Major GC 빈번 → STW 증가 및 애플리케이션 지연 → 결국 OutOfMemoryError로 종료 가능


대표적인 GC 알고리즘

  • Serial GC

    • 단일 스레드로 동작하는 가장 기본적인 GC
    • 소규모 Heap에 적합하며, STW(Stop The World) 시간이 김
    • CPU 코어가 1개인 단순 애플리케이션 환경에서 사용 가능
    • 최근 Java 환경에서는 거의 사용되지 않음
  • Parallel GC / Parallel Old

    • 멀티스레드 기반 수집기, 여러 스레드가 병렬로 Minor GC 수행
    • 처리량(Throughput) 중심의 GC (STW는 발생하지만 빠름)
    • Java 8의 기본 GC
    • Serial GC보다 STW 시간이 짧고 CPU 자원을 적극 활용
  • CMS (Concurrent Mark Sweep)

    • Old 영역 GC 중 발생하는 STW를 최소화하기 위해
      애플리케이션 스레드와 GC 스레드가 일부 단계를 병행(Concurrent) 수행
    • 응답 시간(Responsiveness)을 우선시
    • 단점: 메모리 단편화(Fragmentation) 발생 가능 ,CPU 사용량이 높음
    • Java 9부터는 Deprecated (G1로 대체)
  • G1 GC (Garbage First)

    • 전체 힙을 작은 Region 단위로 분할
    • 가비지가 가장 많은 Region(Garbage-First) 을 우선 정리 → 효율적
    • 전체 Heap을 한 번에 스캔하지 않기 때문에 STW 예측 가능
    • 처리량과 응답 시간의 균형이 우수
    • Java 9 이후 기본 GC
  • Shenandoah GC

    • 초저지연(ultra-low pause time) 지향 GC
    • OpenJDK 12+에서 사용 가능
    • 대부분의 GC 작업을 애플리케이션 실행과 완전히 병행(Concurrent) 수행
    • STW 시간이 수 ms 단위로 매우 짧음
  • ZGC (Z Garbage Collector)

    • 대규모 Heap (TB 단위)에서도 ms 수준 STW 유지
    • 객체를 이동시키는 동안에도 참조 무결성을 유지하도록 Load Barrier 기법 사용
    • 지연 시간(latency) 최적화에 특화된 GC
    • Java 15 이후 Production Ready로 채택됨

참고


profile
이것저것이것 개발자

0개의 댓글