Skip to content
hello tis
Go back

2026년 8월 1주차 회고: JVM 네이티브 메모리 추적기 2편

3줄 요약

  • 이월된 jemalloc 조사를 로컬 Docker 실측으로 대체해 RSS 증가의 원인을 확정했다.
  • 인사이트: 앱은 메모리를 거의 전부 반납한다. free된 메모리를 OS에 돌려주지 않는 glibc가 원인이다.
  • 다음 주는 test 환경에 두 처방(MALLOC_ARENA_MAX=2, jemalloc)을 나눠 배포해 운영급 트래픽으로 비교한다.

지난주 Try 점검

이번 주의 문제: NMT 밖 1GB는 정말 살아있는 메모리인가

이전 과정 요약

6월에 운영 인스턴스 한 대가 컨테이너 메모리 limit에 닿아 OOMKill로 재시작되는 장애가 있었다. 직접 원인이던 다이렉트 버퍼 캐시 축적은 JVM 옵션(jdk.nio.maxCachedBufferSize=262144)으로 막았지만, 재시작 주기가 1일에서 5일로 늘었을 뿐 여전히 죽었다. 3주차에 그 나머지를 추적했다 — 힙은 2GB인데 RSS는 3.7GB, JVM이 스스로 아는 메모리(NMT committed 2.5GB)와의 격차가 1.2GB였다. trim으로 걷히는 잔류는 0.2GB뿐이어서 나머지 1GB는 “trim 되지 않는 메모리”라는 결론에 다다랐다.

상황

이번 실험은 이 1GB의 출처를 확인하는 것이 목표였다. 배포 없이도 로컬에서 확인할 수 있겠다 싶어, 로컬 Docker에 실험 환경을 만들어 부하 테스트를 진행했다.

시도

  1. 프로파일링 — 누수 없음 확인. 프로파일링을 통해 부하를 걸어둔 채 60초간 네이티브 malloc/free를 기록하고 주소를 매칭해 미해제분을 계산했다.

    • glibc: 샘플된 malloc 873건/5.2MB 중 미해제 0.02MB
    • jemalloc: 샘플된 malloc 2,123건/12.4MB 중 미해제 0.06MB — 프로파일러 자체 버퍼 제외

    할당 주체도 앱이 아니라 JIT 컴파일러(C2)였다. 상위 스택이 PhaseIdealLoop::Dominators, Matcher::Label_Root 같은 컴파일 작업 메모리였고 작업 후 전량 free됐다.

    네이티브 malloc 스택 플레임 그래프. 폭 대부분을 CompileBroker에서 시작하는 JIT 컴파일 스택이 차지하고, 오른쪽 작은 블록은 프로파일러 attach 스택이다. 앱 코드 스택은 없다

    NIO 다이렉트 버퍼의 live는 단 1건 6.8KB — 3주차에 배포한 maxCachedBufferSize 방어가 작동 중이라는 실측 확인이다. 후보 넷 중 앱 쪽 누수(zlib 등)는 여기서 제외됐다.

  2. 격차의 출처 — 주소 단위 대조. OS가 보는 메모리(/proc/1/smaps)와 JVM이 아는 메모리(NMT detail, 영역별 주소 범위)를 부하 테스트 전후로 캡처해 주소 단위로 대조했다. RSS 증가 +1,074MB의 내역은 이렇다.

    • Java 힙: +815MB — Xmx 이내 정상 동작
    • GC·Metaspace·Code: +33MB
    • NMT가 추적하지 못하는 익명 영역: +225MB — 159MB→384MB

    mmap 내역을 확인하니, 증가분의 99%(223MB)는 64MB 경계에 정렬된 익명 매핑 78개에 나뉜 단편화된 메모리였다 — glibc가 스레드 arena를 만들 때 나타나는 배치다.

glibc의 free는 왜 OS 반환이 아닌가?

free된 메모리는 OS가 아니라 glibc 내부의 빈 공간 리스트(bin)로 돌아가 재사용을 기다린다. 그래서 프로파일러에는 정상 해제로 보여도 RSS는 줄지 않는다. 미해제는 0.02MB뿐인데 격차는 수백 MB까지 벌어진 것도 이 때문이다.

  1. 사후 회수 시도 — 효과 미미. jcmd System.trim_native_heap으로 glibc가 쥔 메모리 반환을 시도했다. 회수는 5MB에 그쳤다. 쌓인 메모리를 나중에 걷어내는 방식으로는 격차를 줄일 수 없었다.
  2. 업계 선례 확인. 같은 문제가 OpenJDK 공식 버그 JDK-8193521로 등록돼 있고, 대응 선례도 두 갈래로 쌓여 있다.
  3. glibc vs jemalloc A/B 실측. 앱·이미지·부하·limit을 전부 고정하고 LD_PRELOAD만 바꿨다. 지표는 격차(= VmRSS − NMT committed)이다.
