힙 그래프는 평평한데 프로세스 RSS 는 계속 오르는 유형의 누수.
JVM 프로세스 메모리 = 힙 + 그 밖의 전부
힙 밖에 있는 것
-
메타스페이스 (클래스 메타데이터)
-
코드 캐시 (JIT 컴파일 결과)
-
스레드 스택 (스레드당 기본 1MB)
-
DirectByteBuffer / MappedByteBuffer
-
GC 자체 자료구조
-
네이티브 라이브러리(JNI) 할당
-
-Xmx 를 아무리 낮춰도 이쪽은 안 줄어든다
DirectByteBuffer 가 특히 까다로운 이유
ByteBuffer.allocateDirect() 는 힙 밖에 메모리를 잡는다 해제는 그 버퍼를 감싼 자바 객체가 GC 될 때 Cleaner 가 수행한다
문제: 힙에 여유가 있으면 GC 가 안 돈다
- 자바 객체가 살아 있다 → 네이티브 메모리도 안 풀린다
- 힙은 한가한데 네이티브가 고갈된다
즉 '해제 시점을 GC 에 맡긴' 구조 자체가 원인이다
진단 순서
- ① 힙 사용량과 RSS 를 함께 그린다 — 벌어지면 힙 밖이다
- ② Native Memory Tracking 을 켠다
- -XX:NativeMemoryTracking=summary
- jcmd <pid> VM.native_memory summary
- Class · Thread · Code · GC · Internal 중 어디가 크는지 본다
- ③ Direct 버퍼는 JMX 로 본다
- java.nio:type=BufferPool,name=direct 의 MemoryUsed
- ④ 그래도 안 맞으면 malloc 수준 (jemalloc 프로파일 · pmap)
자주 만나는 원인
- Netty · NIO 기반 라이브러리의 버퍼 미해제
- 압축/암호화 네이티브 라이브러리
- 스레드를 계속 만들어 스택이 쌓임 (스레드 누수)
- 동적 클래스 생성 (프록시 · 스크립트 엔진) → 메타스페이스 증가
- glibc malloc arena 단편화 (컨테이너에서 특히)
상한을 명시한다
-XX:MaxDirectMemorySize=512m 안 주면 대략 -Xmx 와 같은 값이 되어, 힙+다이렉트로 두 배를 쓸 수 있다 컨테이너 limit 을 넘겨 OOMKill 되는 전형적 경로다
컨테이너 메모리 예산을 세워 본다
컨테이너 limit 2GB 일 때 대략적 배분
힙 (-Xmx) 1,200MB
메타스페이스 128MB (-XX:MaxMetaspaceSize)
코드 캐시 64MB (-XX:ReservedCodeCacheSize)
스레드 200 × 1MB 200MB (-Xss 로 조정 가능)
다이렉트 버퍼 128MB (-XX:MaxDirectMemorySize)
GC · JVM 내부 ~200MB
─────────────────────────────────
합계 ~1,920MB
힙을 limit 의 60% 정도로 잡는 감각이 여기서 나온다
MaxRAMPercentage 기본값(25%) 은 보수적이고, 75% 는 위험할 수 있다
상한을 안 걸면 어떻게 죽나
JVM 은 자기 힙만 지킨다 힙 밖이 넘치면 커널이 프로세스를 SIGKILL 한다
- JVM 로그에 아무것도 안 남는다
- 힙 덤프도 없다
- kubectl describe 에 OOMKilled, exit code 137 만 남는다
"로그가 없다" 는 것 자체가 이 유형의 단서다
면접 함정
- ❌ "OOM은 항상 힙 문제" → 메타스페이스·다이렉트·스레드 스택 모두 원인이 된다.
- ❌ "컨테이너가 죽었으니 JVM 로그를 본다" → OOMKill은 커널이 죽인 것이라 JVM 로그에 없다.