jemalloc vs tcmalloc

两大高性能内存分配器的深度对比 —— 从原理到实践,一次讲透

jemalloc · FreeBSD / Facebook tcmalloc · Google Golang 内存模型

🧠 一、它们是什么?

jemalloc

jemalloc 是由 Jason Evans 于 2005 年为 FreeBSD 操作系统开发的通用内存分配器。 它以出色的多线程并发性能内存碎片控制而闻名,后被 Facebook(Meta)大规模采用并持续优化。

FreeBSD libc Facebook / Meta Redis 默认 Rust 编译器 Android

一句话:jemalloc 的目标是最小化内存碎片,在长时间运行的多线程服务中保持低且稳定的内存占用。

tcmalloc

tcmalloc(Thread-Caching Malloc)是 Google 于 2005 年前后开发的, 专为解决大规模多线程 C++ 服务(如 Google Search、Bigtable)中的内存分配瓶颈而设计。

gperftools Google 全栈 Golang 灵感来源 Chrome / Android

一句话:tcmalloc 的核心思想是线程本地缓存,让大多数内存分配不涉及全局锁。
💡 为什么需要它们?
操作系统的默认 malloc(如 glibc 的 ptmalloc)在多线程高并发场景下存在严重的锁竞争内存碎片问题。jemalloc 和 tcmalloc 都针对这些问题做了深度优化,是现代高性能服务的事实标准。

🔧 二、用的是什么编程语言?

jemalloc

C 语言 编写
(少量 C++ 用于测试)

🔮
tcmalloc

C++ 语言 编写
(提供 C 语言 API)

🐹
Go 内存分配器

Go 语言 编写
借鉴 tcmalloc 设计思想

⚙️ 三、核心实现原理

tcmalloc 核心原理 —— 三级缓存架构

tcmalloc 的名字(Thread-Caching Malloc)揭示了它的核心:线程本地缓存。 它通过三级结构来减少锁竞争:

1
Thread Cache(线程本地缓存) 每个线程拥有独立的本地空闲链表。分配内存时优先从本线程的 cache 取,完全无锁。 每个线程 cache 维护一个大小类(size class)的链表数组。
2
Central Cache(中央缓存) 当 Thread Cache 不够用时,从 Central Free List 批量补充。Central Cache 按 size class 组织, 使用自旋锁保护,但由于批量操作,竞争频率已大幅降低。
3
Page Heap(页堆) 当 Central Cache 也不够时,从 Page Heap 申请。Page Heap 管理的是页(page)级别的内存, 通过 mmap / sbrk 向操作系统申请,并负责归还。
// tcmalloc 分配流程 应用线程 │ ▼ ┌─────────────────┐ │ Thread Cache │ ← 无锁,极快(~10 ns) │ (per-thread) │ └────────┬────────┘ │ 不够 ▼ ┌─────────────────┐ │ Central Cache │ ← 自旋锁,批量搬砖 │ (per-size-class) │ └────────┬────────┘ │ 不够 ▼ ┌─────────────────┐ │ Page Heap │ ← 全局锁,向 OS 申请 │ (global) │ mmap / sbrk └─────────────────┘

jemalloc 核心原理 —— Arena 分区架构

jemalloc 的设计哲学是分区(Partition),通过将内存管理分散到多个独立的 Arena 中, 减少线程间的竞争,同时精确控制内存碎片。

