이 사이트의 실습은 자바스크립트를 켜야 움직입니다. 아래는 같은 내용을 글로 적은 것입니다.
공부 › Java › JVM
GC와 메모리 누수
버려진 객체는 누가 언제 치우나
더 이상 쓰이지 않는 객체의 메모리를 JVM이 알아서 되찾는 일이 GC(가비지 컬렉션)입니다. 새 객체는 Eden에 만들어지고, Eden이 차면 Minor GC가 살아 있는 객체만 골라 옮깁니다. 여러 번 살아남은 객체는 Old로 올라가고, Old가 차면 훨씬 오래 걸리는 Full GC가 돕니다. 쓰지 않는데도 어딘가에서 계속 참조하고 있어 GC가 치우지 못하는 객체가 쌓이는 것이 메모리 누수이며, 누수가 있을 때 힙이 어떻게 되는지도 봅니다.
일부만 실행으로 확인 · JDK 21에서 방향만 확인(수치는 모형)
이 실습에 나오는 개념
- 가비지 컬렉션(GC)
- 프로그램이 더 이상 사용하지 않는 객체를 찾아 그 메모리를 자동으로 회수하는 JVM의 기능이다. 개발자가 직접 메모리를 해제하지 않아도 되어 해제 실수로 인한 오류를 막아 준다. 대신 수집하는 동안 애플리케이션이 잠시 멈출 수 있고, 참조가 남아 있는 객체는 회수하지 못한다.
- 도달 가능성과 GC 루트
- GC는 어떤 객체가 아직 쓰이는지를 "도달 가능한가"로 판단한다. 실행 중인 스레드의 지역 변수, static 변수처럼 항상 살아 있는 출발점(GC 루트)에서 참조를 따라갈 수 있는 객체는 살아 있는 것으로 보고, 따라갈 수 없는 객체는 회수 대상으로 본다.
- Young 세대와 Old 세대
- 대부분의 객체는 만들어진 직후 금방 쓸모없어진다는 관찰에 따라 힙을 나눈 구조다. 새 객체는 Young 세대(Eden 영역)에 만들어지고, Young 세대의 수집에서 여러 번 살아남은 객체는 Old 세대로 옮겨진다. 금방 죽는 객체가 모인 Young 세대만 자주 수집하면 적은 비용으로 많은 메모리를 회수할 수 있다.
- Minor GC와 Full GC
- Minor GC(Young GC)는 Young 세대만 수집하는 것으로 자주 일어나지만 보통 빨리 끝난다. Full GC는 힙 전체를 수집하는 것으로 시간이 오래 걸리고 애플리케이션 멈춤도 길다. Full GC가 자주 일어나거나 Full GC 후에도 메모리가 줄지 않으면 메모리 누수나 힙 크기 부족을 의심한다.
- Stop-the-world(STW)
- 가비지 컬렉션을 안전하게 수행하기 위해 애플리케이션의 모든 스레드를 일시 정지시키는 것이다. 이 시간 동안은 요청 처리가 멈추므로 응답 지연으로 나타난다. 최신 수집기(G1, ZGC)는 이 멈춤 시간을 짧게 유지하는 것을 목표로 설계되었다.
- 메모리 누수
- 더 이상 필요 없는 객체가 어딘가에서 계속 참조되고 있어 가비지 컬렉터가 회수하지 못하는 상태다. 시간이 지날수록 사용 메모리가 늘다가 결국 OutOfMemoryError로 이어진다. static 컬렉션에 계속 쌓이는 데이터, 만료가 없는 캐시, 스레드 풀에서 정리하지 않은 ThreadLocal이 대표적인 원인이다.
- StackOverflowError와 OutOfMemoryError
- StackOverflowError는 메서드 호출이 너무 깊어져 스레드의 스택 공간을 넘었을 때 발생하며 주로 종료 조건이 잘못된 재귀가 원인이다. OutOfMemoryError는 힙 등의 메모리가 부족해 객체를 더 할당할 수 없을 때 발생하며 메모리 누수나 한 번에 너무 많은 데이터를 올리는 것이 흔한 원인이다.
이렇게 바꿔 보세요
- 처음 설정으로 — 대부분의 객체가 금방 버려집니다. Minor GC만으로 유지됩니다.
- 오래 사는 객체가 많으면 — Old로 올라가는 객체가 늘어 Full GC가 돌기 시작합니다.
- 객체를 더 빨리 만들면 — Eden이 빨리 차서 Minor GC가 잦아집니다.
- 힙을 키우면 — GC 횟수가 줄어듭니다.
- 메모리 누수 — GC가 돌아도 Old가 비지 않다가 OutOfMemoryError로 끝납니다.
- 누수 상태에서 힙만 키우면 — 죽는 시점이 늦춰질 뿐입니다.
처음 설정으로 실행하면
- 1틱: 객체 5개를 만들었습니다. Eden 5/20칸 사용 중.
- 2틱: 객체 5개를 만들었습니다. Eden 10/20칸 사용 중.
- 3틱: 객체 5개를 만들었습니다. Eden 15/20칸 사용 중.
- 4틱: 객체 5개를 만들었습니다. Eden 20/20칸 사용 중.
- 5틱: Eden이 가득 찼습니다. Minor GC가 Young 영역(20개)만 검사해서, 참조되지 않는 18개를 버리고 살아 있는 2개를 옮겼습니다(Survivor로 2개, Old로 승격 0개). Eden은 비었습니다. 잠깐 멈춥니다(1만큼 멈춤).
- 5틱: 객체 5개를 만들었습니다. Eden 5/20칸 사용 중.
- 6틱: 객체 5개를 만들었습니다. Eden 10/20칸 사용 중.
- 7틱: 객체 5개를 만들었습니다. Eden 15/20칸 사용 중.
- 8틱: 객체 5개를 만들었습니다. Eden 20/20칸 사용 중.
- 9틱: Eden이 가득 찼습니다. Minor GC가 Young 영역(22개)만 검사해서, 참조되지 않는 18개를 버리고 살아 있는 4개를 옮겼습니다(Survivor로 2개, Old로 승격 2개). Eden은 비었습니다. 잠깐 멈춥니다(1만큼 멈춤).
- 9틱: 객체 5개를 만들었습니다. Eden 5/20칸 사용 중.
- 10틱: 객체 5개를 만들었습니다. Eden 10/20칸 사용 중.
- 11틱: 객체 5개를 만들었습니다. Eden 15/20칸 사용 중.
- 12틱: 객체 5개를 만들었습니다. Eden 20/20칸 사용 중.
- 13틱: Eden이 가득 찼습니다. Minor GC가 Young 영역(22개)만 검사해서, 참조되지 않는 18개를 버리고 살아 있는 4개를 옮겼습니다(Survivor로 2개, Old로 승격 2개). Eden은 비었습니다. 잠깐 멈춥니다(1만큼 멈춤).
- 13틱: 객체 5개를 만들었습니다. Eden 5/20칸 사용 중.
- 14틱: 객체 5개를 만들었습니다. Eden 10/20칸 사용 중.
- 15틱: 객체 5개를 만들었습니다. Eden 15/20칸 사용 중.
- 16틱: 객체 5개를 만들었습니다. Eden 20/20칸 사용 중.
- 17틱: Eden이 가득 찼습니다. Minor GC가 Young 영역(22개)만 검사해서, 참조되지 않는 18개를 버리고 살아 있는 4개를 옮겼습니다(Survivor로 2개, Old로 승격 2개). Eden은 비었습니다. 잠깐 멈춥니다(1만큼 멈춤).
- 17틱: 객체 5개를 만들었습니다. Eden 5/20칸 사용 중.
- 18틱: 객체 5개를 만들었습니다. Eden 10/20칸 사용 중.
- 19틱: 객체 5개를 만들었습니다. Eden 15/20칸 사용 중.
- 20틱: 객체 5개를 만들었습니다. Eden 20/20칸 사용 중.
- 21틱: Eden이 가득 찼습니다. Minor GC가 Young 영역(22개)만 검사해서, 참조되지 않는 18개를 버리고 살아 있는 4개를 옮겼습니다(Survivor로 2개, Old로 승격 2개). Eden은 비었습니다. 잠깐 멈춥니다(1만큼 멈춤).
- 21틱: 객체 5개를 만들었습니다. Eden 5/20칸 사용 중.
- 22틱: 객체 5개를 만들었습니다. Eden 10/20칸 사용 중.
- 23틱: 객체 5개를 만들었습니다. Eden 15/20칸 사용 중.
- 24틱: 객체 5개를 만들었습니다. Eden 20/20칸 사용 중.
- 25틱: Eden이 가득 찼습니다. Minor GC가 Young 영역(22개)만 검사해서, 참조되지 않는 18개를 버리고 살아 있는 4개를 옮겼습니다(Survivor로 2개, Old로 승격 2개). Eden은 비었습니다. 잠깐 멈춥니다(1만큼 멈춤).
- 25틱: 객체 5개를 만들었습니다. Eden 5/20칸 사용 중.
- 26틱: 객체 5개를 만들었습니다. Eden 10/20칸 사용 중.
- 27틱: 객체 5개를 만들었습니다. Eden 15/20칸 사용 중.
- 28틱: 객체 5개를 만들었습니다. Eden 20/20칸 사용 중.
- 29틱: Eden이 가득 찼습니다. Minor GC가 Young 영역(22개)만 검사해서, 참조되지 않는 18개를 버리고 살아 있는 4개를 옮겼습니다(Survivor로 2개, Old로 승격 2개). Eden은 비었습니다. 잠깐 멈춥니다(1만큼 멈춤).
- 29틱: 객체 5개를 만들었습니다. Eden 5/20칸 사용 중.
- 30틱: 객체 5개를 만들었습니다. Eden 10/20칸 사용 중.
- 31틱: 객체 5개를 만들었습니다. Eden 15/20칸 사용 중.
- 32틱: 객체 5개를 만들었습니다. Eden 20/20칸 사용 중.
- 33틱: Eden이 가득 찼습니다. Minor GC가 Young 영역(22개)만 검사해서, 참조되지 않는 18개를 버리고 살아 있는 4개를 옮겼습니다(Survivor로 2개, Old로 승격 2개). Eden은 비었습니다. 잠깐 멈춥니다(1만큼 멈춤).
- 33틱: 객체 5개를 만들었습니다. Eden 5/20칸 사용 중.
- 34틱: 객체 5개를 만들었습니다. Eden 10/20칸 사용 중.
- 35틱: 객체 5개를 만들었습니다. Eden 15/20칸 사용 중.
- 36틱: 객체 5개를 만들었습니다. Eden 20/20칸 사용 중.
- 37틱: Eden이 가득 찼습니다. Minor GC가 Young 영역(22개)만 검사해서, 참조되지 않는 18개를 버리고 살아 있는 4개를 옮겼습니다(Survivor로 2개, Old로 승격 2개). Eden은 비었습니다. 잠깐 멈춥니다(1만큼 멈춤).
- 37틱: 객체 5개를 만들었습니다. Eden 5/20칸 사용 중.
- 38틱: 객체 5개를 만들었습니다. Eden 10/20칸 사용 중.
- 39틱: 객체 5개를 만들었습니다. Eden 15/20칸 사용 중.
- 40틱: 객체 5개를 만들었습니다. Eden 20/20칸 사용 중.
실행 결과
- 대부분의 객체가 금방 버려져서 Minor GC만으로 충분했습니다. "대부분의 객체는 일찍 죽는다"는 가정이 맞을 때 세대를 나누는 방식이 효율적입니다.
코드
// 힙: Eden 20칸, Survivor 5칸, Old 40칸
while (running) { // 한 바퀴 = 한 틱
for (int i = 0; i < 5; i++) {
Event e = new Event(); // Eden에 생성
if (keepForAWhile(e)) { // 10%
recent.put(e.id(), e); // 10틱 뒤 제거됨
}
} // 나머지는 여기서 버려짐
}
이 모형의 전제
객체 크기가 모두 같고 힙을 칸 수로 센 단순한 세대별 GC 모형입니다. 승격 기준을 "Minor GC에서 두 번 살아남으면"으로 낮췄습니다(실제 기본 최대치는 15이며 GC가 상황에 따라 조정합니다). 멈춤 시간 1과 10은 Minor GC가 Full GC보다 훨씬 짧다는 관계만 나타내는 값입니다. G1이나 ZGC 같은 실제 수집기는 힙을 더 잘게 나누고 애플리케이션과 동시에 일하는 등 방식이 다르지만, 새 객체 영역과 오래된 객체 영역을 나누어 다루고 참조가 남은 객체는 치우지 못한다는 원리는 같습니다. 칸 수와 횟수는 모형의 값입니다. 다만 같은 상황을 이 PC의 JDK 21에서 64MB 힙으로 실제로 돌려 방향을 확인했습니다. 금방 버려지는 객체만 만들면 Young GC 24번에 Full GC 0번, 오래 붙잡는 객체가 섞이면 Full GC가 7번 돌았고(평균 멈춤 Young 2.1ms, Full 11.5ms), 지우지 않는 목록에 계속 담으면 Full GC를 37번 반복한 끝에 OutOfMemoryError로 끝났습니다. 힙을 128MB로 키우면 같은 누수가 약 두 배의 객체를 만든 뒤에 같은 오류로 끝났습니다.
앞 실습: 스택과 힙 · 다음 실습: JIT 워밍업