힙은 남았는데 서비스가 멈췄다 · 힙 덤프에서 범인을 찾는다 · 이론
OutOfMemoryError 는 프로세스를 죽이지 않는다
한 줄 요약
OutOfMemoryError 가 났을 때 필요한 것은 그 순간의 힙 덤프와 프로세스를 확실히 죽이는 설정이다. -XX:+HeapDumpOnOutOfMemoryError 가 전자를, -XX:+ExitOnOutOfMemoryError 가 후자를 맡는다. 누수의 범인은 클래스 히스토그램의 첫 줄과 덤프의 지배 트리에서 찾는다.
왜 이게 필요했나
"캐시" 라는 이름의 static HashMap 이 있었다. 넣기만 하고 비우지 않았다. 하루 뒤 힙이 차고 Full GC 가 연달아 돌다가 java.lang.OutOfMemoryError: Java heap space 가 났다. 그런데 프로세스는 죽지 않았다 — 예외는 한 스레드에서 났을 뿐이고, 다른 스레드들은 거의 없는 힙에서 GC 를 반복하며 응답만 못 하는 상태로 몇 시간을 버텼다. 헬스체크는 통과했다(그 요청은 메모리를 거의 안 쓴다). 로드밸런서는 이 인스턴스를 빼지 않았다. 재시작하니 돌아왔고, 덤프가 없어서 원인은 "다음에 나면 보자" 가 됐다.
어떻게 동작하나
[java 명령 문서](https://docs.oracle.com/en/java/javase/21/docs/specs/man/java.html)는 -XX:+HeapDumpOnOutOfMemoryError 를 "OutOfMemoryError 가 던져질 때 HPROF 형식으로 힙을 현재 디렉터리에 덤프한다, 기본은 꺼짐" 이라 적고, -XX:HeapDumpPath=<경로> 로 파일 위치와 이름을 정하며 기본 이름은 java_pid<pid>.hprof 라고 적는다. 운영에서는 이 둘을 항상 켠다. 덤프는 예외가 나는 순간에만 만들어지므로 켜 두지 않으면 사건 뒤에는 아무것도 없다. 파일 크기는 힙 크기와 비슷하니 디스크 자리를 미리 확보한다(-Xmx64m 실습에서 약 50MB 가 나온다, 실측).
프로세스가 죽지 않는 문제는 -XX:+ExitOnOutOfMemoryError 가 푼다. 이 옵션은 21 의 java 명령 문서에는 실려 있지 않지만 HotSpot 이 받아들이며, 실측으로 첫 OutOfMemoryError 에서 Terminating due to java.lang.OutOfMemoryError: Java heap space 를 찍고 종료 코드 3 으로 끝난다. 쿠버네티스라면 컨테이너가 죽고 재시작되므로 "반쯤 죽은 채 버티는" 상태가 사라진다. 헬스체크로 잡히지 않는 상태를 프로세스 종료로 바꾸는 것이 이 옵션의 뜻이다.
살아 있는 프로세스에서는 [jcmd](https://docs.oracle.com/en/java/javase/21/docs/specs/man/jcmd.html) 두 명령을 쓴다. GC.class_histogram 은 힙 사용 통계를 클래스별로 낸다(영향: 높음 — 힙 크기에 비례). 출력은 순위·인스턴스 수·바이트·클래스 이름이며, 누수는 거의 항상 첫 줄에 있다 — [B(byte 배열)나 java.util.HashMap$Node 가 수백만 개면 누가 그것을 붙들고 있는지가 다음 질문이다. GC.heap_dump <파일> 은 HPROF 덤프를 만들며, 문서는 -all 을 주지 않으면 먼저 Full GC 를 요청한다고 적는다 — 그래서 덤프에는 도달 가능한 객체만 남고, 그것이 "붙들려 있는 것" 의 정의다. HPROF 파일은 JAVA PROFILE 1.0.2 로 시작한다(실측). 분석 도구(Eclipse MAT 등)는 이 파일에서 지배 트리(dominator tree) — 어떤 객체를 놓으면 얼마가 풀리는가 — 를 계산하고, 그 꼭대기에 static 필드로 이어진 컬렉션이 있으면 그것이 범인이다.
같은 할당을 해도 참조를 놓으면 누수가 아니다. 실습의 Leak 은 -Dleak.retain=false 로 같은 배열을 만들되 맵에 넣지 않는다 — 할당량은 같은데 -Xmx64m 에서도 끝까지 돈다. 누수의 정의는 "많이 만든다" 가 아니라 "놓지 않는다" 이다.
컨테이너 안에서는 힙 기본값이 cgroup 한도에서 나온다. java -XX:+PrintFlagsFinal -version 이 실제 MaxHeapSize 를 찍고, -XX:MaxRAMPercentage(기본 25%)를 바꾸면 그 값이 따라 바뀐다. 메모리 한도 2Gi 인 파드에서 기본 최대 힙은 512MB 이고, 50% 로 올리면 1GiB 다. 힙을 한도에 너무 붙이면 힙 밖 메모리(메타스페이스·스레드 스택·직접 버퍼) 때문에 OutOfMemoryError 가 아니라 OOMKilled 로 죽는다 — 이 둘은 다른 사건이고 덤프도 남지 않는다.
현장에서 만나는 모습
사건 뒤 덤프가 없는 것이 가장 흔하다 — 옵션을 안 켰거나, 켰는데 디스크가 없었거나, 컨테이너가 재시작되며 파일이 사라졌다. 덤프 경로는 살아남는 볼륨이어야 한다. 두 번째는 ExitOnOutOfMemoryError 없이 "죽지 않는 좀비" 를 몇 시간 두는 것이다. 세 번째는 히스토그램 첫 줄을 보고 "byte 배열이 문제" 라고 끝내는 것이다. [B 는 늘 1등이다. 질문은 누가 그것을 붙드는가이고, 그 답은 덤프의 지배 트리에 있다. 마지막으로, 기동 옵션을 사람 기억에 두는 것이다. 옵션은 기동 스크립트에 적고 그 스크립트를 저장소에 둔다.
다음 실습에서 할 것
Leak.java 를 -Xmx64m 으로 돌려 OutOfMemoryError 를 만들고, HeapDumpOnOutOfMemoryError 로 덤프를 받고, 살아 있는 프로세스에서 jcmd 로 히스토그램과 덤프를 뜨고, 히스토그램 첫 줄을 읽고, retain=false 로 같은 할당이 누수가 아님을 확인하고, ExitOnOutOfMemoryError 의 종료 코드 3 을 잡고, MaxRAMPercentage 로 컨테이너 힙 기본값을 재고, 마지막으로 이 옵션들을 전부 담은 기동 스크립트를 만들어 실제로 돌린다.