프로그램이 malloc/free로 쓰는 메모리 할당자를 glibc 기본(ptmalloc2)에서 jemalloc으로 바꾸는 것만으로 성능이 달라진다는 이야기가 있다. 정말 그런지, 무엇을 대가로 치르는지 보기 전에, jemalloc이 애초에 무엇을 풀려고 태어났는지부터 본다.

왜 만들었나 — 멀티코어가 밀어올린 할당자

jemalloc은 2004~2005년 Jason Evans가 자기 실험 언어 Lyken을 위해 만든 할당자에서 출발했다. Lyken 자체는 접었지만, 2005년 9월 이걸 FreeBSD에 넣으면서 진짜 쓸모를 찾았다 — 마침 멀티프로세서로 넘어가던 시기였고 FreeBSD의 기존 할당자 phkmalloc은 병렬 스레드를 감당하지 못했다. jemalloc은 애초에 멀티코어 확장성을 풀려고 만든 것이다. 초기엔 KDE 앱에서 단편화가 심하게 터져 구조를 크게 갈아엎었는데(통합 extent 방식 → 크기별로 분리), 이게 지금의 size class 설계로 이어졌다.

그 뒤 두 번의 채택이 jemalloc을 키웠다. 2007년 Firefox 3가 특히 Windows에서의 단편화를 잡으려고 1년에 걸쳐 함께 이식했고, 2009년 Evans가 합류한 Facebook이 프로덕션 전반에 쓰면서 힙 프로파일링을 붙였다.

곧 볼 멀티스레드 약점 이야기는 곁가지가 아니라 jemalloc의 존재 이유 그 자체다. 멀티코어가 표준이 되던 때 표준 할당자가 못 따라간 자리를 메우려 나온 것이니, 그 약점부터 구체적으로 본다.

왜 다른 할당자가 필요한가

glibc의 기본 할당자 ptmalloc2는 멀티스레드에서 두 가지로 약하다.

  • 아레나가 스레드 수만큼 늘어난다. glibc은 처음엔 메인 아레나 하나로 시작하고, 여러 스레드가 그 락을 두고 다투기 시작하면 새 아레나(별도 힙 영역)를 mmap으로 더 만든다. 64비트 기본 상한은 MALLOC_ARENA_MAX, 즉 8 × 코어 수다 — 32코어면 최대 256개까지 간다. 스레드는 아레나 하나에 배정되고, 상한에 닿으면 그때부턴 여러 스레드가 한 아레나를 나눠 쓰며 다시 락을 다툰다. 문제는 아레나끼리 메모리를 공유하지 않는다는 점이다. 아레나 1에서 해제한 1KB 블록을 아레나 2의 할당이 재사용하지 못한다. 그래서 같은 크기의 여유 블록이 아레나마다 따로 쌓이고, 개별 아레나엔 여유가 넘쳐도 프로세스 전체가 붙잡은 메모리는 계속 늘어난다 — 이게 단편화다. 뒤에서 MALLOC_ARENA_MAX=2로 아레나를 줄이면 단편화가 잡히는(대신 경합이 심해지는) 이유가 여기 있다.
  • 힙 꼭대기에서만 OS로 반환한다. ptmalloc2는 sbrk로 늘린 힙을 꼭대기부터만 줄일 수 있다. 그래서 오래 사는 할당 하나가 그 아래 전부를 붙잡는다 — 주변이 다 해제돼도 OS에 못 돌려준다. 프로세스가 보고하는 힙은 안정적인데 OS가 본 RSS만 계속 부푸는 현상이 여기서 나온다.

jemalloc은 다르게 접근한다.

  • 아레나를 미리 여러 개 두고 스레드를 분산 배치한다(먼저 잡히는 락을 쓰는 방식이 아니다).
  • 스레드마다 tcache(thread cache)를 둬 자주 쓰는 크기의 블록은 락 없이 꺼내 쓴다.
  • 크기 등급(size class)을 2의 거듭제곱이 아니라 기하급수로 촘촘히 나눠 내부 단편화를 줄이고, 같은 크기끼리 슬래브에 모아 외부 단편화를 줄인다.
  • extent 기반이라 비연속 페이지도 madvise로 OS에 반환한다. dirty_decay_ms로 해제된 페이지를 얼마나 붙잡았다 돌려줄지 조절하고, background_thread:true면 그 정리를 별도 스레드가 맡아 지연 스파이크를 줄인다.
flowchart LR
  subgraph G["glibc ptmalloc2"]
    G1["아레나 = 8 × 코어수까지<br/>여유 블록이 흩어짐 → 단편화"]
    G2["힙 꼭대기에서만 OS 반환<br/>→ 오래 사는 할당 하나가<br/>아래 전부를 붙잡음"]
  end
  subgraph J["jemalloc"]
    J1["아레나 미리 분산 + tcache<br/>락 경합 감소"]
    J2["기하급수 size class<br/>→ 내부 단편화 감소"]
    J3["extent + madvise<br/>→ 비연속 페이지도 OS 반환"]
  end