1
Arena(竞技场)分区 内存被划分成多个独立的 Arena。每个线程被分配到一个 Arena, 线程内部的分配先在所属 Arena 中完成。默认 Arena 数量 = 4 × CPU 核心数。
2
tcache(线程缓存)+ bin 系统 每个线程有自己的 tcache(类似 tcmalloc 的 Thread Cache), 但数据结构是bins(按大小分桶)。小对象走 bins,大对象走 runs。
3
大小分类(Size Classes)精细划分 jemalloc 定义了 200+ 个 size class,从 8 字节到数 KB, 每个 size class 有独立的 bin。这种精细划分极大减少了内部碎片
4
Huge 对象独立管理 超大对象(> 几 MB)直接用 mmap 分配,不经过 Arena/bin 系统。 释放时立即 munmap 归还 OS。
// jemalloc 分配流程 应用线程 → 绑定到某个 Arena │ ▼ ┌──────────────────────┐ │ tcache (per-thread) │ ← 无锁,从所属 Arena 取 │ 线程本地缓存 │ └──────────┬───────────┘ │ 不够 ▼ ┌──────────────────────┐ │ Arena → bins │ ← per-arena 锁 │ 小对象:bin (slab) │ │ 中对象:run │ │ 大对象:chunk → mmap │ └──────────────────────┘ // 多个 Arena 并行工作 Arena 0 ← 线程 A,B Arena 2 ← 线程 E,F Arena 1 ← 线程 C,D Arena 3 ← 线程 G,H (线程数多时自动扩展 Arena 数量)

⚔️ 四、关键区别对比

维度 jemalloc tcmalloc
设计哲学 分区(Arena 独立管理) 分层缓存(Thread → Central → Page)
线程模型 线程绑定到 Arena,Arena 内竞争 线程各自独立 cache,竞争集中在 Central
内存碎片 ⭐ 极低,200+ 精细 size class 较低,约 80+ size class
内存归还 OS 主动、激进地释放空闲页 较保守,需配合参数调优
运行时内存 长期运行后 RSS 更低、更稳定 短期峰值表现更优
分配/释放速度 略慢但极其稳定 小对象分配极快(无锁)
统计分析能力 ⭐ 极其丰富的统计接口 基础统计(搭配 heap profiler)
内存 profiling 内置 malloc_stats_print 内置 Heap Profiler(pprof)
实现语言 C C++
代表用户 Redis、Rust、FreeBSD、Meta Chrome、Golang、Google 全家桶

📊 五、各自的优劣势

🟦 jemalloc 优势

  • 碎片控制业界最佳,长期运行 RSS 极低
  • 主动归还 OS 内存,不囤积空闲页
  • 200+ 精细 size class,内部碎片最小
  • 强大的统计/调试接口,便于排查内存问题
  • Arena 自动扩展,CPU 越多扩展越好

🟦 jemalloc 劣势

  • 配置复杂度较高(Arena 数量、dirty page 衰减等)
  • 过于积极的归还策略在某些场景下可能影响性能
  • 内存占用统计比 tcmalloc 略高(管理元数据)
VS

🟣 tcmalloc 优势

  • 线程本地缓存,极速分配(完全无锁)
  • 设计简洁,三级结构直观易懂
  • 内置 Heap Profiler(搭配 pprof),调试方便
  • Google 生态深度集成

🟣 tcmalloc 劣势

  • 碎片控制不如 jemalloc 精细
  • 内存归还 OS 较保守,长期 RSS 可能偏高
  • 线程频繁创建/销毁时 Thread Cache 开销大
  • 超大对象分配不如 jemalloc 高效
🔬 实测数据参考(来自公开 benchmark)
在 Redis 场景下,jemalloc 的碎片率约 1.01 - 1.05(分配 1GB 占用约 1.05GB), 而 glibc malloc 可能达到 1.5 - 2.0。tcmalloc 约 1.05 - 1.15。 jemalloc 的碎片控制是所有主流分配器中最好的。

🐹 六、Go 语言为什么能用 tcmalloc?(这不是 C 写的吗?)

关键澄清:Go 不是"用" tcmalloc,而是"借鉴"了 tcmalloc 的设计思想

这是一个非常好的问题,很多初学者都会困惑。核心答案是:

✅ Go 的内存分配器是用纯 Go 语言重新实现的,它在设计思想上借鉴了 tcmalloc, 而不是直接链接 tcmalloc 的 C/C++ 代码。两者是"思想相似、实现独立"的关系。

Go 内存分配器(TCMalloc 风格的 Go 实现)

