动态内存管理揭秘
# 18.动态内存管理揭秘
glibc
ptmalloc2的 chunk 数据结构、Fast Bins/Small Bins/Large Bins 三级分配策略、sbrk小内存 vsmmap大内存双通道、空闲 chunk 合并与碎片问题、Valgrind--leak-check=full内存泄漏检测、AddressSanitizer 影子内存 RedZone 原理、手写内存池:固定大小分配器的实现与性能对比
# 目录
# 1. 案例引入
# 1.1 内存只涨不降
某广告投放引擎是一个 7×24 的常驻进程,上线 3 个月后运维发现它的内存占用曲线是这样的:
RSS
^
│ ╱
│ ╱╲╱╱
│ ╱╲╱╱
│ ╱╲╱╱
│ ╱╲╱╱
│ ╱╲╱╱
│ ╱╲╱╱
│ ╱╲╱╱
└──────────────────────────────────────────► 时间
0 1天 2天 3天 4天...
# 阶梯状上涨!但代码里所有 malloc 都有对应的 free
2
3
4
5
6
7
8
9
10
11
12
13
14
用 pmap 看进程的内存映射:
$ pmap -x $PID | head -20
Address Kbytes RSS Dirty Mode Mapping
...
0000000001234000 20480 20480 20480 rw--- [ anon ] ← 堆持续增大
00007f3a8b400000 2048 512 0 r-x-- libc.so.6
...
2
3
4
5
6
现象:
- 每处理一批广告请求,RSS 上涨 ~50MB
- 但这批请求处理完后,RSS 不降
- 下次请求高峰,RSS 再涨 ~50MB
- 如此反复,呈阶梯状永久上涨
- 进程从来不崩(没有 segfault),但 K8s 的 OOM Killer 会在 RSS 接近 limit 时杀掉它
用 malloc_stats() 查看 glibc 内部分配器状态:
#include <malloc.h>
malloc_stats();
// 输出:
// Arena 0:
// system bytes = 524288000 (500 MB 从系统申请的)
// in use bytes = 8388608 (8 MB 实际在用)
// Total (incl. mmap):
// system bytes = 524288000
// in use bytes = 8388608
// max mmap regions = 8
// max mmap bytes = 134217728
2
3
4
5
6
7
8
9
10
11
system bytes = 500MB,in use bytes = 8MB——492MB 是空闲的,但 glibc 没有归还给操作系统!
# 1.2 顺藤摸到根因
追查:
- 假设 1:是不是内存泄漏?——
valgrind --leak-check=full跑一轮测试,报告 "All heap blocks were freed -- no leaks are possible"。没有泄漏。 - 假设 2:那为什么 RSS 不降?——
free只把内存还给 glibc 的分配器(进入 bin),不一定还给操作系统(munmap或sbrk缩堆)。glibc"囤"着空闲内存以备后续 malloc 复用。 - 假设 3:为什么囤着?——
sbrk扩展堆是单向的:只能往上推,不能局部释放——除非堆顶恰好全是空闲 chunk,否则无法缩堆。 - 假设 4:广告请求的模式:先 malloc 2000 个小对象(每个 50-200B),处理完 free 掉。这些对象散布在堆中,堆中间有一两个大对象还没释放 → 堆顶退不下来。
- 假设 5:下一次请求又 malloc 2000 个新对象 → glibc 从 bin 里拿空闲 chunk 复用。但如果 bin 里的 chunk 不够大(碎片化),glibc 会从堆顶分配新的 → 堆又涨了。
凶手:内存碎片(fragmentation)——大量小对象的 free 和 re-malloc 在堆中留下"洞",glibc 无法把分散的空闲 chunk 合并成连续的大块归还给 OS。堆的 RSS 只涨不降。
这个事故藏着至少 8 个原理点:
① malloc 返回的指针背后是什么数据结构?chunk 长什么样? → 第 3 章
② free 后内存去哪了?Fast Bins/Small Bins/Large Bins? → 第 4 章
③ 什么时候用 sbrk 扩展堆,什么时候用 mmap? → 第 5 章
④ 为什么 free 了但 RSS 不降?碎片与合并机制? → 第 6 章
⑤ tcache 是什么?glibc 2.26 的性能提升从哪来? → 第 4.5 节
⑥ Valgrind 怎么检测内存泄漏? → 第 7.3 节
⑦ AddressSanitizer 的 RedZone 怎么工作? → 第 7.4 节
⑧ 自己写一个内存池比 malloc 快多少? → 第 7.1/7.2 节
2
3
4
5
6
7
8
# 1.3 我们要回答什么
从malloc(100)到这一百字节被分配出去——这一秒之内,glibc 的 ptmalloc2 分配器做了大量决策。它比你想的复杂:chunk 的设计精妙到用 3 个 bit 省了 3 个字段,bin 的组织方式精确到用 64 个单链表 + 89 个循环链表 + tcache 做分层缓存。
本篇路线:
架构总图 (第 2 章)
↓
chunk 结构 (第 3 章) ─→ 解开"malloc 返回的指针到底是什么"
↓
三级 Bins (第 4 章) ─→ 解开"free 后的内存去了哪个桶"
↓
双通道扩展 (第 5 章) ─→ 解开"sbrk 和 mmap 的分工"
↓
碎片与合并 (第 6 章) ─→ 解开"为什么 RSS 只涨不降"
↓
内存池实战 (第 7 章) ─→ 自己写一个比 malloc 快 10 倍的分配器
↓
综合案例 (第 8 章) ─→ 彻底剖开 + 速查卡
2
3
4
5
6
7
8
9
10
11
12
13
📌 本篇定位:这是"内存"话题的收尾篇。第 01 篇讲了"内存在哪(地址空间)",第 17 篇讲了"文件 IO 怎么经过内核",本篇讲"堆内存怎么被分配和回收"。理解 ptmalloc2 的内部机制,是写出不会泄漏、不会碎片化、不会 OOM 的 C 程序的前提。
# 2. 架构概览
# 2.1 堆三层模型
┌─────────────────────────────────────────────────────────────────┐
│ 应用层 (Application) │
│ │
│ 用户代码: malloc(100) / free(ptr) / realloc / calloc │
│ │
├─────────────────────────────────────────────────────────────────┤
│ 分配器层 (Allocator) │
│ │
│ glibc ptmalloc2: │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ tcache (线程本地缓存, glibc 2.26+) │ │
│ │ Fast Bins (≤80B, LIFO 单链表, 不合并) │ │
│ │ Small Bins (≤512B, 双向循环链表) │ │
│ │ Large Bins (>512B, 排序链表, 最佳适配) │ │
│ │ Unsorted Bin (新释放的 chunk 中转站) │ │
│ │ Top Chunk (堆顶未分配区域) │ │
│ └───────────────────────────────────────────────────────┘ │
│ │
├─────────────────────────────────────────────────────────────────┤
│ 系统层 (OS) │
│ │
│ brk/sbrk: 小块 → 扩展堆顶 (program break) │
│ mmap/munmap: 大块 → 匿名映射到 mmap 区域 │
│ │
└─────────────────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
关键边界:
malloc(100)不一定触发系统调用——可能从 tcache/bin 直接拿free(ptr)不一定会munmap——通常只是把 chunk 放回 bin 链表- 分配器是用户态"缓存层"——它在应用和 OS 之间缓冲,减少系统调用
# 2.2 ptmalloc2 的核心设计哲学
ptmalloc2 (glibc 的 malloc 实现,基于 Doug Lea 的 dlmalloc) 的设计目标:
| 目标 | 实现手段 |
|---|---|
| 低延迟 | tcache 无锁、fastbins LIFO、大小分级 |
| 低碎片 | chunk 合并(前后相邻空闲 chunk 合并)、双通道(小/大分离) |
| 多线程友好 | 每线程一个 arena、tcache 线程本地 |
| 空间节省 | prev_size 复用、chunk header 只有 16 字节 |
| 安全可靠 | MALLOC_CHECK_ 环境变量、MALLOC_PERTURB_ 填充 freed 内存 |
# 3. ptmalloc2 chunk 结构
# 3.1 malloc_chunk 的字段布局
疑惑:malloc(100) 返回了一个 void* 指针,这块内存"前面"有什么?
论证——每个 malloc 返回的指针,前面都有一个 chunk header:
返回给用户的指针 ptr = malloc(100)
│
▼
┌─────────┬─────────┬─────────┬─────────────────────────────────┐
│ prev_size│ size │ fd │ 用户数据 (100 字节) │
│ (8B) │ (8B) │ (8B) │ │
├─────────┴─────────┼─────────┴─────────────────────────────────┤
│ ← chunk header → │ ← 用户可用区域 → │
│ (16 字节) │ (至少 100 字节, 对齐到 16) │
└───────────────────┴───────────────────────────────────────────┘
▲
│
实际分配的 chunk (从上一个 chunk 的边界开始)
2
3
4
5
6
7
8
9
10
11
12
13
malloc_chunk 结构体定义(glibc 源码 malloc/malloc.c,简化):
struct malloc_chunk {
INTERNAL_SIZE_T mchunk_prev_size; // 前一个 chunk 的大小
// (如果前一个是空闲的)
INTERNAL_SIZE_T mchunk_size; // 当前 chunk 的大小
// (低 3 bits 是标志位)
// 当 chunk 空闲时,以下字段用于链表管理:
struct malloc_chunk* fd; // 前向指针 (bin 双向链表)
struct malloc_chunk* bk; // 后向指针 (bin 双向链表)
// 当 chunk 是 Large Bin 空闲块时:
struct malloc_chunk* fd_nextsize; // 下一个不同大小的 chunk
struct malloc_chunk* bk_nextsize;
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
当 chunk 被分配(in use)时,fd/bk 的位置被用户数据覆盖——这是 glibc 高空间利用率的设计。
# 3.2 prev_size 的复用技巧
prev_size 字段有一个巧妙的复用规则:
如果前一个 chunk (P) 是空闲的:
→ 当前 chunk 的 prev_size = 前一个 chunk 的 size
→ 用于在 free 时快速计算前一个 chunk 的起始地址
如果前一个 chunk (P) 正在使用中:
→ prev_size 字段被前一个 chunk 的用户数据"借用"
→ glibc 不浪费这 8 字节!
2
3
4
5
6
7
这个"借用"是 glibc 把 chunk 开销控制在 16 字节的关键——prev_size 只有在前一个 chunk 空闲时才需要存储,in-use 时可以借给用户数据。
# 3.3 size 字段的 3 个标志位
mchunk_size 的低 3 位被用作标志(因为 chunk 大小总是 16 字节对齐,低 4 位永远为 0):
mchunk_size (64位):
┌──────────────────────────────────────────────────────┬───┬───┬───┐
│ │ A │ M │ P │
│ chunk 大小 (61 bits) │ │ │
└──────────────────────────────────────────────────────┴───┴───┴───┘
↑
bit 0: PREV_INUSE
bit 1: IS_MMAPPED
bit 2: NON_MAIN_ARENA
2
3
4
5
6
7
8
9
| 标志 | 位 | 含义 | 用途 |
|---|---|---|---|
PREV_INUSE (P) | bit 0 | 前一个 chunk 是否在使用中 | free 时决定是否向前合并 |
IS_MMAPPED (M) | bit 1 | 这个 chunk 是否由 mmap 分配 | free 时决定 munmap 还是放回 bin |
NON_MAIN_ARENA (A) | bit 2 | 是否属于非主 arena | free 时确定归还到哪个 arena |
// 宏定义
#define PREV_INUSE 0x1
#define IS_MMAPPED 0x2
#define NON_MAIN_ARENA 0x4
// 从 size 中提取标志和大小
#define chunk_size(p) ((p)->mchunk_size & ~(SIZE_BITS))
#define prev_inuse(p) ((p)->mchunk_size & PREV_INUSE)
2
3
4
5
6
7
8
# 3.4 从指针到 chunk 的地址换算
// 用户拿到的指针: ptr = malloc(100)
// 到 chunk header 的转换:
#define mem2chunk(mem) ((mchunkptr)((char*)(mem) - 2*SIZE_SZ))
// 从 chunk header 到用户数据区域:
#define chunk2mem(p) ((void*)((char*)(p) + 2*SIZE_SZ))
2
3
4
5
6
地址换算图解:
低地址 ──────────────────────────────────────► 高地址
┌──────────┬──────────┬──────────────────────┐
│ prev_size │ size │ 用户数据 (100B) │
│ (8B) │ (8B) │ │
└──────────┴──────────┴──────────────────────┘
▲ ▲
│ │
chunk ptr 返回的 user ptr
│ │
└────── 16B ──────────┘
2
3
4
5
6
7
8
9
10
11
为什么 size 字段包括 header 本身?
// malloc(100) → 实际 chunk 大小:
// header: 16 字节 (prev_size + size)
// 用户数据: 100 字节
// 对齐: 12 字节 (对齐到 16 的倍数)
// ─────────────────
// → 实际大小 = 128 字节 (对齐后)
// → mchunk_size = 128 | PREV_INUSE
// malloc(100) 实际占用内存 ≥ 128 字节
// 多出的 28 字节 = header + 对齐 padding
2
3
4
5
6
7
8
9
10
所以面试题"malloc(100) 实际分配了多少内存"的答案是"至少 128 字节(64位系统),取决于对齐和 glibc 版本"。
# 4. 三级 Bins 分配策略
# 4.1 FastBins
Fast Bins 是速度优先的缓存——牺牲了空间合并:
Fast Bins 数组 (共 10 个, 覆盖 16~80 字节):
bins[0] → [16B chunk] → [16B chunk] → NULL (LIFO 单链表)
bins[1] → [32B chunk] → [32B chunk] → NULL
bins[2] → [48B chunk] → NULL
...
bins[9] → [80B chunk] → [80B chunk] → [80B chunk] → NULL
2
3
4
5
6
7
Fast Bins 的特点:
| 特性 | 值 |
|---|---|
| 大小范围 | 16 ~ 80 字节(默认 global_max_fast = 80) |
| 数据结构 | 单链表(只用 fd 指针,不用 bk) |
| 操作方式 | LIFO(后进先出)——新 free 的 chunk 插入链表头 |
| 是否合并 | 不合并——相邻的 fastbin chunk 保持独立 |
| 标志位维护 | free 时不设 PREV_INUSE=0(为了速度) |
| 适用 | 高频小对象(如临时字符串、小结构体) |
// free 一个 ≤80B 的 chunk 时:
void _int_free(mstate av, mchunkptr p) {
size_t size = chunksize(p);
if (size <= global_max_fast) {
// 放入 fastbin 头部 (LIFO)
p->fd = fastbin(av, fastbin_index(size)); // 原链表头
fastbin(av, fastbin_index(size)) = p; // 新链表头
return; // ← 不合并,快速返回
}
// ... 否则走 slow path
}
2
3
4
5
6
7
8
9
10
11
# 4.2 SmallBins
Small Bins 覆盖 512 字节以内(不含 fastbin 范围)的 chunk:
Small Bins 数组 (共 62 个):
bin[2] → 32B chunks 双向循环链表 (精确匹配)
bin[3] → 48B chunks
bin[4] → 64B chunks
bin[5] → 80B chunks
...
bin[63] → 512B chunks
2
3
4
5
6
7
8
| 特性 | 值 |
|---|---|
| 大小范围 | 32 ~ 512 字节(步进 16B,实际 bin index 2~63) |
| 数据结构 | 双向循环链表(有 fd 和 bk) |
| 操作方式 | FIFO(先进先出)——从链表头取,插入链表尾 |
| 是否合并 | 是——相邻空闲 chunk 在 free 时会合并 |
# 4.3 LargeBins
Large Bins 覆盖 > 512 字节的 chunk:
Large Bins 数组 (共 63 个):
bin[64] → 512B ~ 576B chunks (排序链表)
bin[65] → 576B ~ 640B chunks
...
bin[97] → 3072B ~ 3584B chunks
...
bin[126] → 24576B ~ 32768B chunks
2
3
4
5
6
7
8
| 特性 | 值 |
|---|---|
| 大小范围 | > 512 字节 |
| 数据结构 | 双向循环链表 + 按大小排序 |
| 分配算法 | Best Fit(最佳适配)——找 ≥ 请求大小且最接近的 |
| 额外字段 | fd_nextsize / bk_nextsize 组成按大小排序的二级链表 |
# 4.4 UnsortedBin
Unsorted Bin 只有 1 个(bin[1]),是所有刚释放的非 fast chunk 的第一站:
// free 时(非 fastbin chunk):
void _int_free(mstate av, mchunkptr p) {
// ...
// 把 p 放入 unsorted bin 头部
p->fd = unsorted_chunks(av)->fd;
p->bk = unsorted_chunks(av);
// ← 放进去就完事了,不做分类
}
// malloc 时,会先遍历 unsorted bin:
// 1. 如果恰好有精确匹配 → 直接返回
// 2. 否则 → 把 unsorted chunk 分拣到对应的 small/large bin
// 3. 还没有 → 从对应的 bin 中找
2
3
4
5
6
7
8
9
10
11
12
13
设计用意:延迟分类——free 时不用判断该放哪个 bin(快速返回),malloc 时在需要的时候再分类(可能根本不需要分类——chunk 在 unsorted bin 中就被复用了)。
# 4.5 tcache
tcache (Thread Local Cache) 是 glibc 2.26 引入的重大性能优化:
每个线程的 tcache:
tcache[0] → [24B chunk] → [24B chunk] (最多 7 个)
tcache[1] → [32B chunk] (LIFO 单链表)
tcache[2] → [48B chunk] → [48B chunk]
...
tcache[63] → [1040B chunk]
2
3
4
5
6
7
tcache 的核心规则:
// malloc 时: 优先从 tcache 拿 (零锁,极快)
if (tcache 有对应大小的 chunk) {
return tcache 的链表头;
}
// free 时: 优先放入 tcache (如果没满)
if (tcache 该大小槽位未满 (默认 7 个)) {
插入 tcache 链表头;
return; // ← 不进 bins!
}
2
3
4
5
6
7
8
9
10
tcache 带来的性能提升:
| 操作 | 无 tcache (glibc 2.25) | 有 tcache (glibc 2.26+) |
|---|---|---|
| 连续 malloc/free 相同大小 | 每次查 bin + 可能上锁 | 直接读 tcache 链表 |
| 小对象分配延迟 | ~100 ns | ~20 ns |
| 多线程竞争 | 争 arena lock | tcache 无锁 |
tcache 的槽位数量可通过 TCACHE_FILL_COUNT 调整(默认 7),大小上限通过 TCACHE_MAX_BINS 调整(默认 64 个槽,覆盖到 1040B)。
# 5. 双通道扩展机制
# 5.1 sbrk 小块内存的 arena 扩展
当所有 bins 都没有合适的空闲 chunk 时,glibc 尝试从 top chunk(堆顶未分配区域)切一块:
堆的布局:
┌─────┬──────┬──────┬─────────────┬─────────────────┐
│ data│ chunk│ chunk│ 空闲 bins │ top chunk │
│/bss │ A │ B │ (已 free) │ (未分配区域) │
└─────┴──────┴──────┴─────────────┴─────────────────┘
↑
program break
如果 top chunk 不够大 → 调用 sbrk(N) 把 program break 往上推
→ top chunk 变大 → 从中切一块给 malloc
2
3
4
5
6
7
8
9
10
// 简化的 sbrk 扩展逻辑:
if (top_chunk_size < requested_size) {
size_t extend_size = max(requested_size + MINSIZE, MALLOC_ALIGNMENT);
void* new_brk = sbrk(extend_size);
// top chunk 增大 extend_size 字节
}
2
3
4
5
6
# 5.2 mmap 大块内存的直接映射
当请求的 chunk 大小 > MMAP_THRESHOLD(默认 128 KB)时,glibc 不走 arena→bins→top chunk 路径,而是直接调 mmap:
void* p = mmap(NULL, total_size, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
// chunk 的 IS_MMAPPED 标志位 = 1
2
3
free 时:IS_MMAPPED=1 → 直接 munmap → 虚拟空间 + 物理内存一起归还给 OS。RSS 立即下降。
# 5.3 MMAP_THRESHOLD 的动态调整
MMAP_THRESHOLD 不是固定的——glibc 会动态调整:
初始: MMAP_THRESHOLD = 128 KB
MMAP_MAX_THRESHOLD = 默认 512 KB (可配)
如果 free 大块 mmap 时发现碎片太多:
→ MMAP_THRESHOLD 放大 (鼓励更多 chunk 走 bins)
如果 arena 碎片严重:
→ MMAP_THRESHOLD 缩小 (鼓励更多 chunk 走 mmap)
2
3
4
5
6
7
// 手动调整
#include <malloc.h>
mallopt(M_MMAP_THRESHOLD, 64 * 1024); // 64KB 以上就走 mmap
mallopt(M_MMAP_MAX, 100); // 最多 100 个 mmap 区域
2
3
4
# 5.4 多线程 arena 竞争与缓解
问题:多个线程同时 malloc → 都找 main_arena → 抢锁 → 阻塞。
解决:ptmalloc2 为每个线程创建独立的 arena(用 mmap 分配),一个 arena 不够时新建一个:
线程1 → main_arena (用 brk)
线程2 → arena_1 (用 mmap)
线程3 → arena_2 (用 mmap)
线程4 → arena_1 (复用已有 arena, 但需要锁)
...
2
3
4
5
arena 数量上限:8 * CPU 核心数(64 位系统)。
"真假"内存占用问题:当线程退出时,其 arena 不会自动释放——它仍然占据虚拟地址空间。malloc_trim(0) 可以尝试释放空闲 arena。
# 6. 碎片与合并问题
# 6.1 内部碎片 vs 外部碎片
内部碎片 (Internal Fragmentation):
malloc(100) → 实际分配 128 字节 chunk
→ 28 字节浪费在 header + padding 上
→ 不可消除 (chunk 控制的固定开销)
外部碎片 (External Fragmentation):
┌────────┐ free ┌────────┐ free ┌────────┐ free ┌────────┐
│ chunk A│ 128B │ chunk B│ 128B │ chunk C│ 512B │ chunk D│ ...
└────────┘ └────────┘ └────────┘ └────────┘
free(A) then free(C) → 得到:
┌────────┐ free ┌────────┐ inuse┌────────┐ free ┌────────┐
│ 空 128│ │ chunk B│ 128B │ 空 512│ │ chunk D│ ...
└────────┘ └────────┘ └────────┘ └────────┘
malloc(600) → 没有连续 600B 的空间!尽管空闲总量 640B > 600B
→ 这是外部碎片的本质:空闲空间不连续
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 6.2 free 时的前后合并逻辑
glibc 在 free 时(非 fastbin + 非 mmap)会尝试合并相邻的空闲 chunk:
void _int_free(mstate av, mchunkptr p) {
// 1. 检查前一个 chunk 是否空闲
if (!prev_inuse(p)) {
// prev_size 告诉前一个 chunk 的大小
size_t prev_size = p->prev_size;
// 把 p 和前一个 chunk 合并
unlink_chunk(av, prev_chunk); // 从 bin 中卸下前一个
prev_chunk->size += chunksize(p); // 合并大小
p = prev_chunk; // p 向前移动到合并后的 chunk
}
// 2. 检查后一个 chunk 是否空闲
mchunkptr next = next_chunk(p);
if (!prev_inuse(next_chunk(next))) {
// 把 p 和后一个 chunk 合并
unlink_chunk(av, next);
p->size += chunksize(next);
}
// 3. 如果合并后的 chunk 紧邻 top chunk:
if (next_chunk(p) == av->top) {
// 直接吸收进 top chunk → 虚拟地址空间可能回收
av->top = p;
av->top->size += p->size;
}
// 4. 否则放入 unsorted bin
else {
// ... 放入 unsorted bin 头部
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
Fast Bins 的例外:fastbin 的 chunk 在 free 时不做合并(为了速度)。只有在 malloc 需要大 chunk 时,会"先遍历 unsorted bin,批量清理 fastbin → 合并 → 放入 unsorted bin"——这是一个延迟合并策略。
# 6.3 为什么 RSS 只涨不降
第 1 章案例的完整机理:
广告请求处理:
malloc 2000 个小对象 (50-200B) → RSS 涨 50MB
处理完毕 → free 2000 个对象
→ 每个 free 将 chunk 放入 tcache / fastbins (不合并)
→ RSS 不降
下一次请求:
malloc 2000 个小对象 → tcache/fastbins 中的 chunk 足够 → 直接复用
→ RSS 不涨不降 ← 稳态
但某次请求有大对象 (如 500KB):
malloc 500KB → 从 top chunk 切 或者 mmap
→ RSS 涨
下次请求又有 500KB:
free 的大对象如果是 mmap → munmap → RSS 降 ✅
如果走的是 sbrk → 进入 bins → RSS 不降 ❌
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
降 RSS 的手段:
#include <malloc.h>
malloc_trim(0); // 尝试归还堆顶空闲空间给 OS
// 参数: pad (保留多少字节以防立即又要 malloc)
// 返回: 1 = 有归还, 0 = 没有
2
3
4
malloc_trim 只能归还堆顶的连续空闲区域——如果堆中间有一个未释放的 chunk 挡住了,堆顶就退不下来。
# 6.4 malloc_trim 与 malloc_stats
#include <malloc.h>
// 查看分配器内部状态
malloc_stats();
// 输出:
// Arena 0:
// system bytes = 524288000 (从 OS 申请的)
// in use bytes = 8388608 (实际用着的)
// max mmap regions = 8
// max mmap bytes = 134217728
// 归还堆顶空闲空间
malloc_trim(0); // 尝试归还所有堆顶空闲
// 查看分配器信息 (可读性更强的接口)
struct mallinfo2 info = mallinfo2();
printf("arena: %zu\n", info.arena); // 非 mmap 分配的总空间
printf("ordblks: %zu\n", info.ordblks); // 空闲 chunk 数量
printf("fordblks: %zu\n", info.fordblks); // 空闲空间总字节
printf("uordblks: %zu\n", info.uordblks); // 使用中空间总字节
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 7. 内存池设计与实现
# 7.1 定长分配器
当你知道"几乎所有分配都是 64 字节"时,自定义内存池比通用 malloc 快 5~50 倍:
固定大小内存池 (64 字节):
┌─────────────────────────────────────────────────────────┐
│ pool header │
│ ┌────────┬───────┬──────────────────────────────────┐ │
│ │ free │ chunk │ 预分配的 1024 个 64字节块 │ │
│ │ list │ count │ [slot0][slot1][slot2]...[slot1023]│ │
│ └────────┴───────┴──────────────────────────────────┘ │
│ │ │
└──────┼──────────────────────────────────────────────────┘
│
▼
┌─────────┐ ┌─────────┐ ┌─────────┐
│ slot N │───►│ slot M │───►│ slot K │──► NULL
│ (空闲) │ │ (空闲) │ │ (空闲) │
└─────────┘ └─────────┘ └─────────┘
空闲链表: 每个空闲 slot 的前 8 字节存放下一个空闲 slot 的地址
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
设计要点:
- 一次
malloc一大块内存,切成固定大小的 slot - 空闲 slot 用侵入式链表(slot 本身存
next)——零额外开销 - 分配 = 从空闲链表取头节点(O(1))
- 释放 = 把节点放回链表头(O(1))
- 无锁(如果每线程一个池)或无锁 CAS(多线程)
# 7.2 完整实现与性能测试
// fixed_pool.h
#include <stddef.h>
typedef struct pool_slot {
struct pool_slot* next; // 空闲链表
} pool_slot_t;
typedef struct {
pool_slot_t* free_list; // 空闲链表头
char* memory; // 预分配的内存块基底
size_t slot_size; // 每个 slot 的大小
size_t slot_count; // 总 slot 数
size_t used_count; // 当前已分配的 slot 数
} fixed_pool_t;
// 初始化: 预分配 slots 个大小为 slot_size 的内存块
void fixed_pool_init(fixed_pool_t* pool, size_t slot_size, size_t slots) {
// 确保 slot 至少足够装下一个指针 (8 字节)
if (slot_size < sizeof(pool_slot_t))
slot_size = sizeof(pool_slot_t);
pool->slot_size = slot_size;
pool->slot_count = slots;
pool->used_count = 0;
pool->memory = malloc(slot_size * slots);
pool->free_list = NULL;
// 把所有 slot 串成空闲链表
for (size_t i = 0; i < slots; i++) {
pool_slot_t* slot = (pool_slot_t*)(pool->memory + i * slot_size);
slot->next = pool->free_list;
pool->free_list = slot;
}
}
// 分配: O(1)
void* fixed_pool_alloc(fixed_pool_t* pool) {
if (!pool->free_list) return NULL; // 池已满
pool_slot_t* slot = pool->free_list;
pool->free_list = slot->next;
pool->used_count++;
return slot;
}
// 释放: O(1)
void fixed_pool_free(fixed_pool_t* pool, void* ptr) {
pool_slot_t* slot = (pool_slot_t*)ptr;
slot->next = pool->free_list;
pool->free_list = slot;
pool->used_count--;
}
// 销毁
void fixed_pool_destroy(fixed_pool_t* pool) {
free(pool->memory);
pool->memory = NULL;
pool->free_list = NULL;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
性能测试(分配+释放各 100 万次 64 字节):
// bench.c
fixed_pool_t pool;
fixed_pool_init(&pool, 64, 1000000);
// 内存池: 分配+释放 100 万次
clock_t start = clock();
for (int i = 0; i < 1000000; i++) {
void* p = fixed_pool_alloc(&pool);
fixed_pool_free(&pool, p);
}
clock_t end = clock();
printf("Fixed Pool: %ld ms\n", (end - start) * 1000 / CLOCKS_PER_SEC);
// 原生 malloc/free 对比
start = clock();
for (int i = 0; i < 1000000; i++) {
void* p = malloc(64);
free(p);
}
end = clock();
printf("malloc/free: %ld ms\n", (end - start) * 1000 / CLOCKS_PER_SEC);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
实测结果(x86-64, glibc 2.35, 单线程):
| 分配器 | 耗时 (100万次) | 相对速度 |
|---|---|---|
| Fixed Pool | ~8 ms | 15× 更快 |
| malloc/free (tcache命中) | ~120 ms | 1× (基准) |
| malloc/free (无tcache) | ~350 ms | 3× 更慢 |
为什么快这么多:
- 内存池:两次指针读写(取链表头 + 更新链表头)≈ 4ns
- malloc:tcache 查找 → bin 查找 → 可能 sbrk/mmap → 至少 ~30ns
# 7.3 Valgrind 内存泄漏检测原理
Valgrind 不是"运行时插桩",而是"CPU 模拟器"——它把程序的每条 x86 指令翻译成自己的中间表示(VEX IR),在翻译过程中插入影子内存检查:
你的程序 (原始 x86) → Valgrind (翻译) → VEX IR → 插入检测 → 重新生成 x86 → 执行
Valgrind 维护的元数据:
1. Valid-Address (V) bits: 每个字节是否可访问
2. Valid-Value (V) bits: 每个字节是否被初始化
3. 内存分配记录: 记录每个 malloc/calloc/realloc 的调用栈
2
3
4
5
6
Memcheck 工具的原理(valgrind --leak-check=full):
malloc(100) → 记录: [address=0x..., size=100, stack=main:42]
标记: 100 字节为"可访问但未初始化"
free(ptr) → 标记: 100 字节为"不可访问"
从记录表中移除
程序退出时:
遍历记录表中剩余的条目 → 每个都是"泄漏" → 报告调用栈
2
3
4
5
6
7
8
Valgrind 的慢:大约慢 10~50 倍——因为它实质上在模拟 CPU。这是为什么--leak-check=full只用于测试,不能跑在生产环境上。
# 7.4 ASan RedZone 影子内存原理
AddressSanitizer (ASan) 采用编译期插桩(比 Valgrind 快,约 2 倍 slowdown):
编译时 ASan 做的插桩:
每个 malloc(N) → 在实际内存周围插入 RedZone (红色区域)
每次内存访问 → 插入影子内存检查
内存布局 (ASan):
┌──────────┬────────────────────┬──────────┐
│ RedZone │ 实际 malloc 区 │ RedZone │
│ (32B+) │ (N 字节) │ (32B+) │
│ 不可访问 │ │ 不可访问 │
└──────────┴────────────────────┴──────────┘
影子内存 (shadow memory):
实际内存的每个 8 字节 → 影子内存 1 字节
影子字节 = 0x00 → 8 字节全部可访问
影子字节 = 0xFa → 8 字节全部是 RedZone (不可访问)
影子字节 = 0x05 → 前 5 字节可访问,后 3 字节不可访问
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
ASan 检测的快速访问:
// 用户代码: *ptr = 42;
// ASan 插桩后:
char* shadow_addr = (ptr >> 3) + SHADOW_OFFSET;
if (*shadow_addr != 0) {
// 影子字节非0 → 可能越界 → 进入慢路径检查
if (!is_accessible(ptr, sizeof(*ptr)))
ASAN_REPORT_ERROR("heap-buffer-overflow");
}
*ptr = 42; // 原始写入
2
3
4
5
6
7
8
9
ASan 的内存开销:约 2~5 倍——影子内存是实际内存的 1/8 + RedZone 增加。这也是为什么 ASan 不能长期运行生产进程。
# 8. 综合案例串讲
# 8.1 案例真相揭晓
回到第 1 章广告引擎 RSS 只涨不降的八个疑问,逐条作答:
| 疑问 | 答案 |
|---|---|
| ① malloc 返回的指针背后是什么? | 第 3 章:chunk header(16B)+ 用户数据,prev_size 复用策略 |
| ② free 后内存去哪了? | 第 4 章:tcache→fastbins→unsorted bin→small/large bins 分层缓存 |
| ③ 什么时候 sbrk vs mmap? | 第 5 章:≤128KB 走 sbrk(arena),>128KB 走 mmap |
| ④ 为什么 free 了 RSS 不降? | 第 6.3:碎片+tcache/fastbins 不合并+堆中间有未释放 chunk |
| ⑤ tcache 是什么? | 第 4.5:glibc 2.26 的线程本地 LIFO 缓存,7 个/槽,无锁 |
| ⑥ Valgrind 怎么检测泄漏? | 第 7.3:CPU 模拟 + 分配记录表 + 退出时报告未释放 |
| ⑦ ASan RedZone 怎么工作? | 第 7.4:编译期插桩 + 影子内存 + RedZone 越界触发 |
| ⑧ 内存池比 malloc 快多少? | 第 7.2:固定大小池 15× 更快,零碎片、O(1) 分配释放 |
第 1 章案例的完整根因链条:
广告请求: malloc 2000 小对象 (50-200B)
→ 从 tcache/fastbins/smallbins 分配 → RSS 可能涨
处理完毕: free 2000 小对象
→ 放入 tcache/fastbins (不合并)
→ RSS 不降
某次大请求: malloc 500KB
→ 如果 >MMAP_THRESHOLD → mmap → RSS 涨 500KB
→ free → munmap → RSS 降 ✅
→ 如果 ≤ MMAP_THRESHOLD → sbrk → RSS 涨
→ 堆中某处有一个 500KB 的空闲 chunk
→ 下一次 500KB 请求可能复用
但如果请求模式反复: 小对象频繁 + 偶尔大对象跨 128KB 边界
→ sbrk 堆中有擦不掉的"洞"
→ 堆顶又退不下来
→ RSS 阶梯上涨
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
修复方案:
方案 A:调低 MMAP_THRESHOLD(治标)
mallopt(M_MMAP_THRESHOLD, 4096); // 4KB 以上就走 mmap
// free 大对象 → 直接 munmap → RSS 立即下降
2
代价:大量 mmap/munmap 系统调用,性能下降 10~20%。
方案 B:定期 malloc_trim(缓解)
// 每处理完一批请求
if (requests_processed % 1000 == 0) {
malloc_trim(0); // 归还堆顶空闲空间
}
2
3
4
方案 C:内存池 + freed 对象复用(治本)
// 对高频对象用内存池,绕过 malloc 的碎片问题
static fixed_pool_t small_obj_pool;
fixed_pool_init(&small_obj_pool, 128, 10000);
// 这个池的内存只申请一次,零碎片
2
3
4
# 8.2 一次 malloc 的完整旅程
用户调用 malloc(100)
│
├─ 1. tcache (如果启用)
│ └─ 100B → 对齐到 112B → tcache[对应槽]
│ 有 → O(1) 返回 ← 快速路径,无锁!
│ 没有 ↓
│
├─ 2. Fast Bins (如果 100B ≤ 80B)
│ └─ 大小不在 fastbin 范围 → 跳过
│
├─ 3. Small Bins (100 ≤ 512)
│ └─ 查 smallbin[100/16 + 偏移] → 双向链表
│ 有 → unlink → 返回
│ 没有 ↓
│
├─ 4. 合并 Fast Bins + 遍历 Unsorted Bin
│ └─ 把所有 fastbin chunk 合并 → 加入 unsorted bin
│ 遍历 unsorted bin:
│ 精确匹配 → 返回
│ 否则 → 分拣到 small/large bin
│
├─ 5. Large Bins (100 ≤ 512 不会走到这)
│ └─ 跳过
│
├─ 6. Top Chunk (所有 bin 都没合适的)
│ └─ 从堆顶切一块 (top chunk)
│ top chunk 够大 → 切出来 → 返回
│ top chunk 不够
│
├─ 7. 扩展堆
│ └─ 请求大小 > MMAP_THRESHOLD (128KB)?
│ 是 → mmap(N) → 返回
│ 否 → sbrk(扩展量) → top chunk 变大 → 切 → 返回
│
└─ 8. 都失败了
└─ errno = ENOMEM → 返回 NULL
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# 8.3 面试高频问题清单
1. malloc(100) 实际分配了多少内存?
至少 128 字节(64 位系统)。16 字节 chunk header(prev_size+size)+ 100 字节用户数据 + 12 字节对齐 padding = 128 字节。chunk 的总大小记录在
size字段中(包含 header)。
2. Fast Bins 和 Small Bins 的核心区别?
Fast Bins:LIFO 单链表,≤80B,free 后不合并不设 PREV_INUSE=0,速度快但导致碎片。Small Bins:FIFO 双向循环链表,32~512B,free 后合并相邻空 chunk。Fast Bins 以速度换空间,Small Bins 以合并减少碎片。
3. tcache 是什么?它怎么加速 malloc/free?
glibc 2.26+ 引入的线程本地缓存。每个线程有 64 个 LIFO 槽位(覆盖 24~1040B),每槽最多 7 个 chunk。malloc 时优先从 tcache 拿(无锁),free 时优先放回 tcache。对比无 tcache 版本快 5~10 倍。
4. sbrk 和 mmap 在内存分配中分别用于什么场景?
sbrk用于小块(≤128KB 默认),扩展堆顶(program break),无法局部释放。mmap用于大块(>128KB),在 mmap 区域独立映射,free 时直接munmap归还 OS。双通道兼顾了小对象的低碎片和大对象的灵活释放。
5. free 后内存为什么不一定归还给 OS?
free只把 chunk 放回 glibc 的 bins(tcache/fastbins/bin 链表),不调用munmap(除非是 mmap 的大块)。bins 中的空闲 chunk 被保留以加速后续 malloc。只有通过malloc_trim(0)主动归还,或堆顶恰好全是空闲 chunk 时才会缩堆。
6. 什么是内部碎片和外部碎片?
内部碎片:chunk 的实际分配大小 > 请求大小(header+padding),固定开销无法消除。外部碎片:空闲空间总量足够但分散在不同位置不连续,导致无法满足大块请求。fastbins 不合并加剧外部碎片。
7. Valgrind 和 ASan 的检测原理有什么不同?
Valgrind:CPU 模拟(VEX IR 翻译),在翻译时插入内存检查,慢 10~50 倍,但不需要重新编译。ASan:编译期插桩 + 影子内存 + RedZone,慢约 2 倍,需要特殊编译,生产友好度更高。
8. 固定大小内存池为什么比 malloc 快?
内存池:O(1) 的链表操作(取头节点 + 更新 next),~4ns。malloc:至少需要查 tcache/bins + 可能的上锁 + 可能的系统调用,~30ns 起。池化还消除了碎片、避免了 page fault、缓存局部性更好。
9. Unsorted Bin 的作用?
所有非 fastbin chunk 在释放时先进入 Unsorted Bin(延迟分类)。free 时不需要判断该放入哪个具体 bin(快速返回),malloc 时再遍历 unsorted bin 做精确分类或复用。减少了 free 的延迟。
10. chunk->size 字段的最低 3 位是什么?
bit 0: PREV_INUSE(前一个 chunk 是否在使用中,决定 free 时是否向前合并)。bit 1: IS_MMAPPED(是否由 mmap 分配,决定 free 时是 munmap 还是放回 bin)。bit 2: NON_MAIN_ARENA(是否属于非主 arena)。详见第 3.3 节。
# 8.4 内存管理速查卡
诊断命令:
# 查看内存映射
pmap -x $PID # 进程完整地址空间
cat /proc/$PID/maps | grep heap # 只看堆
cat /proc/$PID/smaps # 每段的详细统计
# 内存指标
ps -o pid,vsz,rss,comm -p $PID # VSZ + RSS
smem -p # PSS (按共享比例拆分)
# glibc 内部分配器状态
gdb -p $PID --batch -ex "call malloc_stats()"
gdb -p $PID --batch -ex "call malloc_info(0, stdout)"
gdb -p $PID --batch -ex "call malloc_trim(0)"
# 泄漏检测 (测试环境)
valgrind --leak-check=full --show-leak-kinds=all ./app
ASAN_OPTIONS=detect_leaks=1 ./app # ASan 的泄漏检测
# 编译时启用 ASan
gcc -fsanitize=address -g -O2 test.c -o test
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
mallopt 常用配置:
#include <malloc.h>
mallopt(M_MMAP_THRESHOLD, 64 * 1024); // 64KB 以上用 mmap
mallopt(M_MMAP_MAX, 0); // 禁止 mmap
mallopt(M_TRIM_THRESHOLD, 128 * 1024); // 128KB 空闲触发缩堆
mallopt(M_TOP_PAD, 0); // 缩堆不留 padding
mallopt(M_ARENA_MAX, 2); // 最多 2 个 arena
2
3
4
5
6
7
内存池核心接口模板:
typedef struct {
void* (*alloc)(size_t size);
void (*free)(void* ptr);
void* (*realloc)(void* ptr, size_t size);
void (*destroy)(void);
size_t used;
size_t capacity;
} allocator_t;
// 即插即用:把 malloc/free 替换为池子的实现
#define pool_malloc(size) fixed_pool_alloc(&my_pool)
#define pool_free(ptr) fixed_pool_free(&my_pool, ptr)
2
3
4
5
6
7
8
9
10
11
12
下一篇:19.多线程与锁机制 —— 我们已经知道"堆内存怎么被分配和回收",下一步进入并发:pthread_create 后内核做了什么?mutex 的 futex 系统调用为什么是"快锁"?自旋锁 vs 互斥锁的性能拐点在哪里?