직접 재본다 — 같은 바이너리, 환경변수만 바꾼다

“코드 한 줄 안 고치고 할당자만 바꿨을 때” 정말 달라지는지, 무엇을 대가로 치르는지 WSL(g++ 11.4, 32코어)에서 직접 재봤다. 8개 스레드가 각각 300만 번, 8B~4KB 블록을 할당하고 512개 슬롯에 돌려 담으며 일부를 오래 붙잡는 워크로드를 만들었다. 멀티스레드 할당 처처닝이라 할당자 차이가 드러나기 좋다.

void worker(int seed) {
  unsigned s = seed * 2654435761u + 1u;
  auto rnd = [&] { s = s * 1103515245u + 12345u; return s; };
  std::vector<char*> live(512, nullptr);
  for (long i = 0; i < 3'000'000; ++i) {
    int slot = rnd() % 512;
    if (live[slot]) free(live[slot]);
    size_t sz = 8 + (rnd() % 4088);
    char* p = (char*)malloc(sz);
    p[0] = p[sz - 1] = (char)i;   // 첫·끝 바이트 터치 → 실제 커밋
    live[slot] = p;
  }
}

핵심은 이 바이너리를 다시 컴파일하지 않는다는 것이다. 할당자는 실행할 때 환경변수로만 바꾼다.

./bench                                   # ① glibc 기본
MALLOC_ARENA_MAX=2 ./bench                # ② glibc, 아레나 2개로 제한
LD_PRELOAD=/…/libjemalloc.so.2 ./bench    # ③ jemalloc

결과(각 2회, WSL 32코어):

설정시간최대 RSS
① glibc 기본204 ms8.6 MB
② glibc MALLOC_ARENA_MAX=24,450 ms8.9 MB
③ jemalloc (LD_PRELOAD)40 ms13.8 MB

세 가지를 읽어야 한다.

jemalloc이 약 5배 빨랐다(40 ms 대 204 ms). 이 워크로드의 병목은 아레나 락 경합인데, jemalloc의 분산 아레나 + tcache가 그걸 걷어냈다. 코드는 그대로다.

메모리를 아끼려던 손잡이가 속도를 부쉈다. MALLOC_ARENA_MAX=2는 아레나를 줄여 단편화를 잡는 흔한 처방이다. 그런데 8스레드가 아레나 2개를 두고 다투니 20배 넘게 느려졌다(4,450 ms). 단편화와 처리량은 이렇게 맞바꿈 관계다.

이 워크로드에선 jemalloc이 RSS를 더 썼다(13.8 MB 대 8.6 MB). 32코어에서 아레나를 여럿 미리 잡느라 메타데이터가 그만큼 든 것이다. jemalloc의 RSS 이득은 짧은 벤치가 아니라 오래 돌며 단편화가 쌓이는 서비스에서 드러난다. Malt는 GKE의 자바 서비스가 힙은 안정적인데 RSS만 부풀어 OOMKill 당하던 문제를, 먼저 MALLOC_ARENA_MAX=2로 크게 완화하고 최종적으로 jemalloc으로 바꿔 잡았다고 보고했다. 다만 그들도 “측정·모니터링 없이 프로덕션을 바꾸지 말라”고 못박는다 — 결과는 워크로드에 달렸다.

즉 “무조건 빠르고 메모리도 준다”가 아니라, 아레나 경합이 병목이면 크게 이기고, 이득의 종류(속도냐 RSS냐)는 워크로드가 정한다. 그래서 바꾸기 전에 재보는 게 전부다. 할당 자체를 세는 방법은 앞선 「C++ 문자열 연결 — operator+는 정말 느린가」에서 operator new를 전역 대체해 횟수를 셌던 그 기법이 그대로 쓰인다.

완결인가, 진행인가 — 끝났다고 선언된 프로젝트

jemalloc을 지금 고를 때 짚어야 할 게 하나 더 있다. 이 프로젝트는 한 번 공식적으로 끝났다.

창시자 Jason Evans는 **2025년 6월 12일 “jemalloc Postmortem”**을 올리고 “내 입장에서 upstream jemalloc 개발은 끝났다”고 적었다. 프로젝트를 4단계로 정리하며 마지막을 “Phase 4: Stasis(정체)“라 불렀다. 이유로 Meta가 핵심 기술 투자보다 ROI를 앞세우며 투자를 줄인 점, huge page 작업이 기술 부채에 막혀 정체된 점, Meta의 내부 필요가 외부 사용자와 어긋난 점, 그리고 외부 조직에서 주력 기여자를 끝내 못 키운 점을 들었다. “공개는 무기한 유지되지만” 새 개발은 남이 포크로 이어가야 할 것이라고 했다.

