一句话结论(30s)
jemalloc 的本质是专为「大量小对象、高频分配释放、碎片敏感」场景设计的内存分配器。关键设计:因为 Redis 单线程里每秒几十万次 malloc/free、且碎片率每升 10% 就丢 GB 级容量,jemalloc 用四层架构(tcache 线程缓存 → arena bins/extents → huge)配合 200+ 细粒度 size class 和红黑树 extent 管理,把内碎片与锁竞争同时压低。权衡:延迟回收(不积极 munmap)牺牲「及时归还 OS」,换来下次分配免 mmap 的性能——所以 used_memory 降了但 RSS 不降是特性而非 bug。
核心原理(2min)
jemalloc 分四层:L1 tcache 每线程独立 size class 缓存,命中时无锁 pop/push(<10ns),miss/overflow 才批量 fill/flush 到 arena(锁竞争降 N 倍);L2 arena 内 bins 管小对象、extents 用红黑树按大小组织以支持 best-fit 分配与相邻合并;L3 huge 单独管理。size class 从 8B 到 14KB 共 200+ 档、每级约 12.5% 增长,内碎片上限可控。回收走「延迟 purge」:对象 free 回 tcache → 满则 flush 回 arena → 后台线程 madvise(MADV_DONTNEED) 标记可回收、保留虚拟地址供下次复用。相比 ptmalloc 碎片率 1.5-2.0,jemalloc 可控制在 1.0-1.2。
底层深入(5-10min)
一、为什么 Redis 要自己指定内存分配器?
Redis 在编译时可以指定使用哪个内存分配器:
make MALLOC=jemalloc # 默认
make MALLOC=libc # 使用系统 malloc(ptmalloc on Linux)
Redis 不使用系统默认的 malloc,原因是 Redis 的内存使用模式非常特殊:
- 大量小对象分配释放:Key、Value、SDS、ziplist、quicklistNode —— 每个对象通常几字节到几十 KB
- 高频内存操作:单线程事件循环中每秒数十万次内存分配/释放
- 碎片敏感:Redis 是内存数据库,碎片率上升 10% 可能意味着丢失 GB 级可用容量
- 多实例部署:一台机器可能跑 10-20 个 Redis 实例
这些特征使通用的 ptmalloc(glibc 默认)暴露出明显短板。
思考:为什么内存分配器对 Redis 这么重要,普通业务程序却很少关心?关键在「碎片率 × 内存体量」——Redis 是内存数据库,数据都常驻内存,碎片率每升 10% 可能就丢几个 GB 容量;而普通程序内存大多短暂申请、用完即还,碎片问题不痛。想清楚「Redis 的分配/释放是高频、长期、碎片敏感」这三点,就明白为什么它要专门挑一个分配器。
二、ptmalloc vs tcmalloc vs jemalloc
2.1 ptmalloc(glibc malloc)
ptmalloc 基于 边界标记法 和 arena 机制:
// ptmalloc 内部结构(简化)
struct malloc_chunk {
size_t prev_size; // 前一个 chunk 的大小(如果前一个空闲)
size_t size; // 当前 chunk 大小 + 标志位(3 位)
struct malloc_chunk *fd; // 空闲链表的前向指针
struct malloc_chunk *bk; // 空闲链表的后向指针
};
- Arena:每个线程从多个 arena 中获取内存,减少锁竞争
- 但多个 arena 之间不共享空闲内存 → arena 膨胀
- 小对象从 fastbin → smallbin → largebin(复杂的分级缓存),碎片随运行时间累积
Redis 场景下 ptmalloc 的致命伤:长期运行后内存碎片率可达 1.5-2.0,即 Redis 看到 10GB 数据实际占用 RSS 15-20GB。多实例时碎片更严重。
2.2 tcmalloc(Google)
tcmalloc 的核心是 线程缓存(Thread Cache)+ 中央空闲链表(Central Free List)+ 页堆(Page Heap):
- 每个线程有自己的 tcache,分配释放无需全局锁
- 小对象按 size class 静态划分(8B, 16B, 32B, …)
- 超过阈值的对象从 Page Heap 分配(spinlock 保护)
tcmalloc 的多核性能优于 ptmalloc,但 size class 划分较粗(间隔较大),导致内碎片较多(分配 33 字节实际占 40 字节)。
2.3 三者对比
| 维度 | ptmalloc | tcmalloc | jemalloc |
|---|---|---|---|
| 碎片控制 | 差(1.5-2.0x) | 中(1.2-1.5x) | 好(1.0-1.2x) |
| 多核扩展 | 中(arena 锁竞争) | 好(线程缓存) | 优(细粒度锁 + tcache) |
| 内存回收 | 积极(munmap) | 延迟 | 延迟(可配置) |
| size class 精度 | 粗 | 中 | 细(200+ 个 bucket) |
| 调试/分析 | 基础 | malloc_extension | 丰富的统计接口(malloc_stats_print) |
| 适配 Redis | 差(碎片高) | 中 | 优(默认选择) |
Redis 开发者 antirez 在多篇博客中阐述了从 ptmalloc 切换到 jemalloc 的理由,核心一句话:jemalloc 在 “碎片控制” 和 “多核稳定” 两个维度上同时最优。
三、jemalloc 的核心架构
3.1 四层分配
┌─────────────────────────────────┐
│ tcache │ ← L1: 线程缓存(无锁,最快)
│ (每个线程独立的 size class 缓存) │
└──────────────┬──────────────────┘
│ tcache miss/overflow
┌──────────────▼──────────────────┐
│ arena (N个) │ ← L2: arena(细粒度锁)
│ ┌──────────┬──────────────┐ │
│ │ bins │ extents │ │
│ │(small) │ (红黑树管理) │ │
│ └──────────┴──────────────┘ │
└──────────────┬──────────────────┘
│ extent split/merge
┌──────────────▼──────────────────┐
│ Huge (≥ chunksize) │ ← L3: 单独大对象管理
└─────────────────────────────────┘
3.2 tcache:线程级零锁缓存
typedef struct tcaches_s {
// 每个线程维护一个 tcache
cache_bin_t bins[TCACHE_NSLABS]; // 每个 size class 一个 bin
// ...
} tcaches_t;
typedef struct cache_bin_s {
void **avail; // 指向可用对象栈
uint32_t ncached; // 当前缓存对象数
uint32_t low_water; // 低水位:低于此值触发填充
} cache_bin_t;
tcache 的工作流程:
分配 (malloc):
1. 查 tcache 中对应 size class 的 bin
2. 有空闲对象 → pop 返回(无锁) → 耗时 < 10ns
3. 无空闲 → tcache fill:从 arena 批量获取 N 个对象(需要锁)
释放 (free):
1. 查 tcache 中对应 size class 的 bin
2. 未满 → push 入栈(无锁)
3. 已满 → tcache flush:批量归还 N 个对象给 arena(需要锁)
批量操作(fill/flush)将锁竞争次数降低了 N 倍(N 通常为几十到几百),这是 jemalloc 多核扩展性的关键。
思考:为什么「批量」能降锁竞争?顺着推——如果每次 malloc 都直接去 arena 拿、每次 free 都直接还回去,每次都要抢 arena 的那把锁,N 次分配就是 N 次锁竞争;改成 tcache 先攒着、攒满才一次性 fill/flush,N 次分配只碰一次锁,竞争次数除以 N。这就是「摊薄」的思想:把高频的小锁操作合并成低频的大批量操作。
3.3 细粒度 size class
jemalloc 预定义了 200+ 个 size class,从 8 字节到 14KB,间隔极小:
size class 示例(部分):
8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256,
320, 384, 448, 512, 640, 768, 896, 1024, 1280, 1536, 1792, 2048, ...
计算 size class 的算法(jemalloc 内部,size_classes.sh 生成):
// 简化的 size class 生成逻辑
// 小对象:1/8 对数间隔
// 原则:最多 12.5% 的内碎片
for (size = 8; size <= 14336; ) {
size = roundup(size + size/8, alignment);
// 每级增长约 12.5%
}
对比 tcmalloc 的 88 个 size class 和 ptmalloc 的更粗划分,jemalloc 的内碎片最小。以 33 字节对象为例:jemalloc 分到 40 字节 bucket(浪费 7 字节 / 17.5%),ptmalloc 分到 48 字节(浪费 15 字节 / 31.3%)。
3.4 红黑树管理 extent
jemalloc 中的 extent(对应页管理中的 run/chunk)以红黑树组织,而不是链表:
typedef struct extent_s {
uint64_t e_bits; // 状态位(地址、大小、是否脏等)
void *e_addr; // 虚拟地址
size_t e_size; // 大小
extent_node_t e_node; // 红黑树节点
} extent_t;
为什么红黑树而非链表?
- 分配时按”最适合”策略:找到 ≥ 请求大小的最小空闲 extent,避免浪费
- 红黑树 O(log n) 按大小查找,链表的线性扫描在大碎片化时可能很慢
- 合并相邻空闲 extent(coalescing)时需高效查找前后邻接块
思考:为什么 extent 要用红黑树而不是简单的空闲链表?反着想链表的痛点——分配时要按「最适合」策略找「≥ 请求大小的最小空闲块」,链表只能从头线性扫,碎片一多就慢成 O(N);合并相邻空闲块还要找到前后邻接块,链表定位也慢。红黑树按大小有序、O(log N) 查找,既支持 best-fit 又支持高效邻接合并,本质是用「一点点树结构开销」换「碎片化和合并的效率」。
3.5 延迟回收(Lazy Purging)
jemalloc 不立即归还内存给 OS(不频繁 munmap),而是:
1. 对象 free → 进入 tcache
2. tcache 满 → flush → arena bin
3. arena bin 积累足够多脏 extent → 后台线程 purge
4. purge:madvise(MADV_DONTNEED) → 标记 OS 可回收物理页 → 保留虚拟地址
5. 虚拟地址保留意味着:下次分配时直接 madvise(MADV_NORMAL) 重新使用,无需 mmap
这与 ptmalloc 的积极 munmap 形成鲜明对比。积极 munmap 在 Redis 场景下有两个问题:
- 数据库启动后 RSS 从 0 重新增长,无法利用之前保留的虚拟地址
- mmap/munmap 系统调用开销大于 madvise
Redis 内存”不回收”现象的解释:used_memory 下降了,但 used_memory_rss 不降——因为 jemalloc 保留了虚拟地址和物理页(purge 有延迟和阈值)。这是特性,不是 bug。
思考:为什么「不积极 munmap」反而更好?权衡点在「下次分配」——若每次 free 都 munmap 归还 OS,下次分配就得重新 mmap,系统调用开销大;jemalloc 用 madvise(MADV_DONTNEED) 标记物理页可回收、但保留虚拟地址,下次分配直接复用、免去 mmap。所以「used_memory 降了但 RSS 不降」不是泄漏,而是 jemalloc 在「及时归还」和「下次分配性能」之间选择了后者。
四、在 Redis 中使用 jemalloc 的实践
4.1 内存统计
INFO memory
# used_memory: Redis 用 jemalloc 分配的总内存
# used_memory_rss: OS 视角的 RSS
# mem_fragmentation_ratio = used_memory_rss / used_memory
# → 接近 1.0:jemalloc 碎片控制良好
# → > 1.5:可能有碎片问题需要排查
4.2 jemalloc profiling
# 编译时启用 profiling
make MALLOC=jemalloc USE_JEMALLOC=yes
# 运行时导出统计
redis-cli MEMORY MALLOC-STATS
# 输出 jemalloc 的详细 arena/bin 统计
输出示例:
___ Begin jemalloc statistics ___
Version: "5.2.1-0-gea6b3e973b477b8061e0076bb257dbd7f3faa756"
Allocated: 1053424, active: 1097728, mapped: 2097152
...
tcache_bytes: 1048576
4.3 Redis 7.0 引入的 MEMORY MALLOC-STATS
Redis 7.0 新增 MEMORY MALLOC-STATS 命令(需要编译时启用 jemalloc),可以直接在 Redis 中查看 jemalloc 内部状态:
MEMORY MALLOC-STATS
章末提问
Q1:Redis 为什么不用系统默认的 malloc,而要专门用 jemalloc?ptmalloc 在 Redis 场景有什么短板?
回答思路:结论——因为 Redis 是碎片敏感的内存数据库,ptmalloc 长期运行碎片率可达 1.5-2.0,会白白丢掉大量容量。因为:Redis 大量小对象、高频分配释放、单实例常驻内存,ptmalloc 的 arena 之间不共享空闲内存、碎片随运行累积,10GB 数据实际占用 RSS 可能到 15-20GB;jemalloc 靠细粒度 size class + tcache + 红黑树 extent,把碎片率压到 1.0-1.2。
Q2:jemalloc 的 tcache 是怎么降低锁竞争的?批量 fill/flush 的原理是什么?
回答思路:结论——tcache 让每个线程在自己的无锁缓存里完成大部分 malloc/free,只有 miss 或溢出才批量 fill/flush 到 arena,把锁竞争次数除以批量大小 N。因为:命中时 pop/push 无锁、耗时 <10ns;只有 tcache 空了才从 arena 一次性拿 N 个对象(fill)、满了才一次性还 N 个(flush),把 N 次锁操作摊薄成 1 次。
Q3:为什么 Redis 会出现「used_memory 降了但 used_memory_rss 不降」?这是 bug 吗?
回答思路:结论——这是 jemalloc 延迟回收(lazy purge)的特性,不是 bug。因为:对象 free 后先进 tcache、再回 arena,后台线程用 madvise(MADV_DONTNEED) 标记物理页可回收但保留虚拟地址,且 purge 有延迟和阈值;目的是下次分配时直接复用虚拟地址、免去 mmap 系统调用开销。所以内存是「暂不归还」而非「泄漏」,代价是牺牲及时归还 OS。