jemalloc이란?
  • 무엇인가 — Jason Evans가 FreeBSD용으로 만든 범용 malloc 구현체다(jemalloc.net). 멀티스레드 환경의 락 경합과 단편화를 줄이는 게 설계 목표로, Redis의 기본 할당자이고 Meta가 자사 서비스 전반에 쓴다.
  • free까지 통째로 바뀐다LD_PRELOAD로 끼우면 malloc뿐 아니라 free·calloc·realloc까지 전체 패밀리가 교체된다. 할당과 해제는 같은 할당기의 쌍이어야 하기 때문이다. glibc의 free는 jemalloc이 할당한 메모리의 내부 구조를 모른다. 앱 코드는 한 줄도 안 바뀐다.

실험 단계별 격차 막대그래프. glibc는 275에서 486MB까지 계속 늘고, jemalloc은 240~280MB에서 평탄하다

같은 20분 동안 glibc의 격차는 48→486MB로 10배가 됐고, jemalloc은 사실상 제자리였다. 최종 RSS도 glibc 1,835MB, jemalloc 1,591MB로 244MB 차이가 났다. glibc의 peak RSS 1,881MB(limit의 92%)는 운영 OOMKill 사이클의 재현이다 — 힙이 정상적으로 커지는 위에 arena가 쥔 메모리가 쌓여 limit에 닿는다. jemalloc은 background_thread:true가 필수다 — 없으면 부하가 멈춰도 반환이 일어나지 않는 것을 1차 실험에서 확인했다.

  1. glibc를 유지하는 대안 — MALLOC_ARENA_MAX 실측. Hadoop과 Cloud Foundry가 기본으로 심을 만큼 검증된 처방이라, 나도 같은 부하를 새 컨테이너에 걸어 arena 축소 단독과 trim 조합을 나란히 재봤다.

glibc 기본은 라운드마다 격차가 48에서 275MB까지 계속 오르고, MALLOC_ARENA_MAX=2는 41~75MB 사이에서 진동하며 추세가 없는 라인 그래프

arena 축소 단독으로 효과의 대부분이 나온다 — 275→69MB(-75%). 부하 후 64MB 정렬 arena 매핑이 기본값 78개에서 6개로 줄었으니, 격차가 쌓이는 arena 수 자체가 줄어든 것이다. trim_threshold를 얹으면 69→31MB로 조금 더 줄어들 뿐이다. 3주차 재현기에서는 같은 플래그가 RSS를 44% 늘렸는데 이번엔 정반대다. 튜닝 플래그는 워크로드마다 실측해야 한다는 걸 다시 확인했다.

짚고 갈 것이 하나 있다. 이 스케일에서는 튜닝된 glibc가 jemalloc보다 격차 절대값이 오히려 작다. 두 개선책의 장단은 이렇게 갈린다.

해결

RSS 증가의 원인은 free된 메모리를 OS에 반환하지 않고 arena에 쥐고 있는 glibc의 고질적인 문제다. 메모리 주소를 직접 대조했을 때, 64MB 단위로 78개로 쪼개진 매핑 값(225MB)을 직접 확인했다.

따라서 arena 개수를 줄여 단편화로 인한 RSS 증가 상한선을 둘지, jemalloc으로 전환해 단편화를 최소화할지 결정해야 했다.

MALLOC_ARENA_MAX=2jemalloc 전환
격차 개선O — 75% 절감 (31–69MB)O — 수렴 (약 245MB)
적용 방법환경변수 한 줄이미지 변경 + LD_PRELOAD
락 경합발생 가능없음
기본 보유 메모리작음

각 시도한 과정을 정리하면 다음과 같다.

세 가지 구성의 부하 테스트 전후 격차 막대그래프. glibc 기본만 48에서 275MB로 벌어지고, ARENA_MAX=2는 69MB에 그친다. jemalloc은 244에서 245MB로 평탄하다

최종 선택은 test 환경에서 두 안을 나란히 걸어 운영급 트래픽으로 가른다.

배운 것

glibc와 jemalloc의 격차 그래프가 갈린 건 배치 규칙의 차이다.

이 차이는 작년 5월에 Redis의 기본 할당자를 정리해둔 노트의 벤치마크에서 경우별로 그대로 드러난다. Redis는 redis-server -v 출력에 malloc=jemalloc-5.2.1이 찍힐 만큼 jemalloc을 내장하는데, 그 배경인 Matt Stancliff의 quicklist 벤치마크를 보면 이렇다.

할당자는 워크로드를 재보고 정한다는 이번 교훈이 그 노트에도 있었다. jemalloc이 다중 arena로 락 경합을 줄이도록 설계됐다는 내용은 Jason Evans의 2006년 논문 원문에 있다.

이번 주의 공부: 『주니어 백엔드 개발자가 반드시 알아야 할 실무지식』 3장

3장은 성능을 좌우하는 DB 설계와 쿼리를 다룬다. 저자가 반복해서 강조하는 건 하나다. DB 자체가 문제인 경우보다 잘못 사용해서 생기는 문제가 더 많다는 것. 기억할 내용은 세 가지다.

실무에 적용할 점

복합 인덱스를 걸 때의 판단 기준을 정리해뒀다.

더 알아볼 것 / 질문

읽으면서 생긴 질문들이다. 답도 직접 찾아봤다.

다음 주 Try


Share this post:

Previous Post
설계 노트: 포인트 차감과 결제 사이의 정합성 다루기
Next Post
2026년 7월 4주차 회고: 『주니어 백엔드 개발자가 반드시 알아야 할 실무지식』 2장