마지막 메이저 릴리스 5.3.0이 2022년 5월이었으니, 이 선언은 3년 넘게 정체된 끝의 사망 선고에 가까웠다.

그런데 반전이 있다. 2026년 4월 13일, 공식 저장소에서 5.3.1이 나왔다 — postmortem 이후, 약 4년 만의 릴리스다(ChangeLog에 * 5.3.1 (Apr 13, 2026)으로 박혀 있다).

무엇이 들어갔나. 390개가 넘는 커밋으로 버그 수정·새 기능·성능·이식성을 두루 손봤는데, 특히 이식성 확장이 눈에 띈다 — musl·C99 빌드 수정, macOS 힙 프로파일링, ARM/aarch64 큰 페이지 대응, 그리고 C23의 free_sized/free_aligned_sized 지원. 안정성 쪽은 decay 재진입 중 데드락 방지, extent 병합 시 segfault 수정, background thread 초기화 레이스 정리 같은 것들이고, tcache_ncached_max 같은 새 튜닝 손잡이도 늘었다.

왜 살렸나. ChangeLog는 이 릴리스가 Meta의 대규모 프로덕션 테스트를 거쳤다고 밝힌다 — 되살린 주체가 Meta라는 뜻이다. postmortem이 짚었던 “Meta 내부 포크가 외부 저장소와 갈라졌다”는 바로 그 간극을, 4년간 내부에 쌓인 개선을 공식 저장소로 되돌리며 메운 셈이다. 공개된 동기가 그 이상 자세히 적혀 있지는 않아 확실히 말할 수 있는 건 여기까지다. 어쨌든 끝났다고 선언된 프로젝트가 되살아났다.

정리하면 오늘 jemalloc은 쓸 수 있고 널리 쓰이며 방금 새 릴리스까지 받았지만, 그 명운이 한 조직(Meta)에 크게 묶여 있다는 리스크는 여전하다 — 2025년의 선언이 그걸 정확히 보여줬다. 그 점이 걸린다면 활발히 개발되는 대안인 mimalloc이나 tcmalloc을 함께 저울에 올리면 된다.

덧 — 드롭인은 리눅스에서만, Windows는 mimalloc

위에서 “코드 안 고치고 바꾸기”가 됐던 건 LD_PRELOAD 덕분이고, 이건 리눅스(ELF 동적 링커)의 기능이다. 프로세스가 뜰 때 jemalloc의 malloc을 glibc 것보다 먼저 로드해 가로챈다. 그래서 쿠버네티스 컨테이너나 systemd 유닛에 환경변수 한 줄 넣는 것으로 배포된다. 통계는 MALLOC_CONF=stats_print:true로 뽑는다.

Windows에는 LD_PRELOAD가 없다. jemalloc을 정적으로 링크할 수는 있지만 “재빌드 없이 갈아끼우는” 드롭인은 안 된다. Windows에서 그 자리를 메우는 건 mimalloc(Microsoft)이다. Windows를 1급으로 지원하고, mimalloc-override.dll을 끼워 넣는 리다이렉트 방식으로 재컴파일 없이 기본 할당자를 대체할 수 있다. tcmalloc(Google)은 리눅스 쪽에 무게가 실려 있어 Windows 드롭인 용도로는 mimalloc이 더 자연스럽다. (이 글에서는 mimalloc을 직접 재보지는 않았다.)

참고

  • jemalloc Postmortem — Jason Evans (2025-06-12) — “upstream 개발 종료” 선언과 4단계(특히 “Phase 4: Stasis”), 종료 이유, 그리고 초기 역사(Lyken·FreeBSD·Firefox·Facebook)의 1차 출처
  • jemalloc ChangeLog5.3.0 (May 6, 2022)·5.3.1 (Apr 13, 2026) 릴리스 날짜, 5.3.1의 변경 내용(이식성·안정성·새 옵션)과 “Meta 프로덕션 테스트” 문구를 원문으로 확인(HTML 요약이 연도를 잘못 주기에 raw로 대조)
  • jemalloc TUNING.mdMALLOC_CONF·background_thread·dirty_decay_ms 등 튜닝 옵션 확인
  • Malt Engineering — Java in K8s: reducing memory without changing code — glibc 아레나 폭증 진단, MALLOC_ARENA_MAX·jemalloc 적용 경과와 “측정 없이 바꾸지 말라”는 경고
  • microsoft/mimalloc — Windows 네이티브 지원과 override(redirect) 방식 확인