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

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

static 변수 (method area) 


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

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


💥 누수 패턴 예시GC는 참조가 끊긴(Unreachable) 객체만 회수.
여전히 참조 중인 객체는 “Reachable”로 간주되어 GC 불가 → 메모리 누수 발생 가능
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<>(); 처럼 약참조 기반 구조 사용(상황에 따라)
}
}
지역 변수 자체는 스택 프레임이 끝나면 사라지지만, 전역 컬렉션이 참조하면 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());
}
}
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(); // ✅ 반드시 해제해야 함
}
}
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();
}
}
네이티브 핸들은 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);
}
}
}
동등성 규약 위반 시 같은 논리 키가 중복 저장됨.
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 일관 구현 }
}
}
키가 변경되면 해시 버킷 위치가 달라져 탐색/제거가 불가능해짐.
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 등 불변 타입 사용
}
}
직접 구현한 연결 구조에서 내부 필드 참조를 지우지 않으면 누수.
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;
}
}
}
원시 타입 대신 래퍼를 반복 생성하면 불필요한 객체가 폭증(수거 가능하나 힙 압박).
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);
}
}
Old에서 누적 → Major GC 빈번 → STW 증가 및 애플리케이션 지연 → 결국 OutOfMemoryError로 종료 가능
Serial GC
Parallel GC / Parallel Old
CMS (Concurrent Mark Sweep)
G1 GC (Garbage First)
Shenandoah GC
ZGC (Z Garbage Collector)