자바 언어·플랫폼 용어 사전
실무 사례DirectByteBuffer · MaxDirectMemorySize · NMT · 네이티브 메모리

오프힙 누수

힙은 멀쩡한데 프로세스 메모리가 계속 는다. 힙 밖 영역이 원인인 경우.

힙 그래프는 평평한데 프로세스 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 로그에 없다.

함께 보면 좋은 용어

노트에서 맥락과 함께 보기 — 실무 노하우·실제 사고 사례