Java (7) - GC: 안 쓰는 객체는 언제 사라지나
힙의 Young과 Old 구조를 보고 GC 로그를 켜서 실제로 도는 것을 확인한 뒤 힙을 작게 줘서 OutOfMemoryError를 내보는 것을 다룹니다.
자바 기초 시리즈의 7편이자 마지막 편입니다. 전체 목차는 0편에 있습니다.
1편에서 객체는 전부 힙에 있다고 했습니다. 그런데 힙은 유한합니다. 안 쓰는 객체를 누군가는 치워야 합니다.
왜 자동인가
C에서는 손으로 치웁니다.
1
2
3
char *buf = malloc(1024);
// ... 쓰고 ...
free(buf); // 안 부르면 새고, 두 번 부르면 터진다
이 한 줄을 빠뜨리면 메모리 누수고, 두 번 부르면 프로그램이 죽습니다. 이미 해제한 메모리를 가리키는 포인터를 쓰면 더 나쁜 일이 납니다.
자바는 이 결정을 개발자에게서 가져갔습니다. free()가 없습니다. 대신 GC가 “아무도 안 가리키는 객체”를 찾아서 치웁니다.
1
2
3
Member m = new Member("김");
m = null; // 이제 아무도 그 객체를 안 가리킨다
// 언젠가 GC가 치운다. 언제인지는 모른다
“아무도 안 가리킨다”의 기준은 이렇습니다. 실행 중인 스레드의 스택, static 필드 같은 뿌리(GC Root) 에서 출발해서 참조를 따라갔을 때 닿지 않는 객체입니다.
1
2
3
4
5
6
7
8
GC Root 힙
┌───────────────┐
│ 스레드 스택 │──▶ ■ ──▶ ■ ──▶ ■ ← 닿는다. 살린다
│ static 필드 │──▶ ■
└───────────────┘
■ ──▶ ■ ← 안 닿는다. 치운다
▲ │ (서로 가리켜도 소용없다)
└─────┘
서로 가리키고 있어도 뿌리에서 못 닿으면 치웁니다. 참조 카운팅 방식과 달라서 순환 참조가 문제되지 않습니다.
힙의 구조
힙은 한 덩어리가 아닙니다. 나이별로 나눠져 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
┌─────────────────────────── 힙 (Heap) ────────────────────────────┐
│ │
│ ◀─────────────── Young ───────────────▶ ◀────── Old ──────▶ │
│ │
│ ┌──────────────────┐ ┌──────┐ ┌──────┐ ┌──────────────────┐ │
│ │ Eden │ │ S0 │ │ S1 │ │ 오래 살아남은 │ │
│ │ new 하면 여기 │ │ │ │ │ │ 객체들 │ │
│ └──────────────────┘ └──────┘ └──────┘ └──────────────────┘ │
│ Survivor 두 칸 │
│ │
│ Minor GC 가 청소 (자주, 빠르게) Major GC 가 청소 │
│ (가끔, 오래) │
└──────────────────────────────────────────────────────────────────┘
이렇게 나눈 근거는 관찰 하나입니다. 대부분의 객체는 만들어지자마자 죽습니다.
1
2
3
public String toDto(Member m) {
return new MemberResponse(m.getName(), m.getAge()).toString();
}
MemberResponse 객체는 메서드가 끝나면 아무도 안 가리킵니다. 웹 애플리케이션이 만드는 객체의 대부분이 이렇습니다. 요청 하나 처리하는 동안만 살아 있습니다.
그래서 살아남은 것만 찾아서 옮기는 쪽이 훨씬 빠릅니다. 죽은 게 99%면, 산 1%를 다른 곳으로 옮기고 나머지는 통째로 비워버리면 됩니다. 하나씩 지우고 다닐 필요가 없습니다.
1
2
3
4
5
6
7
8
9
10
11
12
① new 하면 Eden 에 들어간다
Eden [■■■■■■■■■■■■] 가득 참
│
② Minor GC ▼ 살아 있는 것만 Survivor 로 옮긴다
Eden [ ] 통째로 비움
S0 [■■] 나이 +1
│
③ 계속 살아남으면 ▼ Survivor 를 오가며 나이를 먹는다
S0 ⇄ S1 나이 1, 2, 3, ...
│
④ 나이가 임계값을 넘으면 ▼
Old [■] 승격(promotion)
| Minor GC | Major GC (Full GC) | |
|---|---|---|
| 대상 | Young 영역 | Old 영역 (또는 힙 전체) |
| 빈도 | 자주 | 가끔 |
| 시간 | 짧다 (보통 ms 단위) | 길다 |
| 원인 | Eden이 가득 참 | Old가 가득 참 |
DB의 버퍼 풀 같은 캐시 구조와 나란히 놓으면 이해가 붙습니다. 그런 캐시에서도 자주 쓰는 페이지는 young 리스트, 오래 살아남은 페이지는 old 리스트로 나눠 관리하는 방식을 씁니다. “최근 것과 오래된 것을 나눠서 다르게 다룬다”는 발상이 같습니다.
GC 로그를 켠다
여기까지가 그림이고, 이제 실제로 도는 걸 봅니다.
확인 1: -Xlog:gc
1
2
3
4
5
6
7
8
9
10
public class Gc {
public static void main(String[] args) {
System.out.println("최대 힙 = " + Runtime.getRuntime().maxMemory() / 1024 / 1024 + "MB");
for (int i = 0; i < 2_000_000; i++) {
byte[] garbage = new byte[1024]; // 만들고 바로 버린다
if (i % 500_000 == 0) System.out.println("i = " + i);
}
System.out.println("끝");
}
}
garbage는 다음 반복이 오면 아무도 안 가리킵니다. 1KB짜리를 200만 번 만드니 총 2GB어치를 만들지만, 힙을 64MB만 줘도 돕니다.
1
java -Xlog:gc -Xmx64m Gc.java
1
2
3
4
5
6
7
8
9
10
[0.002s][info][gc] Using G1
[0.239s][info][gc] GC(0) Pause Young (Normal) (G1 Evacuation Pause) 14M->3M(62M) 3.778ms
최대 힙 = 64MB
i = 0
[0.315s][info][gc] GC(1) Pause Young (Normal) (G1 Evacuation Pause) 38M->5M(62M) 3.642ms
[0.324s][info][gc] GC(2) Pause Young (Normal) (G1 Evacuation Pause) 38M->5M(62M) 3.128ms
[0.329s][info][gc] GC(3) Pause Young (Normal) (G1 Evacuation Pause) 40M->5M(62M) 1.269ms
[0.332s][info][gc] GC(4) Pause Young (Normal) (G1 Evacuation Pause) 40M->5M(62M) 1.168ms
...
[0.373s][info][gc] GC(20) Pause Young (Normal) (G1 Evacuation Pause) 43M->5M(64M) 0.242ms
한 줄을 읽는 법입니다.
1
2
3
4
5
6
7
8
9
10
[0.315s][info][gc] GC(1) Pause Young (Normal) (G1 Evacuation Pause) 38M->5M(62M) 3.642ms
│ │ │ │ │ │ │ │
│ │ │ │ │ │ │ └─ 멈춘 시간
│ │ │ │ │ │ └─ 전체 힙 크기
│ │ │ │ │ └─ GC 후 사용량
│ │ │ │ └─ GC 전 사용량
│ │ │ └─ 이유
│ │ └─ Young 영역만 청소 (Minor GC)
│ └─ 몇 번째 GC인가
└─ JVM 시작 후 경과 시간
38M->5M 이 핵심입니다. 38MB 중 33MB가 쓰레기였고 살아남은 건 5MB뿐입니다. 앞에서 말한 “대부분의 객체는 금방 죽는다”가 그대로 나타납니다.
그리고 한 번 도는 데 1~4ms입니다. Minor GC는 이 정도입니다.
2GB어치를 만들었는데 힙 64MB로 끝난 이유도 여기 있습니다. 같은 자리를 20번 넘게 재활용했습니다.
GC 로그의 정확한 문구와 숫자는 JDK 버전, GC 종류, 장비, 힙 크기에 따라 전부 다릅니다. 위 출력은 예시고, 봐야 할 건
앞->뒤(전체)형식과 앞뒤 차이입니다.
힙이 모자라면
확인 2: OutOfMemoryError를 내본다
이번에는 만든 객체를 버리지 않습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
import java.util.*;
public class Oom {
public static void main(String[] args) {
List<byte[]> keep = new ArrayList<>();
int i = 0;
while (true) {
keep.add(new byte[64 * 1024]); // 리스트가 계속 잡고 있다
i++;
if (i % 100 == 0) System.out.println("담아둔 크기 = " + (i * 64 / 1024) + "MB");
}
}
}
1
java -Xmx32m Oom.java
1
2
3
4
5
6
담아둔 크기 = 6MB
담아둔 크기 = 12MB
담아둔 크기 = 18MB
담아둔 크기 = 25MB
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at Oom.main(Oom.java:8)
java.lang.OutOfMemoryError: Java heap space. 힙에 자리가 없다는 뜻입니다.
keep 리스트가 모든 배열을 가리키고 있으니 GC가 치울 수 있는 게 하나도 없습니다. GC는 최선을 다했지만 살릴 것밖에 없었습니다.
6편의 계층에서 봤듯 OutOfMemoryError는 Exception이 아니라 Error입니다. 잡아서 복구하는 게 아닙니다. 잡아도 다음 new에서 또 납니다.
메시지 뒤에 붙는 문구로 무엇이 모자란지 구분합니다.
| 메시지 | 뜻 |
|---|---|
Java heap space |
힙이 모자라다. 대부분 이것 |
Metaspace |
클래스 메타데이터 영역이 모자라다 |
unable to create native thread |
스레드를 더 못 만든다 (힙 문제가 아님) |
Requested array size exceeds VM limit |
배열 하나가 너무 크다 |
Stop-the-World
GC가 도는 동안 애플리케이션 스레드가 전부 멈춥니다. 이걸 Stop-the-World라고 합니다.
1
2
3
4
애플리케이션 ████████ ██████████ ████████████
GC ▓▓▓▓ ▓▓▓▓▓
▲ ▲
여기서 모든 스레드가 멈춘다
멈춰야 하는 이유는 단순합니다. 객체를 옮기는 중에 다른 스레드가 그 객체를 참조하면 안 되기 때문입니다. 앞의 그림에서 Eden의 생존자를 Survivor로 옮긴다고 했는데, 옮기면 주소가 바뀝니다.
확인 1에서 본 1~4ms는 문제가 안 됩니다. 문제는 Old 영역까지 청소하는 Full GC입니다. 힙이 크면 수백 ms에서 수 초까지 갑니다.
“평소에는 빠른데 가끔 몇 초씩 느려진다” 는 증상은 여기가 후보입니다. 로그도 정상이고 SQL도 정상인데 응답 시간만 튀면 GC 로그를 봅니다.
GC 종류
이름과 용도만 정리하면 이렇습니다.
| GC | 특징 | 언제 |
|---|---|---|
| Serial | 스레드 하나로 청소 | 힙이 작고 코어가 하나일 때 |
| Parallel | 여러 스레드로 청소. 처리량 위주 | 멈춤 시간보다 총 처리량이 중요할 때 |
| G1 | 힙을 작은 영역으로 쪼개서 조금씩. Java 9부터 기본 | 대부분의 경우 |
| ZGC | 멈춤 시간을 아주 짧게 유지 | 힙이 크고 지연 시간이 중요할 때 |
0편에서 확인했듯 Java 21의 기본은 G1입니다. 아무 옵션도 안 주면 G1이 돕니다.
확인 3: GC를 바꿔본다
확인 1과 완전히 같은 코드를 GC만 바꿔서 돌립니다.
1
java -XX:+UseSerialGC -Xlog:gc -Xmx64m Gc.java
1
2
3
4
5
6
7
8
9
[0.001s][info][gc] Using Serial
[0.245s][info][gc] GC(0) Pause Young (Allocation Failure) 16M->3M(59M) 5.548ms
최대 힙 = 61MB
i = 0
[0.310s][info][gc] GC(1) Pause Young (Allocation Failure) 20M->4M(59M) 4.655ms
[0.316s][info][gc] GC(2) Pause Young (Allocation Failure) 21M->4M(60M) 1.635ms
[0.318s][info][gc] GC(3) Pause Young (Allocation Failure) 21M->4M(60M) 0.086ms
[0.320s][info][gc] GC(4) Pause Young (Allocation Failure) 21M->4M(60M) 0.057ms
...
세 군데가 다릅니다.
| G1 | Serial | |
|---|---|---|
| 첫 줄 | Using G1 |
Using Serial |
| 원인 문구 | (G1 Evacuation Pause) |
(Allocation Failure) |
| 최대 힙 | 64MB | 61MB |
Using 한 줄만 봐도 어떤 GC가 도는지 알 수 있습니다. 원인 문구도 GC마다 다릅니다. 같은 -Xmx64m인데 최대 힙이 다르게 나오는 건, GC마다 내부적으로 영역을 나누는 방식이 달라서입니다.
-XX:+UseParallelGC로 바꾸면 Using Parallel이 나옵니다.
힙이 다 찼을 때의 로그
확인 2의 Oom 코드를 Serial GC로 돌리면 마지막 부분이 이렇습니다.
1
java -XX:+UseSerialGC -Xmx32m -Xlog:gc Oom.java
1
2
3
4
5
6
7
담아둔 크기 = 25MB
[0.328s][info][gc] GC(6) Pause Full (Allocation Failure) 30M->30M(30M) 4.814ms
[0.335s][info][gc] GC(7) Pause Full (Allocation Failure) 30M->29M(30M) 7.150ms
[0.339s][info][gc] GC(8) Pause Full (Allocation Failure) 30M->30M(30M) 3.885ms
[0.343s][info][gc] GC(9) Pause Full (Allocation Failure) 30M->30M(30M) 3.885ms
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at Oom.main(Oom.java:8)
Pause Young이 아니라 Pause Full입니다. 그리고 30M->30M 입니다. 청소했는데 1MB도 안 줄었습니다.
이게 죽기 직전의 전형적인 모습입니다. Full GC가 연달아 돌면서 아무것도 못 줄이고, CPU만 태우다가 결국 터집니다. 운영 중이라면 OutOfMemoryError가 나기 전부터 응답이 이미 심하게 느려집니다.
실제 운영에서는
OutOfMemoryError가 뜬 시점보다 훨씬 전에 이 로그가 시작됩니다. GC 로그를 켜두면 이 구간이 남습니다.-XX:+HeapDumpOnOutOfMemoryError를 붙여두면 터지는 순간의 힙 덤프도 남길 수 있습니다.
GC가 있어도 메모리는 샌다
“GC가 알아서 치워준다”의 조건은 아무도 안 가리킬 때입니다. 누군가 계속 가리키고 있으면 GC는 손을 못 댑니다.
확인 4: static 컬렉션에 쌓기
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import java.util.*;
public class Leak {
static final List<byte[]> CACHE = new ArrayList<>(); // static 은 GC Root 다
static long usedMb() {
Runtime r = Runtime.getRuntime();
return (r.totalMemory() - r.freeMemory()) / 1024 / 1024;
}
static void handleRequest(boolean cache) {
byte[] data = new byte[64 * 1024];
if (cache) CACHE.add(data);
}
public static void main(String[] args) throws Exception {
System.out.println("[A] 캐시에 안 담는다");
for (int i = 1; i <= 3000; i++) {
handleRequest(false);
if (i % 1000 == 0) System.out.println(" 요청 " + i + "건 처리 후 힙 = " + usedMb() + "MB");
}
System.out.println("[B] static 리스트에 담아둔다");
for (int i = 1; i <= 1500; i++) {
handleRequest(true);
if (i % 500 == 0) System.out.println(" 요청 " + i + "건 처리 후 힙 = " + usedMb() + "MB");
}
System.gc();
Thread.sleep(200);
System.out.println("System.gc() 호출 후 힙 = " + usedMb() + "MB");
System.out.println("CACHE 안의 개수 = " + CACHE.size());
}
}
1
2
3
4
5
6
7
8
9
10
[A] 캐시에 안 담는다
요청 1000건 처리 후 힙 = 22MB
요청 2000건 처리 후 힙 = 19MB
요청 3000건 처리 후 힙 = 16MB
[B] static 리스트에 담아둔다
요청 500건 처리 후 힙 = 38MB
요청 1000건 처리 후 힙 = 71MB
요청 1500건 처리 후 힙 = 104MB
System.gc() 호출 후 힙 = 96MB
CACHE 안의 개수 = 1500
[A]는 요청을 3000건 처리했는데 힙이 오히려 줄었습니다. 만든 객체를 아무도 안 가리키니 GC가 계속 치웠습니다.
[B]는 요청 500건마다 33MB씩 늘어납니다. 그리고 System.gc()를 명시적으로 불러도 96MB가 남습니다. CACHE가 1500개를 붙잡고 있기 때문입니다.
코드에 버그가 없습니다. CACHE.add()는 시킨 대로 동작했습니다. 그런데 아무도 지우지 않습니다.
새는 자리는 대체로 정해져 있습니다.
| 패턴 | 왜 새나 |
|---|---|
static 컬렉션에 계속 add |
static은 GC Root라 애플리케이션이 끝날 때까지 살아 있다 |
| 크기 제한 없는 자체 캐시 | 넣기만 하고 빼는 코드가 없다 |
| 리스너·콜백을 등록만 하고 해제 안 함 | 등록한 쪽이 계속 참조를 붙잡는다 |
ThreadLocal을 remove() 안 함 |
스레드 풀에서는 스레드가 재사용돼 값이 계속 남는다 |
6편의 close() 누락 |
이건 힙이 아니라 힙 밖 자원이라 GC와 무관하다 |
마지막 줄이 중요합니다. GC는 힙만 봅니다. 파일 핸들, 소켓, DB 커넥션은 힙 밖에 있어서 GC가 관여하지 않습니다. try-with-resources가 따로 있는 이유입니다.
System.gc()는 “GC 좀 돌려주세요”라는 부탁이지 명령이 아닙니다. JVM이 무시할 수도 있습니다. 위 실험에서는 확인을 위해 불렀지만, 운영 코드에 넣으면 안 됩니다. Full GC를 유발해서 오히려 멈춤을 만듭니다.
시리즈를 마치며
여덟 편이 결국 한 줄로 이어집니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
1편 객체는 힙에 있고 변수는 그 자리를 가리킨다
│
2편 그럼 "같다"는 게 뭔가 → equals 와 hashCode
│
3편 그 hashCode 로 자리를 정하는 게 HashMap 이다
│
4편 그 자리에 들어가는 타입은 컴파일 후 사라진다
│
5편 그 컬렉션을 다루는 다른 방법이 스트림이다
│
6편 중간에 실패하면 예외로 빠져나간다
│
7편 그리고 안 쓰는 객체는 힙에서 사라진다
면접에서 자주 나오는 질문으로 바꾸면 이렇습니다.
| 질문 | 어디에 |
|---|---|
| 자바는 값 전달인가요 참조 전달인가요 | 1편 |
equals만 재정의하면 어떻게 되나요 |
2편 |
HashMap은 왜 O(1)인가요, 언제 느려지나요 |
3편 |
| 제네릭은 왜 배열을 못 만드나요 | 4편 |
스트림이 for보다 항상 빠른가요 |
5편 |
| 체크 예외를 왜 안 쓰나요 | 6편 |
| GC가 있는데 메모리 누수가 나나요 | 이 편 |
전부 main 메서드 하나로 확인했습니다. 스프링도 DB도 Docker도 없었습니다. 답이 궁금하면 직접 재현할 수 있다는 게 이 시리즈의 유일한 주장입니다.
내가 헷갈렸던 지점
정리
- GC는 뿌리(스레드 스택,
static필드)에서 참조를 따라갔을 때 닿지 않는 객체를 치운다. 서로 가리켜도 뿌리에서 못 닿으면 치우므로 순환 참조는 문제가 아니다 - 힙은 Young(Eden + Survivor)과 Old로 나뉜다. 근거는 대부분의 객체가 만들어지자마자 죽는다는 관찰이고, 그래서 살아남은 것만 옮기고 나머지는 통째로 비우는 쪽이 빠르다
- Minor GC는 Young만 청소하고 보통 ms 단위로 끝난다. Old까지 청소하는 Full GC는 수백 ms에서 수 초까지 간다. 응답 시간이 가끔 튀는 증상의 후보다
-Xlog:gc한 줄이면 GC가 도는 걸 볼 수 있다.38M->5M(62M)에서 앞뒤 차이가 곧 치운 양이다. 2GB어치를 만들어도 같은 자리를 재활용하면 힙 64MB로 끝난다- 힙이 모자라면
java.lang.OutOfMemoryError: Java heap space다.Exception이 아니라Error라 잡아서 복구하는 게 아니다. 터지기 직전에는Pause Full이 연달아 돌면서30M->30M처럼 아무것도 못 줄이는 로그가 남는다 - GC 종류는 Serial / Parallel / G1 / ZGC고 Java 21의 기본은 G1이다.
-XX:+UseSerialGC로 바꾸면Using Serial과(Allocation Failure)처럼 로그가 달라진다 - GC가 있어도 메모리는 샌다. 누군가 계속 가리키고 있으면 치울 수 없다.
static컬렉션, 크기 제한 없는 캐시, 해제 안 한 리스너,ThreadLocal이 흔한 자리다. 파일·소켓·커넥션은 힙 밖이라 GC와 무관하고close()가 필요하다
이것으로 자바 기초 시리즈를 마칩니다. 전체 목차는 0편에 있습니다.