소스: java-src/advanced/10_jvm_gc/Main.java 하나입니다. 4·5절은 자기 자신을 자식 JVM 으로 다시 띄웁니다.
cd java-src/advanced/10_jvm_gc
javac -encoding UTF-8 *.java && java -Dstdout.encoding=UTF-8 MainRuntime rt = Runtime.getRuntime();
rt.maxMemory(); // -Xmx
for (MemoryPoolMXBean p : ManagementFactory.getMemoryPoolMXBeans())
System.out.println(p.getName() + " " + p.getType() + " " + p.getUsage().getUsed());
for (GarbageCollectorMXBean gc : ManagementFactory.getGarbageCollectorMXBeans())
System.out.println(gc.getName() + " " + Arrays.toString(gc.getMemoryPoolNames()));힙: 현재 254 MB / 최대 4042 MB (물리 메모리의 1/4 이 기본 -Xmx)
풀 Metaspace 비힙 사용 879 KB
풀 G1 Eden Space 힙 사용 2048 KB
풀 G1 Old Gen 힙 사용 0 KB
풀 G1 Survivor Space 힙 사용 0 KB
GC G1 Young Generation 담당 풀 [G1 Eden Space, G1 Survivor Space, G1 Old Gen]
GC G1 Old Generation 담당 풀 [G1 Eden Space, G1 Survivor Space, G1 Old Gen]이 MXBean 들이 Actuator 의 /actuator/metrics/jvm.memory.used 와 Grafana 대시보드가 읽는 원천입니다. 물리 메모리 16GB 라 최대 힙이 4GB 로 잡혔습니다.
for (int i = 0; i < 100_000; i++) {
byte[] garbage = new byte[10_000]; // 만들고 바로 버림
sum += garbage.length;
} G1 Young Generation 7회 (누적 22 ms)
G1 Concurrent GC 0회 (누적 0 ms)
G1 Old Generation 0회 (누적 0 ms)
할당 합계 953 MB, 지금 힙 사용 1 MB ← 남은 게 없습니다1GB 를 만들었는데 Young GC 7번, 합계 22ms 입니다. 살아남는 객체가 없어서 복사할 것이 없기 때문입니다. "객체를 많이 만들면 느리다" 는 절반만 맞습니다. 많이 만들고 오래 붙잡을 때 느립니다.
static final Map<String, byte[]> CACHE = new HashMap<>(); // 만료 없음
for (int i = 0; i < 1000; i++) CACHE.put("key" + i, new byte[100_000]);
Map<String, byte[]> weak = new WeakHashMap<>();
for (int i = 0; i < 1000; i++) weak.put(new String("key" + i), new byte[10_000]); 시작 힙 사용 1 MB
static Map 에 100KB × 1000 저장 후 GC → 100 MB (안 줄어듦, 엔트리 1000)
clear() 후 GC → 1 MB
WeakHashMap 에 10KB × 1000 저장 직후 엔트리 184, 힙 5 MB
GC 후 엔트리 0, 힙 1 MB ← 키를 아무도 안 잡으면 사라짐static Map 은 GC 를 아무리 돌려도 100MB 그대로입니다. clear() 로 참조를 끊어야 돌아옵니다. WeakHashMap 은 넣는 도중에 이미 Young GC 가 돌아 184개만 남았고, 명시적 GC 후에는 0개입니다.
부모 JVM 이 자신을 -Xmx64m 으로 다시 띄웁니다. 자식은 1MB 배열을 리스트에 계속 넣습니다.
List<String> out = runChild("oom", "-Xmx64m", "-Xlog:gc",
"-XX:+HeapDumpOnOutOfMemoryError", "-XX:HeapDumpPath=" + dump); 자식 최대 힙 64 MB, 1MB 씩 할당하며 붙잡습니다
[0.091s][info][gc] GC(0) Pause Young (Concurrent Start) (G1 Humongous Allocation) 30M->28M(64M) 2.018ms
[0.102s][info][gc] GC(9) Pause Full (G1 Compaction Pause) 62M->62M(64M) 2.089ms
[0.104s][info][gc] GC(10) Pause Full (G1 Compaction Pause) 62M->62M(64M) 2.178ms
java.lang.OutOfMemoryError: Java heap space
Dumping heap to C:\...\Temp\lesson-oom.hprof ...
Heap dump file created [35702847 bytes in 0.019 secs]
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at Main.childOom(Main.java:137)
종료 코드 1
힙 덤프 생성: lesson-oom.hprof (34 MB) → VisualVM/MAT 로 엽니다62M->62M 두 번 뒤에 OOM 입니다. Full GC 로도 하나도 못 치웠다는 뜻이고, 덤프를 열면 ArrayList 하나가 byte[] 60개를 쥐고 있는 것이 보입니다. Humongous Allocation 은 G1 영역 절반보다 큰 객체를 뜻하며, 1MB 배열이 여기 해당합니다.
같은 작업(20MB 유지 + 20KB × 10만 개 단명 할당) 을 -Xmx256m 으로 네 번 돌립니다.
SerialGC 전체 170 ms | Copy 28회/15ms, MarkSweepCompact 0회/0ms
ParallelGC 전체 179 ms | PS MarkSweep 0회/0ms, PS Scavenge 25회/14ms
G1GC 전체 203 ms | G1 Young Generation 14회/14ms, G1 Old Generation 0회/0ms
ZGC 전체 369 ms | ZGC Cycles 8회/138ms, ZGC Pauses 24회/0ms이 작업은 살아남는 것이 적어 어느 GC 든 Old 수집이 0회입니다. 볼 것은 ZGC 줄입니다. 정지(Pauses) 는 24회 합쳐 0ms 인데 동시 작업(Cycles) 이 138ms 로, 그만큼 CPU 를 애플리케이션과 나눠 썼습니다. 응답 시간을 사면 처리량을 냅니다.
작은 힙·짧은 작업에서는 Serial 이 가장 빠릅니다. GC 스레드를 띄우는 비용조차 아깝기 때문입니다. 컨테이너 코어가 1개면 JVM 은 자동으로 Serial 을 고릅니다.
int[] 4 MB (4바이트 × 100만)
Integer[] 19 MB (헤더 12 + 값 4 + 참조 4)
String[] 57 MB (String 24 + byte[] 16+길이)같은 100만 개 숫자가 박싱하면 5배, 문자열로 들면 14배입니다. 조회 결과를 List<Map<String, Object>> 로 받는 코드가 왜 배치에서 OOM 을 내는지가 여기 있습니다.
Thread t = new Thread(null, () -> deep[0] = maxDepth(), "deep", 64L << 20); // 64MB 스택 기본 스택(main): 깊이 13430
64MB 스택 스레드: 깊이 4190969 ← 재귀가 깊으면 -Xss 또는 반복문으로기본 1MB 스택으로 약 1만 3천 단계입니다. 트리 깊이가 수만이 되는 재귀는 StackOverflowError 가 나므로, 스택을 키우기보다 반복문과 명시적 Deque 로 바꾸는 것이 정석입니다.