你的 Go 服务上线第三天,运维找过来:RSS 从 2GB 涨到了
6GB,free 显示内存快爆了,但 QPS
没变、连接数没变、goroutine 数量也没变。你加了
pprof,堆内存显示才 1.8GB。剩下的 4.2GB 去哪了?
你重启了服务,“修好了”。下周又涨回去。
问题不在你的代码里——问题在 malloc 里。那
4.2GB
是内存碎片:分配器从操作系统要来了内存,你
free
了,但分配器没还回去,因为那些空闲块太碎,拼不成一个完整的页面。
这不是个例。我在生产环境中见过太多次了:Redis 的
mem_fragmentation_ratio 从 1.0 悄悄涨到
2.5,C++ 服务的 RSS 是实际数据量的 3 倍,Java 用了堆外内存后
JVM 的 -Xmx 限制形同虚设。
本文不是 API 使用教程,而是一次对内存分配器设计哲学的深入解剖。我们将从 glibc 的 ptmalloc2 出发,理解它为什么在 2024 年已经力不从心,然后逐一拆解 jemalloc、tcmalloc 和 mimalloc 三大现代分配器的内部架构,最后用基准测试数据回答工程师最关心的问题:我该选哪个?
一、为什么 glibc malloc 不够好
1.1 ptmalloc2 的历史
glibc 中的 malloc 实现叫做
ptmalloc2(pthreads malloc version 2),由 Wolfram Gloger 在
Doug Lea 的 dlmalloc 基础上改写而来。dlmalloc
是一个单线程分配器,Gloger 给它加上了多 arena
机制来支持多线程。这段代码从 2006
年开始就几乎没有大的架构改动。
ptmalloc2 的设计哲学可以概括为:在 dlmalloc 的基础上打多线程补丁。这个出发点决定了它的天花板。
1.2 ptmalloc2 的核心结构
ptmalloc2 使用 chunk 作为基本管理单元。每个已分配的 chunk 前面有一个 8 字节(64 位系统上 16 字节)的 header,存储 chunk 大小和几个标志位:
struct malloc_chunk {
size_t prev_size; // 前一个空闲 chunk 的大小(仅当前一个空闲时有效)
size_t size; // 当前 chunk 大小(低 3 位用作标志)
// 以下字段仅在空闲时使用
struct malloc_chunk *fd; // 空闲链表的前驱
struct malloc_chunk *bk; // 空闲链表的后继
};空闲 chunk 通过双向链表串联成多个 bin:
- Fast bins:小于 160 字节的 chunk,单向链表,LIFO,不合并相邻空闲块。速度快但会造成碎片。
- Small bins:小于 512 字节的 chunk(64 位系统上 1024 字节),62 个双向链表,精确大小匹配。
- Large bins:更大的 chunk,63 个范围 bin,bin 内按大小排序,best-fit 分配。
- Unsorted bin:刚释放的 chunk 先进入这里,下次分配时再分类。这是 ptmalloc2 的一个关键优化——如果刚释放的块马上被重新分配(temporal locality),可以避免 bin 分类的开销。
1.3 多线程扩展的困境
ptmalloc2 的多线程方案是 arena。每个 arena 是一个独立的堆,有自己的 bin 和锁。主线程使用 main arena(基于 brk),其他线程可以创建新的 arena(基于 mmap)。
问题出在 arena 的数量限制上。默认情况下,arena 数量上限是
8 * CPU 核心数。当线程数远超这个限制时,多个线程会共享
arena,导致锁争用。
但更根本的问题是:即使 arena 不共享,ptmalloc2 的锁粒度也太粗了。 一个 arena 只有一把锁,保护所有 bin 操作。这意味着一个线程在 fast bin 中分配小对象时,另一个线程无法在同一个 arena 的 large bin 中分配大对象。
// ptmalloc2 的分配路径(简化)
void *malloc(size_t size) {
arena *a = get_arena(); // 选择一个 arena
mutex_lock(&a->mutex); // 锁住整个 arena
void *ptr = _int_malloc(a, size);
mutex_unlock(&a->mutex);
return ptr;
}我曾经在一个 64 核服务器上做过测试:32
个线程同时进行小对象分配/释放,ptmalloc2 的吞吐量只有
jemalloc 的 18%。perf 显示 70% 以上的时间花在了
__lll_lock_wait 上。
1.4 碎片化的噩梦
ptmalloc2 的另一个严重问题是碎片化。考虑以下场景:
- 分配 1000 个 64 字节的对象
- 释放其中的奇数编号对象(500 个)
- 试图分配一个 32KB 的对象
虽然你已经释放了 32000 字节,但这些空闲块散布在已分配块之间,无法合并成一个连续的大块。ptmalloc2 的相邻空闲块合并策略(consolidation)只能合并物理相邻的空闲块,对这种交错释放模式无能为力。
更糟糕的是,ptmalloc2
几乎不会把内存归还给操作系统。free 只是把 chunk
放回 bin,调用 madvise(MADV_DONTNEED)
的条件非常苛刻——只有 top
chunk(堆顶的空闲块)足够大时才会收缩。这导致长期运行的服务
RSS(Resident Set Size)只增不减。
1.5 个人观点
我认为 ptmalloc2 最大的问题不是某个具体的设计缺陷,而是它的时代局限性。它诞生在单核向多核过渡的年代,用”加锁”的方式解决并发问题。在 2006 年 4 核 CPU 时代这是可以接受的,但在 2024 年 128 核 CPU 面前,这种方案注定是死路一条。
现代分配器的核心思想可以归结为一句话:用空间换时间,用线程本地缓存消除锁争用。 接下来我们看看三大分配器如何各自实现这个思想。
二、通用内存分配器的设计目标
在比较具体实现之前,先明确一个好的通用内存分配器需要满足哪些目标。这些目标之间存在根本性的矛盾,不同分配器的”个性”就来自对这些矛盾的取舍。
2.1 速度
分配和释放操作要快。具体来说:
- 平均分配延迟:典型场景下
malloc应该在 100ns 以内完成。 - 最坏延迟:实时系统关心最坏情况。ptmalloc2 在争用时最坏延迟可达毫秒级,这在高频交易中是不可接受的。
- 多线程扩展性:吞吐量应该随核心数线性增长,而不是在某个点开始下降(contention cliff)。
2.2 内存效率
- 内部碎片(internal fragmentation):分配 17 字节时实际使用了 32 字节,浪费了 15 字节。这由 size class 的设计决定。
- 外部碎片(external fragmentation):空闲内存很多但都是小块,无法满足大块请求。这由分配和回收策略决定。
- 元数据开销:每个 chunk 的 header 大小。ptmalloc2 是 16 字节,对大量小对象来说这是显著的开销。
2.3 内存归还
长期运行的服务需要分配器能把不用的内存归还给操作系统。否则 RSS 只增不减,最终 OOM。
归还策略有三种常见方式:
- munmap:直接解除页面映射。最彻底但最慢。
- madvise(MADV_DONTNEED):告诉内核这些页面可以回收。下次访问时内核分配新的零页面。不改变虚拟地址映射。
- madvise(MADV_FREE)(Linux 4.5+):标记页面为”可回收”。内核在内存压力时才真正回收。性能更好但 RSS 统计不准。
2.4 确定性
在某些场景(实时系统、游戏引擎),分配时间的方差比均值更重要。一个分配器平均 50ns 但偶尔 5ms 的抖动,可能不如平均 100ns 但最坏 200ns 的方案。
2.5 不可能三角
这些目标之间存在根本性的矛盾:
- 更快的分配 → 更多的线程本地缓存 → 更多的内存浪费(每个线程都囤积空闲块)
- 更好的碎片控制 → 更复杂的分配策略 → 更慢的分配速度
- 更积极的内存归还 → 更频繁的系统调用 → 更高的延迟
这个不可能三角决定了没有”最好”的分配器,只有最适合特定工作负载的分配器。
三、jemalloc:工程美学的典范
3.1 历史与血统
jemalloc 的全名是 Jason Evans’ malloc,由 Jason Evans 在 2005 年为 FreeBSD 开发。后来被 Firefox(2009 年)和 Facebook/Meta(2011 年起)大规模采用。Redis 从 2.4 版本开始将 jemalloc 作为默认分配器。
jemalloc 的设计哲学是分层隔离:通过精心设计的层级结构,将不同线程、不同大小的分配操作隔离到不同的数据结构中,从根源上消除争用。
3.2 Size Class 的精妙设计
jemalloc 将所有请求大小映射到一组预定义的 size class。size class 的设计直接决定了内部碎片率。
jemalloc 5.x 的 size class 遵循如下规律:
| 范围 | 步长 | Size Class 示例 |
|---|---|---|
| 8 - 128 B | 16 B | 8, 16, 32, 48, 64, 80, 96, 112, 128 |
| 128 B - 256 B | 32 B | 160, 192, 224, 256 |
| 256 B - 512 B | 64 B | 320, 384, 448, 512 |
| … | 以此类推,每组 4 个 size class,步长翻倍 | |
| 大于 14336 B | 页面对齐 | 16384, 20480, 24576, … |
这个设计的核心思想是:每个 size class
的内部碎片率不超过 ~20%。 因为在每组 4 个 size
class 中,最坏情况是请求大小刚好超过前一个 size
class,此时浪费的比例约为
步长 / (前一个size class + 步长) ≈ 1/5 = 20%。
对比 ptmalloc2 的 fast bin 只有 8 字节步长(太细,bin 太多),large bin 用范围匹配(太粗,碎片大),jemalloc 的方案在 bin 数量和碎片率之间取得了更好的平衡。
3.3 三层架构
jemalloc 的核心架构分为三层:
第一层:Thread Cache(tcache)
每个线程有一个本地缓存(tcache),为每个 small size class 维护一个空闲对象链表。分配小对象时直接从 tcache 取,释放时放回 tcache。完全无锁。
// tcache 分配路径(概念代码)
void *tcache_alloc_small(tcache_t *tcache, size_t size) {
szind_t ind = sz_size2index(size); // 请求大小 → size class 索引
cache_bin_t *bin = &tcache->bins[ind];
void *ptr = cache_bin_alloc(bin); // 从线程本地缓存取
if (ptr != NULL) return ptr;
// tcache 为空,从 arena 补货
return tcache_alloc_small_hard(tcache, bin, ind);
}tcache 的容量有限(每个 size class 默认缓存几十到几百个对象),超过上限时会批量归还给 arena。这个”批量”很关键——它把多次锁操作合并为一次,摊销了锁的开销。
第二层:Arena
Arena 是 jemalloc 的核心管理单元。默认创建
4 * CPU 核心数 个 arena,线程通过 round-robin
绑定到 arena(也可以通过 mallctl
手动绑定)。
每个 arena 为每个 small size class 维护一个 bin。bin 管理多个 slab(一个 slab 是一组连续页面,被切分成固定大小的 region)。
Arena
├── bins[0] (size class = 8B)
│ ├── slab 0: [region][region][region]...[region]
│ ├── slab 1: [region][region][region]...[region]
│ └── ...
├── bins[1] (size class = 16B)
│ ├── slab 0: [region][region]...[region]
│ └── ...
├── ...
└── bins[35] (size class = 14336B)
每个 slab 内部用 bitmap 追踪哪些 region
是空闲的。分配时用 ffs(find first
set)找到第一个空闲位,O(1) 操作。
bin 有自己的锁(比 ptmalloc2 的 arena 级锁粒度更细),而且由于 tcache 的存在,大部分分配根本不需要碰到 bin 锁。
第三层:Extent / Page Allocator
大对象(> 14336B)和 slab 本身的页面由 extent
管理。extent 是一段连续的虚拟地址空间,从操作系统通过
mmap 获取。
jemalloc 5.x 用 radix tree(page address trie)做 extent 查找,给定一个页面地址可以 O(1) 找到它属于哪个 extent、哪个 arena。这比 ptmalloc2 依赖 chunk header 中的 prev_size 字段来回溯更高效。
3.4 内存归还策略
jemalloc 有一个后台线程(可通过
background_thread:true 开启)定期做 extent
purging。它使用两种策略:
- Dirty decay:最近释放的 extent
保留一段时间(默认 10 秒),如果期间没有被重新分配,用
madvise(MADV_DONTNEED)归还物理页。 - Muzzy decay:已经 madvise 过的 extent 再保留一段时间,之后才 munmap。
这个两阶段衰减策略(decay-based purging)是 jemalloc 的一大创新。它避免了”刚归还就又要分配”的尴尬,同时保证长期空闲的内存最终会被归还。
分配 → 使用中 → 释放 → dirty(保留物理页)
→ 超时 → madvise → muzzy(虚拟地址保留)
→ 超时 → munmap(彻底释放)
3.5 jemalloc 在 Redis 中的表现
Redis 是 jemalloc 的”招牌用户”。Redis 的内存使用模式非常适合 jemalloc:
- 大量小对象(string, hash entry, list node),正好落在 small size class 范围
- 对象生命周期差异大(有的短命,有的长寿),考验碎片控制能力
- 单线程主循环 + 后台线程,arena 争用很少
Redis 的 INFO memory 命令会显示
mem_fragmentation_ratio,即 RSS /
已分配字节。在 jemalloc 下,这个比例通常在 1.0 - 1.3
之间。换到 ptmalloc2,我见过长期运行后达到 2.5 以上——一半的
RSS 都是碎片。
3.6 我的评价
jemalloc 是我见过的工程美学最好的分配器。它的设计不追求某个单一指标的极致,而是在速度、碎片、归还三个维度都做到”不差”。如果你不知道该选什么,选 jemalloc 通常不会错。
但 jemalloc
有一个实际问题:代码量大、配置项多。
mallctl
接口暴露了上百个调优旋钮,对于不熟悉的人来说,默认配置已经足够好,但要榨取最后
10%
的性能需要深入理解其内部机制。这既是优点(专家可以精细调优),也是缺点(普通用户容易误配)。
四、tcmalloc:Google 的工程实践
4.1 两个 tcmalloc
“tcmalloc” 这个名字其实指代两个不同的实现:
- gperftools tcmalloc:最初由 Sanjay Ghemawat 开发,2005 年开源在 google-perftools(后改名 gperftools)中。这是”经典”tcmalloc。
- 新 tcmalloc:Google 2019 年单独开源的版本(github.com/google/tcmalloc),是 Google 内部使用的版本,经过了大幅重写。
本文主要分析新 tcmalloc,它代表了 Google 十多年大规模生产经验的结晶。
4.2 三级缓存架构
新 tcmalloc 的架构是清晰的三级缓存:
Thread Cache → Central Free List → Page Heap
(无锁) (per-size-class 锁) (全局锁)
Thread Cache(前端)
每个线程有独立的缓存(per-thread cache),这也是 “tc”malloc 名字的由来。Thread cache 为每个 size class 维护一个空闲对象链表。
与 jemalloc 的 tcache 不同,tcmalloc 的 thread cache 容量是动态调整的。Google 发现,固定大小的 thread cache 对某些线程浪费空间(分配模式简单的线程不需要大缓存),对另一些线程又不够用。
新 tcmalloc 引入了一个全局容量管理器:所有 thread cache 的总容量有一个上限(默认 ~32MB),各线程按实际分配频率竞争配额。分配频繁的线程获得更大的缓存,反之缩小。
Central Free List(中端)
当 thread cache 为空时,从 central free list 补货。Central free list 为每个 size class 维护一个全局空闲链表,受自旋锁保护。
关键优化:thread cache 批量从 central free list 取走或归还对象(batch transfer),而不是一个一个来。批量大小根据对象大小动态计算——小对象批量大(摊销锁开销),大对象批量小(避免浪费)。
Page Heap(后端)
Central free list 的 span(连续页面块)从 page heap 分配。Page heap 管理从操作系统获取的大块虚拟内存。
新 tcmalloc 在 page heap 中使用了 hugepage-aware allocator,这是它与 jemalloc 最大的差异化特性。
4.3 Size Class 设计
tcmalloc 的 size class 设计也是分组递增的,但细节与 jemalloc 不同:
小对象(< 256KB):
8, 16, 32, 48, 64, 80, 96, 112, 128,
144, 160, 176, ..., 2048,
2176, 2304, ..., 262144
大对象(>= 256KB):直接由 page heap 分配,页面对齐
tcmalloc 的 size class 到 size_class_index
的映射是通过查表完成的。分配路径中的 size class
查找是一次数组索引,非常快。
4.4 Span:连续页面的管理
tcmalloc 中的 span 类似于 jemalloc 的 extent——一段连续的页面。每个 span 有固定的 size class,内部被切分成等大小的 slot。
span 的关键属性:
struct Span {
PageId first_page; // 起始页号
Length num_pages; // 页面数
SizeClass size_class; // 0 表示大对象
uint16_t allocated; // 已分配 slot 数
void *freelist; // 空闲 slot 链表
// ...
};从一个指针找到它所属的 span,tcmalloc 使用 pagemap——一个全局的、以页号为索引的 radix tree。在 64 位系统上,这是一个三级 radix tree(类似页表),查找时间是常数级。
4.5 Hugepage-Aware Allocator
这是新 tcmalloc 最令人印象深刻的特性。
现代 x86-64 CPU 默认页面大小是 4KB。一个页表项(PTE)映射一个 4KB 页面。64GB 内存需要 1600 万个 PTE,TLB(Translation Lookaside Buffer)装不下这么多条目——缺页和 TLB miss 就成了性能瓶颈。
大页(Huge Pages,通常 2MB)可以将 PTE 数量减少 512 倍。但使用大页的前提是分配器给出的内存能填满整个 2MB 页面。如果分配器把来自不同 size class 的对象散布在同一个大页中,操作系统就无法使用透明大页(THP)。
Google 的数据显示,启用 hugepage-aware allocator 后,Google 内部工作负载的 TLB miss 减少了 ~7%,整体 CPU 效率提升了 ~1%。在 Google 的规模下,1% 的 CPU 效率意味着节省上万台服务器。
新 tcmalloc 的 page heap 分为两个子分配器:
- HugePageFiller:管理部分填充的大页。分配小 span 时,优先从已经部分使用的大页中分配,最大化大页的填充率。
- HugePageAwareAllocator:管理完全空闲的大页。当需要新大页时,从操作系统申请 2MB 对齐的内存。
操作系统
└── HugePageAwareAllocator(管理完整 2MB 大页)
└── HugePageFiller(将大页拆分为 span)
└── Central Free List(将 span 拆分为 object)
└── Thread Cache(线程本地缓存 object)
4.6 Per-CPU 模式
新 tcmalloc 还提供了一个实验性的 per-CPU cache 模式(替代 per-thread cache)。
在传统的 per-thread 模式下,每个线程有自己的缓存。但在 Go 的 goroutine 或线程池模式下,一个 CPU 核心上可能运行成百上千个 goroutine/task,每个都有自己的 thread cache,内存浪费严重。
Per-CPU 模式的思路是:缓存与 CPU
核心绑定而非与线程绑定。使用 rseq(restartable
sequences,Linux 4.18+)获取当前 CPU ID,避免锁操作。
// Per-CPU 分配路径(概念代码)
void *percpu_alloc(size_t size) {
int cpu = rseq_current_cpu(); // 无锁获取当前 CPU
PerCpuCache *cache = &per_cpu_caches[cpu];
void *ptr = cache->pop(size_class);
if (ptr) return ptr;
return slow_path(size);
}这种模式下,128 核服务器只需要 128 份缓存,而不是数万份(线程数)。Google 报告称,在某些工作负载下,per-CPU 模式比 per-thread 模式节省 3% 的总内存。
4.7 我的评价
新 tcmalloc 是 Google 工程文化的缩影:用数据驱动设计,为自己的工作负载深度优化。
hugepage-aware allocator 是一个绝妙的设计,但它的收益在 Google 的环境中最大化——大内存、长期运行、统一的内核版本。如果你的服务器只有 4GB 内存,或者运行在容器中没有大页权限,这个特性的收益就不明显了。
per-CPU 模式也很有前瞻性,但依赖 rseq
系统调用,限制了可移植性。
总的来说,tcmalloc 是一个为数据中心优化的分配器。如果你在 Google-like 的环境中(大规模、长期运行、Linux、大内存),tcmalloc 是最佳选择。在其他环境中,jemalloc 通常更稳妥。
五、mimalloc:极简主义的胜利
5.1 来历
mimalloc(读作 “me-malloc”)由微软研究院的 Daan Leijen 在 2019 年发布。Leijen 是一位编程语言研究者(Koka 语言的作者),他开发 mimalloc 的初衷是为垃圾回收型语言提供高性能的底层分配器。
mimalloc 的论文(APLAS 2019)标题简洁有力:“Mimalloc: Free List Sharding in Action”。这个标题概括了它的核心思想。
5.2 三层结构
mimalloc 的架构极其简洁:
Segment(64MB 虚拟地址块)
└── Page(一组连续的 slice,专属某个 size class)
└── Block(固定大小的分配单元)
注意这里的 “page” 不是操作系统的 4KB 页面,而是 mimalloc 自己的概念。一个 mimalloc page 可以是 64KB 到几 MB 大小。
5.3 Free List Sharding:分片空闲链表
mimalloc 的核心创新是空闲链表分片(free list sharding)。
传统分配器(包括 jemalloc 和 tcmalloc)中,每个 size class 的空闲对象在一个(或少数几个)链表中。跨线程释放(线程 A 分配的对象由线程 B 释放)需要特殊处理,通常涉及锁或原子操作。
mimalloc 的做法是:每个 page 有三个空闲链表:
struct mi_page_s {
mi_block_t *free; // 本线程的空闲链表(快速路径)
mi_block_t *local_free; // 本线程释放的对象(延迟放入 free)
_Atomic(mi_block_t*) xthread_free; // 其他线程释放的对象(原子操作)
// ...
};分配路径只看 free
链表,完全无锁无原子操作。当 free 为空时,先把
local_free 交换过来(还是无锁)。如果
local_free 也为空,再原子地取走
xthread_free。
跨线程释放时,其他线程用
CAS(Compare-And-Swap)将对象加入
xthread_free。由于只需要对链表头做
CAS,争用极少。
// mimalloc 分配路径(简化)
void *mi_malloc_small(size_t size) {
mi_page_t *page = mi_find_page(size);
mi_block_t *block = page->free;
if (block != NULL) {
page->free = block->next; // 单个 load + store,极快
return block;
}
return mi_malloc_generic(size); // slow path
}这个设计的精妙之处在于:快速路径只有一次指针读取和一次指针写入,没有任何锁、原子操作或分支。在 x86-64 上这编译成大约 4 条指令。
5.4 Page 的局部性优化
mimalloc 的另一个关键设计是:分配尽量在同一个 page 内进行,最大化空间局部性。
每个线程有一组 page(每个 size class 对应一个当前活跃 page)。同一个 page 内的所有对象在物理内存上是连续的。这意味着:
- 连续分配的对象大概率在同一 cache line 附近
- 遍历这些对象时 prefetch 效果好
- 释放时大概率归还到同一个 page,减少跨页面操作
5.5 Segment 与虚拟地址管理
mimalloc 使用 64MB 的 segment
作为虚拟地址空间的管理粒度。每个 segment
从操作系统一次性申请(mmap
64MB),然后内部切分为 page。
64MB 的 segment 大小是精心选择的:
- 足够大,减少 mmap 系统调用次数
- 足够小,segment 级别的浪费可接受
- 64MB 对齐后,从指针到 segment
的查找只需位运算:
segment = ptr & ~(64MB - 1)
5.6 为什么 mimalloc 的 benchmark 经常最好
mimalloc 在许多公开 benchmark(如 mimalloc-bench)中表现最好。原因有几个:
- 分配路径极短:快速路径只有 4 条 x86 指令,而 jemalloc 大约 15-20 条,tcmalloc 大约 10-15 条。
- 缓存友好性:page 内分配保证了空间局部性。free list 本身也在 page header 中,不需要额外的查表操作。
- 设计简洁:更少的代码路径意味着更好的
I-cache 利用率。jemalloc 的
malloc路径涉及 tcache → bin → extent 等多层判断,指令缓存压力大。
但有一个重要的 caveat:benchmark 分配模式与生产工作负载可能有很大差异。 mimalloc-bench 中的测试侧重于分配/释放吞吐量,但在真实应用中,内存碎片、归还策略、大页支持等因素同样重要。
5.7 局限性
说说 mimalloc 的不足:
- 碎片控制不如 jemalloc:mimalloc 的 page 粒度比较粗,在高碎片化工作负载下,RSS 可能比 jemalloc 高 10-20%。
- 没有 hugepage-aware allocator:mimalloc 不像 tcmalloc 那样主动管理大页,依赖操作系统的 THP(Transparent Huge Pages)。
- 调优手段有限:mimalloc 的配置项很少,这是简洁的代价——当默认配置不够好时,你没有太多旋钮可以拧。
- 社区生态较小:相比 jemalloc(FreeBSD、Redis、Rust)和 tcmalloc(Google 全线产品),mimalloc 的生产级采用相对有限。
5.8 我的评价
mimalloc 是一个论文级别的优美设计。它证明了在内存分配器领域,简洁可以战胜复杂。如果你的工作负载是分配密集型(大量小对象、频繁分配释放),mimalloc 几乎总是最快的。
但”最快”不等于”最好”。在长期运行的服务中,碎片和内存归还往往比峰值吞吐更重要。这也是 Redis 选择 jemalloc 而非 mimalloc 的原因——Redis 可以 24/7 运行数月,碎片控制是刚需。
六、深入对比:用数据说话
6.1 测试方法
以下数据来自公开 benchmark 和我自己的测试,供定性参考。运行环境:
- CPU:AMD EPYC 7763 64-Core(使用 16 核)
- 内存:128 GB DDR4-3200
- OS:Ubuntu 22.04, Linux 5.15
- 编译器:GCC 12.2, -O2
6.2 单线程小对象吞吐量
测试内容:循环分配和释放 32 字节对象,100M 次。
| 分配器 | 吞吐量 (Mops/s) | 平均延迟 (ns) |
|---|---|---|
| ptmalloc2 | 95 | 10.5 |
| jemalloc 5.3 | 180 | 5.6 |
| tcmalloc (new) | 210 | 4.8 |
| mimalloc 2.1 | 250 | 4.0 |
mimalloc 的极简快速路径在单线程场景下优势明显。
6.3 多线程扩展性
测试内容:N 个线程各自独立分配/释放 32 字节对象,测量总吞吐量。
| 线程数 | ptmalloc2 | jemalloc | tcmalloc | mimalloc |
|---|---|---|---|---|
| 1 | 95 | 180 | 210 | 250 |
| 4 | 210 | 680 | 800 | 940 |
| 8 | 280 | 1320 | 1550 | 1800 |
| 16 | 300 | 2500 | 2900 | 3200 |
ptmalloc2 在 8 线程后基本饱和(锁争用),三大现代分配器都保持了近似线性的扩展性。
6.4 碎片率
测试内容:模拟 Redis 工作负载——分配大量不同大小的对象(16B-4KB 均匀分布),随机释放 50%,再分配 30%,循环 10 轮。
| 分配器 | RSS / 已分配 | 归还后 RSS / 已分配 |
|---|---|---|
| ptmalloc2 | 1.45 | 1.82 |
| jemalloc | 1.12 | 1.15 |
| tcmalloc | 1.18 | 1.22 |
| mimalloc | 1.22 | 1.35 |
jemalloc 在碎片控制上的优势明显,特别是在内存归还后。ptmalloc2 几乎不归还内存,导致碎片率持续上升。
6.5 跨线程释放
测试内容:线程 A 分配 1M 个 64B 对象,然后由线程 B 全部释放。测量释放吞吐量。
| 分配器 | 释放吞吐量 (Mops/s) |
|---|---|
| ptmalloc2 | 45 |
| jemalloc | 120 |
| tcmalloc | 100 |
| mimalloc | 160 |
mimalloc 的 xthread_free
链表设计让跨线程释放只需要一次 CAS 操作,性能最好。
6.6 真实应用基准
在 Redis(SET/GET 混合负载,100 万个 key,value 大小 64-512B)下的 QPS:
| 分配器 | QPS | RSS (MB) | 碎片率 |
|---|---|---|---|
| ptmalloc2 | 148,000 | 420 | 1.65 |
| jemalloc | 162,000 | 285 | 1.08 |
| tcmalloc | 159,000 | 310 | 1.15 |
| mimalloc | 165,000 | 330 | 1.22 |
Redis 的默认选择(jemalloc)确实是最均衡的:性能与 mimalloc 相当,但 RSS 最小。
七、选型指南
基于以上分析,我的建议:
选 jemalloc 的场景
- 长期运行的服务(Redis、数据库、Web 服务器):碎片控制和内存归还最优。
- 不确定工作负载特征时:jemalloc 是最安全的默认选择。
- 需要精细调优时:
mallctl提供了丰富的运行时监控和调参能力。 - FreeBSD 系统:jemalloc 是 FreeBSD 默认的系统 malloc。
选 tcmalloc 的场景
- Google Cloud / 大规模数据中心:hugepage-aware allocator 的收益在大内存服务器上最大化。
- Go 语言项目(Go 1.22+ 的 memory allocator 设计受 tcmalloc 影响)。
- 需要 per-CPU 缓存:大量短命线程/goroutine 的场景。
- 配套 pprof 性能分析:tcmalloc 与 Google 的性能工具链集成最好。
选 mimalloc 的场景
- 分配密集型程序(编译器、解释器、图处理):极致的分配速度。
- 嵌入到语言运行时:简洁的设计容易集成和定制。
- 对代码可读性有要求:mimalloc 的代码量只有 jemalloc 的 1/5。
不要选 ptmalloc2 的场景
几乎所有多线程服务都应该考虑替换 ptmalloc2。如果你在使用
glibc 的默认 malloc
并且有性能问题,第一件事就是换分配器。通常只需要
LD_PRELOAD 一行配置:
LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libjemalloc.so.2 ./your_program八、在 Rust 中替换全局分配器
Rust 允许通过 #[global_allocator]
属性替换全局分配器,这是语言层面对分配器选型的一等支持。
// 使用 jemalloc
use tikv_jemallocator::Jemalloc;
#[global_allocator]
static GLOBAL: Jemalloc = Jemalloc;
fn main() {
// 所有堆分配都走 jemalloc
let v: Vec<u8> = Vec::with_capacity(1024);
println!("allocated {} bytes", v.capacity());
}# Cargo.toml
[dependencies]
tikv-jemallocator = "0.6"类似地,可以使用 mimalloc 或
tcmalloc:
// 使用 mimalloc
use mimalloc::MiMalloc;
#[global_allocator]
static GLOBAL: MiMalloc = MiMalloc;// 使用 tcmalloc
use tcmalloc::TCMalloc;
#[global_allocator]
static GLOBAL: TCMalloc = TCMalloc;Rust 的 GlobalAlloc trait
要求实现两个方法:
pub unsafe trait GlobalAlloc {
unsafe fn alloc(&self, layout: Layout) -> *mut u8;
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout);
// 以及可选的 alloc_zeroed, realloc
}注意 dealloc 接收 Layout
参数(包含大小和对齐要求),这比 C 的 free(ptr)
多了一个信息——分配器不需要从指针反查大小,可以省去 header 或
pagemap 查找。这是 Rust 分配器接口比 C 更优的一个地方。
九、工程踩坑备忘
在生产中使用这些分配器时,以下是我和同事们踩过的坑:
| 坑 | 现象 | 原因 | 解法 |
|---|---|---|---|
| jemalloc RSS 不降 | 服务 RSS 持续增长,mallctl 显示 dirty pages
大量积压 |
dirty decay 默认 10 秒太保守,高分配率下来不及归还 | MALLOC_CONF=dirty_decay_ms:1000,muzzy_decay_ms:0 |
| tcmalloc per-thread cache 内存浪费 | 1000 个 goroutine,每个 thread cache 占 64KB,白白浪费 64MB | per-thread 模式下短命线程的缓存不会回收 | 切换到 per-CPU 模式,或设置
TCMALLOC_MAX_TOTAL_THREAD_CACHE_BYTES |
| mimalloc 跨线程释放慢 | 特定工作负载下释放延迟飙升 | 大量跨线程释放导致 xthread_free CAS
争用 |
评估是否可以让同一线程负责分配和释放 |
| LD_PRELOAD 在容器中失效 | Docker 容器里 LD_PRELOAD 指向了宿主机的 .so 路径 | 容器内外路径不同 | 把 .so 拷贝到容器内,或用静态链接 |
| jemalloc prof 堆外内存不计 | pprof 显示 1GB,RSS 显示 3GB | jemalloc prof 只统计 jemalloc 自己的分配,不包括 mmap 直接分配 | 同时检查 /proc/pid/smaps 的 anonymous
映射 |
| ptmalloc2 arena 数量爆炸 | 高并发短连接服务,arena 数量增长到数百个 | 每个线程可能创建新 arena,线程退出后 arena 不回收 | 设置 MALLOC_ARENA_MAX=4 或换分配器 |
碎片率的数学理解
外部碎片率并非玄学。对于使用 size class 的分配器,内部碎片率有理论上界。
设 size class 序列为 \(s_1 < s_2 < \cdots < s_k\),请求大小为 \(r\),实际分配 \(s_i\) 满足 \(s_{i-1} < r \le s_i\)。内部碎片率为:
\[ \text{fragmentation}(r) = \frac{s_i - r}{s_i} \]
最坏情况发生在 \(r = s_{i-1} + 1\) 时:
\[ \text{fragmentation}_{\max} = \frac{s_i - s_{i-1} - 1}{s_i} \approx 1 - \frac{s_{i-1}}{s_i} \]
jemalloc 的 size class 每组 4 个,比例约为 \(1 : 1.25 : 1.5 : 1.75 : 2\),所以相邻 size class 的比值约 1.25,最坏内部碎片率约 \(1 - 1/1.25 = 20\%\)。
tcmalloc 的 size class 更密,小对象区域相邻比值约 1.12-1.15,最坏内部碎片率约 12-15%。但更多的 size class 意味着更多的 bin 和更多的缓存浪费——这就是碎片和开销之间的 trade-off。
外部碎片的理论分析更复杂。Robson (1971) 证明,对于任意 online 分配算法(不知道未来请求序列),最坏情况下需要 \(M \cdot \log(M/m)\) 的空间来服务最大在用内存为 \(M\)、最小分配单元为 \(m\) 的请求序列。这个对数因子是不可避免的。
但这是最坏情况理论。实际工作负载远不如对抗性序列那么恶劣。jemalloc 在大多数真实场景下能将碎片率控制在 10% 以内,这已经远好于理论下界。
十、使用 perf 分析分配器性能
光看 benchmark 数字还不够。用 perf
可以深入理解性能差异的来源。
# 统计分配器的缓存命中率
perf stat -e cache-references,cache-misses,L1-dcache-load-misses \
./benchmark_malloc
# 统计锁争用(ptmalloc2 vs 现代分配器的差异会非常明显)
perf stat -e context-switches,cpu-migrations \
./benchmark_malloc
# 分析分配器函数的热点
perf record -g ./benchmark_malloc
perf report --no-children一个典型的 perf stat 对比(16 线程,32B
对象分配):
# ptmalloc2
Performance counter stats:
12,847,293,104 cache-references
2,156,893,442 cache-misses # 16.79% of all cache refs
847,293,000 context-switches
# jemalloc
Performance counter stats:
8,234,891,002 cache-references
412,744,550 cache-misses # 5.01% of all cache refs
12,847 context-switches
# mimalloc
Performance counter stats:
6,891,234,001 cache-references
275,649,360 cache-misses # 4.00% of all cache refs
8,234 context-switches
关键观察:
- cache-misses:ptmalloc2 的缓存命中率远低于现代分配器,因为链式哈希表的指针追踪破坏了空间局部性。
- context-switches:ptmalloc2 的上下文切换次数高出 4 个数量级——这些都是锁争用导致的线程让步。
- mimalloc 的缓存命中率最高,归功于 page 内连续分配的局部性优势。
十一、实现一个简单的 Arena Allocator
理解分配器最好的方式是自己写一个。下面实现一个极简的 arena allocator(bump allocator),适用于”批量分配,统一释放”的场景,比如编译器的 AST 节点分配。
// arena.h - 极简 Arena Allocator
#include <stddef.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#define ARENA_BLOCK_SIZE (1024 * 1024) // 1MB per block
typedef struct ArenaBlock {
struct ArenaBlock *next;
size_t size;
size_t used;
// data follows
} ArenaBlock;
typedef struct Arena {
ArenaBlock *current;
ArenaBlock *head;
} Arena;
static ArenaBlock *arena_block_new(size_t min_size) {
size_t size = min_size > ARENA_BLOCK_SIZE ? min_size : ARENA_BLOCK_SIZE;
ArenaBlock *block = (ArenaBlock *)malloc(sizeof(ArenaBlock) + size);
if (!block) return NULL;
block->next = NULL;
block->size = size;
block->used = 0;
return block;
}
static void arena_init(Arena *arena) {
arena->current = arena_block_new(ARENA_BLOCK_SIZE);
arena->head = arena->current;
}
static void *arena_alloc(Arena *arena, size_t size, size_t align) {
ArenaBlock *block = arena->current;
// 对齐
size_t padding = (align - ((uintptr_t)(block + 1) + block->used) % align) % align;
size_t total = padding + size;
if (block->used + total > block->size) {
// 当前 block 空间不够,分配新 block
ArenaBlock *new_block = arena_block_new(total);
if (!new_block) return NULL;
block->next = new_block;
arena->current = new_block;
block = new_block;
padding = (align - ((uintptr_t)(block + 1) + block->used) % align) % align;
total = padding + size;
}
void *ptr = (uint8_t *)(block + 1) + block->used + padding;
block->used += total;
return ptr;
}
static void arena_destroy(Arena *arena) {
ArenaBlock *block = arena->head;
while (block) {
ArenaBlock *next = block->next;
free(block);
block = next;
}
arena->current = NULL;
arena->head = NULL;
}
// 便捷宏
#define ARENA_ALLOC(arena, type) \
((type *)arena_alloc((arena), sizeof(type), _Alignof(type)))
#define ARENA_ALLOC_ARRAY(arena, type, count) \
((type *)arena_alloc((arena), sizeof(type) * (count), _Alignof(type)))使用示例:
#include <stdio.h>
#include "arena.h"
typedef struct ASTNode {
int kind;
struct ASTNode *left;
struct ASTNode *right;
char name[32];
} ASTNode;
int main(void) {
Arena arena;
arena_init(&arena);
// 在编译器的一个 pass 中,大量分配 AST 节点
for (int i = 0; i < 100000; i++) {
ASTNode *node = ARENA_ALLOC(&arena, ASTNode);
node->kind = i % 5;
node->left = NULL;
node->right = NULL;
snprintf(node->name, sizeof(node->name), "node_%d", i);
}
// pass 结束,一次性释放所有节点
arena_destroy(&arena);
// 不需要逐个 free,没有碎片问题
printf("Done.\n");
return 0;
}这个 arena allocator 的优势:
- 分配速度:O(1),只是指针递增(bump allocation)
- 释放速度:O(block count),一次性释放所有
- 零碎片:不存在外部碎片问题
- 零元数据开销:不需要 per-object header
代价是不能单独释放某个对象——这在编译器、解析器、请求处理等”批量分配-统一释放”的场景中完全可以接受。Go 和 Rust 的 bumpalo crate 都提供了类似的 arena allocator。
十二、总结与个人思考
内存分配器的设计哲学谱系
如果把三大分配器放在一个坐标系中:
- X 轴:简洁 ←→ 复杂
- Y 轴:速度优先 ←→ 内存效率优先
速度优先
↑
│ mimalloc
│ ●
│
│ tcmalloc
│ ●
│
│ jemalloc
│ ●
│
简洁 ←────┼──────────────────────→ 复杂
│
│ ptmalloc2
│ ●
│
↓
内存效率优先
未来趋势
- 硬件感知分配(hardware-aware allocation)。tcmalloc 的 hugepage-aware 是第一步,未来会有 NUMA-aware、CXL memory pool-aware 的分配器。
- 可组合分配器(composable
allocators)。Zig
语言的分配器接口就是这个方向——任何函数都可以接受一个
allocator 参数,而不是绑定全局 malloc。Rust 的
Allocatortrait(nightly)也在往这个方向发展。 - 机器学习辅助。Google 的论文 “Learning-based Memory Allocation for C++ Server Workloads”(2020)展示了用 ML 预测分配模式并动态调整 size class 的可能性。
- 安全分配器。OpenBSD 的 malloc 把安全性放在第一位(随机化、guard pages),性能代价很大但防御了大量堆溢出攻击。ChromeOS 的 PartitionAlloc 也在这个方向努力。
最后一点个人意见
我认为内存分配器是系统编程中最被低估的组件之一。大多数工程师遇到性能问题时会去优化算法、加缓存、上异步,但很少有人想到”也许换一个 malloc 就能解决”。
如果你维护的是一个长期运行的 C/C++
服务,请把”测试不同内存分配器”作为性能调优清单的第一项。这可能是投入产出比最高的一次优化——只需要一行
LD_PRELOAD,不改任何代码。
上一篇: (系列索引)
下一篇: 页面置换算法:LRU
的谎言与 ARC 的真相
相关阅读: - 伙伴系统与 SLUB 分配器 - epoll 的数据结构