Skip to content
Go back

Redis jemalloc 内存分配器:红黑树、线程缓存与 size class

一句话结论(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 的内存使用模式非常特殊:

  1. 大量小对象分配释放:Key、Value、SDS、ziplist、quicklistNode —— 每个对象通常几字节到几十 KB
  2. 高频内存操作:单线程事件循环中每秒数十万次内存分配/释放
  3. 碎片敏感:Redis 是内存数据库,碎片率上升 10% 可能意味着丢失 GB 级可用容量
  4. 多实例部署:一台机器可能跑 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; // 空闲链表的后向指针
};

Redis 场景下 ptmalloc 的致命伤:长期运行后内存碎片率可达 1.5-2.0,即 Redis 看到 10GB 数据实际占用 RSS 15-20GB。多实例时碎片更严重。

2.2 tcmalloc(Google)

tcmalloc 的核心是 线程缓存(Thread Cache)+ 中央空闲链表(Central Free List)+ 页堆(Page Heap)

tcmalloc 的多核性能优于 ptmalloc,但 size class 划分较粗(间隔较大),导致内碎片较多(分配 33 字节实际占 40 字节)。

2.3 三者对比

维度ptmalloctcmallocjemalloc
碎片控制差(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(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 场景下有两个问题:

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。


Share this post on:

Previous Post
Redis quicklist 详解:双向链表 + listpack 的混合结构
Next Post
Redis Stream——持久化的消息队列新选择