Go runtime 中的内存分配器位于 src/runtime/malloc.go, 其设计思路灵感来源就是 tcmalloc。它复用了 tcmalloc 的核心架构,但完全用 Go 语言实现。

1
mcache(Go 版 Thread Cache) 每个 P(Go 的调度单元,Processor)绑定的本地缓存。无锁分配。 Go 不是按线程而是按 P 做缓存,更符合 GMP 调度模型。
2
mcentral(Go 版 Central Cache) 全局的按 size class 组织的中央缓存。使用lock保护。 mcache 不够时从这里批量拿。
3
mheap(Go 版 Page Heap) 全局堆,管理所有内存页(spans)。通过 mmap 向 OS 申请。 包含 mheap_.arenas(Go 1.11+ 的稀疏 arena 映射)。
// Go 内存分配器架构(借鉴 tcmalloc) Goroutine 请求分配内存 │ ▼ ┌─────────────────────────────────┐ │ mcache (per-P) │ ← 无锁 │ Go 版 Thread Cache │ 每个 P 一个,本地分配 │ 对应 tcmalloc Thread Cache │ └──────────────┬──────────────────┘ │ 不够 ▼ ┌─────────────────────────────────┐ │ mcentral (global × 136) │ ← 有锁 │ Go 版 Central Cache │ 按 spanClass 分类 │ 对应 tcmalloc Central Cache │ └──────────────┬──────────────────┘ │ 不够 ▼ ┌─────────────────────────────────┐ │ mheap (global) │ ← 全局锁 │ Go 版 Page Heap │ 管理 arenas / spans │ 对应 tcmalloc Page Heap │ mmap 向 OS 申请 └──────────────┬──────────────────┘ │ ▼ 操作系统 (mmap / VirtualAlloc) // 三者的对应关系 tcmallocGo 内存分配器 Thread Cache → mcache Central Cache → mcentral Page Heap → mheap

那 C 写的 tcmalloc 真的能在 Go 中使用吗?

可以,但方式完全不同:

方式 原理 场景
CGO 链接 Go 通过 CGO 直接链接 libtcmalloc.so。设置 LD_PRELOAD 或编译时 #cgo LDFLAGS: -ltcmalloc CGO 程序需要高性能 C/C++ 内存分配
LD_PRELOAD 启动时 LD_PRELOAD=/usr/lib/libtcmalloc.so ./myapp,替换全局 malloc CGO 密集调用的 Go 程序
Go 原生(推荐) 使用 Go runtime 自带的 tcmalloc 风格分配器,零成本、零依赖 纯 Go 程序 —— 几乎所有 Go 程序都是这种方式
🔑 核心理解:
① Go 默认使用的内存分配器不是 tcmalloc 的 C 代码,而是用 Go 语言重新实现的、基于 tcmalloc 设计思想的分配器
② 对于纯 Go 代码,它走的是 Go runtime 自己的分配器,和 C 写的 tcmalloc 没有直接关系。
③ 对于 Go 调用 C 代码(CGO)的部分,可以通过 LD_PRELOAD 等方式使用真正的 tcmalloc(C 版本)。

🎯 七、总结 & 选型建议

场景 推荐 原因
Redis / 内存数据库 jemalloc 极低的碎片率,长期运行 RSS 稳定
C++ 多线程服务 tcmalloc 无锁线程缓存,分配速度极快
需要内存 profiling tcmalloc pprof 生态成熟,排查内存泄漏方便
长期运行、内存敏感 jemalloc 主动归还 OS,最低碎片率
Go 程序 Go 原生分配器 已经是 tcmalloc 风格实现,无需替换
嵌入式 / 移动端 jemalloc 内存开销可控,已在 Android 验证

一句话速记

jemalloc → 碎片控制之王
分区架构 | 200+ size class | 适合长期运行
tcmalloc → 速度之王
三级缓存 | 无锁线程缓存 | Google 全栈验证
Go 内存分配器 = tcmalloc 设计思想 + Go 语言实现 + GMP 调度优化
不是直接用 tcmalloc 的 C 代码,而是"取其神,不取其形"