Linux-26 用户态分配器:从 brk 到 Go 的 mspan
25 篇讲了内核如何管理物理页。这一篇讲用户态如何把「页」切成「对象」 —— 以及为什么每个大型运行时(glibc、Go、JVM、Redis)都要自己写一套分配器。
先看五个问题:
malloc为什么不直接用mmap/brk系统调用?free之后内存为什么不还给操作系统(RSS 不下降)?- glibc 的 arena 是什么?为什么多线程程序的内存占用会「莫名膨胀」?
- Go 为什么完全不用 libc 的
malloc,自己实现一套? - 用户态的内存碎片是怎么产生的,怎么衡量?
1. 用户态分配器的职责
1.1 内核给的是页,程序要的是对象
应用代码
malloc(37) malloc(1024) new Foo() make([]byte, 100)
|
v
┌──────────────────────────────────────────────────────┐
│ 用户态分配器(glibc malloc / jemalloc / Go runtime) │
│ 职责:把「大块内存」切成「任意大小的对象」并管理复用 │
└──────────────────────────────────────────────────────┘
| brk() / mmap() ← 只在需要更多内存时才调(次数要尽可能少)
v
┌──────────────────────────────────────────────────────┐
│ 内核:VMA 管理 + 缺页分配(24 篇) │
│ buddy 分配器(25 篇) │
└──────────────────────────────────────────────────────┘
1.2 开篇第一问:为什么不直接用系统调用
三个原因,每一个都是致命的:
① 系统调用太贵
一次 mmap/munmap 约 1~2 微秒(陷入内核 + VMA 操作 + TLB 刷新)
而一次 malloc 应该在【几十纳秒】级别
-> 差 50 倍以上。一个每秒分配百万次的程序会把时间全耗在内核里
② 粒度不匹配
内核的最小单位是 4KB 页,而 malloc(37) 只要 37 字节
-> 每个小对象占一页 = 浪费 99%
③ 缺页开销
即使 mmap 成功,第一次访问还要触发缺页异常(24 篇第 5 章)
-> 又是一次陷入内核 + 清零页面
所以分配器的核心策略是「批发零售」:向内核批量要内存(一次几百 KB 到几 MB),然后在用户态零售给应用,释放的对象留在自己的池子里复用而不还给内核。
# 验证:一百万次 malloc 只产生了几十次系统调用
cat > /tmp/malloc_syscall.c <<'EOF'
#include <stdlib.h>
int main(){ for(int i=0;i<1000000;i++){ void*p=malloc(64); free(p); } }
EOF
gcc -O0 -o /tmp/ms /tmp/malloc_syscall.c
strace -c -e trace=brk,mmap,munmap /tmp/ms 2>&1 | tail -6
# 系统调用次数是【个位数】—— 因为 free 的对象被立刻复用了
1.3 三个互相冲突的目标
① 快 —— 分配/释放要在纳秒级,且多线程下不能有锁竞争
② 省 —— 元数据开销小、内部碎片少
③ 抗碎片 —— 长期运行不会因为碎片而无限增长
这三者互相拉扯:
为了「快」要做 per-thread 缓存 -> 但每个线程都缓存一份就「不省」
为了「省」要精细的 size class -> 但查找和管理变复杂就「不快」
为了「抗碎片」要主动合并空闲块 -> 合并本身有开销,也可能破坏缓存局部性
不同分配器的差异,本质就是【在这三者之间选了不同的平衡点】
2. brk 与 mmap:两条要内存的路
2.1 两条路的分工
// ① brk / sbrk:移动「堆的末端指针」
void *sbrk(intptr_t increment);
int brk(void *addr);
// 堆是一段【连续】的虚拟地址区域,brk 只能线性地扩展/收缩它的末端
// ② mmap:映射一段新的匿名区域
void *mmap(NULL, len, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0);
// 可以在地址空间的任意位置映射,彼此独立
| brk | mmap | |
|---|---|---|
| 形态 | 一段连续的堆,只能移动末端 | 独立的映射区域,位置任意 |
| 开销 | 低(只改一个指针 + 扩展一个 VMA) | 较高(创建新 VMA,munmap 还要刷 TLB) |
| 归还内核 | ❌ 只能从末端收缩 | ✅ 可以单独 munmap |
| 适合 | 大量小对象(堆的自然增长) | 大块内存、需要单独释放的内存 |
| 观察 | /proc/pid/maps 里的 [heap] |
匿名映射区域 |
# glibc 的分界线:默认 128KB
# 小于它 -> 从堆里切(brk 扩展)
# 大于它 -> 直接 mmap,free 时直接 munmap
strace -e trace=brk,mmap ./myapp 2>&1 | head -20
cat > /tmp/threshold.c <<'EOF'
#include <stdlib.h>
int main(){
void *a = malloc(100*1024); // 100KB -> 从堆里切
void *b = malloc(200*1024); // 200KB -> 直接 mmap
free(a); free(b);
}
EOF
gcc -o /tmp/th /tmp/threshold.c && strace -e trace=brk,mmap,munmap /tmp/th 2>&1 | grep -E 'brk|mmap|munmap'
# brk(NULL) / brk(0x...) <- 100KB 走堆
# mmap(NULL, 208896, PROT_READ|PROT_WRITE...) <- 200KB 走 mmap
# munmap(0x..., 208896) <- ✅ 立刻归还内核
2.2 开篇第二问:为什么 free 之后 RSS 不降
这是最常被误判为「内存泄漏」的现象。 三个原因:
原因① brk 的线性性 —— 只能从末端收缩
堆的布局(地址从低到高):
+--------+--------+--------+--------+--------+
| 已释放 | 已释放 | 【存活】| 已释放 | 已释放 | <- brk 末端
+--------+--------+--------+--------+--------+
^^^^^^ 只要这个对象活着,
它【右边】的空间可以还,左边的【还不了】
-> 一个长寿命的小对象就能「钉住」整个堆
-> 这是 malloc 无法解决的结构性问题(不是 bug)
原因② 分配器故意保留(这是特性)
刚 free 的内存马上还给内核,下次 malloc 又要重新申请 + 缺页
-> 保留在池子里复用才是正确的策略
-> glibc 有 M_TRIM_THRESHOLD(默认 128KB):堆末端的空闲区
超过这个值才考虑收缩
原因③ 内存碎片
空闲块分散在各处,无法合并成足够大的连续区域归还
# 观察这个现象
cat > /tmp/nofree.c <<'EOF'
#include <stdio.h>
#include <stdlib.h>
#include <malloc.h>
int main(){
void *arr[10000];
for (int i=0;i<10000;i++) arr[i] = malloc(10240); // 分配 100MB
printf("分配完成,看 RSS: "); fflush(stdout); getchar();
for (int i=1;i<10000;i++) free(arr[i]); // ✅ 只留 arr[0] 不释放
printf("释放了 99.99%%,RSS 还是很高: "); fflush(stdout); getchar();
malloc_trim(0); // ✅ 主动归还
printf("malloc_trim 之后: "); fflush(stdout); getchar();
free(arr[0]);
}
EOF
gcc -o /tmp/nofree /tmp/nofree.c && /tmp/nofree &
watch -n1 "grep -E 'VmRSS' /proc/$(pgrep -n nofree)/status; grep -A1 '\[heap\]' /proc/$(pgrep -n nofree)/smaps | head -2"
# 主动归还的手段
# ① 程序里调 malloc_trim(0)
# ② 让大块走 mmap(降低阈值,free 时直接 munmap)
# mallopt(M_MMAP_THRESHOLD, 65536);
# ③ 换成 jemalloc/tcmalloc(它们有后台线程主动归还,见第 4 章)
关键结论:判断内存泄漏不能只看 RSS,要看「存活对象总量是否持续增长」。
3. glibc malloc(ptmalloc2)
3.1 chunk:一切的基本单位
glibc 把堆切成一个个 chunk,每个 chunk 有 8/16 字节的头部:
+--------------------------------+
| prev_size(前一个 chunk 的大小) | <- 只在前一个 chunk 空闲时有意义
+--------------------------------+
| size | A | M | P | <- 大小 + 3 个标志位
+--------------------------------+ <- malloc 返回的指针指向这里
| 用户数据… |
| |
+--------------------------------+
P(PREV_INUSE):前一个 chunk 是否在使用(用于合并判断)
M(IS_MMAPPED):这个 chunk 是不是单独 mmap 的
A(NON_MAIN_ARENA):属于非主 arena(见 3.3)
✅ 精妙之处:空闲 chunk 的【用户数据区】被复用来存链表指针(fd/bk)
-> 空闲块的管理不需要额外内存
⚠️ 代价:chunk 头部与用户数据相邻 -> 缓冲区溢出可以篡改头部
-> 这是「堆溢出利用」的基础(unlink 攻击、tcache poisoning 等)
# 实际的开销:malloc(1) 会占多少?
cat > /tmp/chunk.c <<'EOF'
#include <stdio.h>
#include <stdlib.h>
#include <malloc.h>
int main(){
void *p = malloc(1);
printf("请求 1 字节,实际可用 %zu 字节\n", malloc_usable_size(p));
void *q = malloc(25);
printf("请求 25 字节,实际可用 %zu 字节\n", malloc_usable_size(q));
}
EOF
gcc -o /tmp/chunk /tmp/chunk.c && /tmp/chunk
# 请求 1 字节,实际可用 24 字节 <- 最小 chunk 是 32 字节(含头部)
# 请求 25 字节,实际可用 40 字节 <- 按 16 字节对齐
# ✅ 这就是【内部碎片】:小对象的开销比例极高
3.2 分箱:不同大小走不同的路
┌─ tcache(线程本地缓存,glibc 2.26+)──────────────────┐
│ 64 个桶(16~1032 字节),每桶最多 7 个 chunk │
│ ✅ 完全【无锁】—— 命中时就是几条指令 │
└────────────────────────────────────────────────────┘
↓ miss
┌─ fastbin ──────────────────────────────────────────┐
│ 小块(≤128 字节)的单向链表,LIFO │
│ ✅ 不合并相邻块(快,但会积累碎片) │
└────────────────────────────────────────────────────┘
↓
┌─ unsorted bin ─────────────────────────────────────┐
│ 刚释放的块先扔这里(一个缓冲带),分配时顺手整理 │
└────────────────────────────────────────────────────┘
↓
┌─ smallbin(< 512B,每 bin 固定大小)────────────────┐
│ largebin(≥ 512B,每 bin 一个大小范围,内部按大小排序)│
│ ✅ 会做相邻空闲块的【合并】 │
└────────────────────────────────────────────────────┘
↓ 都没有
┌─ top chunk(堆顶的大块)───────────────────────────┐
│ 从这里切一块出来;不够就 brk 扩展堆 │
└────────────────────────────────────────────────────┘
↓ 请求 > 128KB
┌─ 直接 mmap ────────────────────────────────────────┐
└────────────────────────────────────────────────────┘
tcache 是 glibc 2.26(2017)的关键改进:
在它之前,即使命中 fastbin 也要【加 arena 的锁】
-> 多线程下即使各自分配小对象也会竞争同一把锁
tcache 是【纯线程本地】的,命中时完全无锁
-> 小对象分配性能提升数倍
代价:每个线程多占一点内存(64 个桶 × 7 个 chunk)
以及新的安全问题(tcache poisoning 成为主流的堆利用手法)
3.3 开篇第三问:arena 与多线程膨胀
问题:多个线程同时 malloc,都要操作同一个堆 -> 锁竞争严重
glibc 的解法:给线程分配【多个 arena】(各自独立的堆)
- 主 arena:用 brk 扩展(就是 [heap])
- 非主 arena:用 mmap 申请 64MB 的区域(HEAP_MAX_SIZE)
- 每个线程首次 malloc 时绑定到一个 arena,之后固定使用它
arena 数量上限:
32 位:2 × 核数
64 位:8 × 核数 <- 一台 32 核机器最多 256 个 arena!
这就是「多线程程序内存莫名膨胀」的原因:
场景:一个 16 核机器上的服务,创建了 64 个线程
-> 最多 128 个 arena
-> 每个 arena 都会保留自己的空闲内存(互相不共享!)
-> 线程 A 释放的内存,线程 B 用不上
-> 总内存占用 = 各 arena 的峰值之和,而不是全局峰值
典型表现:RSS 是「所有线程各自峰值的总和」,远大于实际同时存活的对象总量
# 观察 arena 的数量与内存
cat > /tmp/arena.c <<'EOF'
#include <pthread.h>
#include <stdlib.h>
#include <unistd.h>
#include <stdio.h>
void *worker(void *a){
void *p[200];
for(int i=0;i<200;i++) p[i]=malloc(64*1024); // 每线程分配 12.8MB
for(int i=0;i<200;i++) free(p[i]); // 全部释放
sleep(60); // ✅ 保持线程存活
return NULL;
}
int main(){
pthread_t t[32];
for(int i=0;i<32;i++) pthread_create(&t[i],NULL,worker,NULL);
printf("PID=%d,32 个线程各分配又释放了 12.8MB\n", getpid());
for(int i=0;i<32;i++) pthread_join(t[i],NULL);
}
EOF
gcc -pthread -o /tmp/arena /tmp/arena.c && /tmp/arena &
sleep 3
# 看 RSS:远大于 0(内存被各 arena 保留着)
grep VmRSS /proc/$(pgrep -n arena)/status
# 看 mmap 出来的 arena(每个 64MB 的匿名区域)
grep -c 'rw-p' /proc/$(pgrep -n arena)/maps
awk '$2=="rw-p" && $6=="" {split($1,a,"-"); s+=strtonum("0x"a[2])-strtonum("0x"a[1])} END {print s/1024/1024 " MB 匿名映射"}' /proc/$(pgrep -n arena)/maps
# ✅ 三种缓解手段
# ① 限制 arena 数量(最常用)
MALLOC_ARENA_MAX=2 ./myapp
# 或者代码里:mallopt(M_ARENA_MAX, 2);
# 取舍:arena 少 -> 内存省但锁竞争增加
# ② 降低 mmap 阈值,让大块直接归还
MALLOC_MMAP_THRESHOLD_=65536 ./myapp
# ③ 换分配器(第 4 章)
LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libjemalloc.so.2 ./myapp
# ⚠️ 这是很多 Java/Python/C++ 服务在容器里「内存超限」的真实原因:
# 容器限制 2GB,应用堆只用了 1GB,但 arena 保留的内存让 RSS 到了 2GB -> OOMKilled
# Java 社区甚至有专门的建议:容器里设 MALLOC_ARENA_MAX=2 或 4
3.4 调优与观测
# 环境变量(不用改代码)
MALLOC_ARENA_MAX=2 # arena 数量上限
MALLOC_MMAP_THRESHOLD_=131072 # 超过多大直接 mmap
MALLOC_TRIM_THRESHOLD_=131072 # 堆顶空闲多大才收缩
MALLOC_TOP_PAD_=131072 # 每次扩展堆时多要一点(减少 brk 次数)
MALLOC_MMAP_MAX_=65536 # 最多同时有多少个 mmap 块
MALLOC_PERTURB_=42 # ✅ 调试用:free 后填充固定字节(暴露 use-after-free)
MALLOC_CHECK_=3 # ✅ 调试用:检测堆破坏并 abort
GLIBC_TUNABLES=glibc.malloc.arena_max=2:glibc.malloc.tcache_count=0 # 新式写法
// 程序内观测
#include <malloc.h>
struct mallinfo2 mi = mallinfo2();
printf("arena=%zu (brk 得到的总量)\n", mi.arena);
printf("hblkhd=%zu (mmap 得到的总量)\n", mi.hblkhd);
printf("uordblks=%zu (【正在使用】的字节)\n", mi.uordblks); // ✅ 这才是"真实使用量"
printf("fordblks=%zu (空闲但保留的字节)\n", mi.fordblks); // ✅ 这是"被池子占着的"
printf("keepcost=%zu (可归还的堆顶空闲)\n", mi.keepcost);
malloc_stats(); // 打印到 stderr 的详细统计
malloc_info(0, stdout); // XML 格式,含每个 arena 的明细
malloc_trim(0); // 主动归还
# ✅ 判断「是真泄漏还是分配器保留」的关键对比
# uordblks 持续增长 -> 真泄漏(存活对象在涨)
# uordblks 稳定但 RSS 高 -> 分配器保留/碎片(不是泄漏)
# 外部观测
sudo grep -A 12 '\[heap\]' /proc/$(pgrep -n myapp)/smaps
# Rss / Pss / Private_Dirty 就是堆真实占用的物理内存(13 篇 2.3)
4. 其他分配器
4.1 三个主流替代品
| glibc malloc | tcmalloc(Google) | jemalloc(FB/FreeBSD) | mimalloc(MS) | |
|---|---|---|---|---|
| 核心结构 | arena + bins + tcache | per-thread cache + 中央堆 | arena + 精细 size class + slab | per-thread 分片 + 免费列表分片 |
| 小对象速度 | 好(tcache 之后) | 很好 | 好 | 最好(宣称) |
| 多线程扩展性 | ⚠️ 一般(arena 数量有限) | ✅ 好 | ✅ 很好 | ✅ 很好 |
| 碎片控制 | ⚠️ 一般 | 好 | ✅ 最好(精细 size class + 主动 purge) | 好 |
| 主动归还 OS | ❌ 需手动 malloc_trim |
✅ 有后台线程 | ✅ 有 decay 机制可调 | ✅ 有 |
| 内存开销 | 中 | 中 | 稍高(元数据多) | 低 |
| 可观测性 | 弱(mallinfo) |
好(pprof 集成) | ✅ 最好(malloc_stats_print、丰富的 mallctl) |
一般 |
| 典型用户 | 默认 | Chrome、Google 内部 | Redis、Firefox、Rust 曾用、FreeBSD 默认 | .NET |
4.2 怎么换与什么时候值得换
# ── 方式一:LD_PRELOAD(不改代码、不重编译)──
sudo apt install libjemalloc2 libtcmalloc-minimal4
LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libjemalloc.so.2 ./myapp
LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libtcmalloc_minimal.so.4 ./myapp
# 验证生效
grep -E 'jemalloc|tcmalloc' /proc/$(pgrep -n myapp)/maps
# systemd 里
# [Service]
# Environment=LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libjemalloc.so.2
# ── 方式二:编译时链接 ──
gcc myapp.c -ljemalloc -o myapp
# ── jemalloc 的观测(这是它最大的优势之一)──
MALLOC_CONF=stats_print:true ./myapp # 退出时打印详细统计
MALLOC_CONF=background_thread:true,dirty_decay_ms:5000,muzzy_decay_ms:5000 ./myapp
# ^^^^^^^^^^^^^^^^^^^^^ 开后台线程主动归还
# ^^^^^^^^^^^^^^^^^^ 脏页多久后归还 OS
什么时候值得换:
✅ 值得:
- 多线程 + 大量小对象分配(glibc 的 arena 膨胀明显)
- 长期运行的服务出现碎片累积(RSS 持续缓涨但 uordblks 稳定)
- 需要精细的内存可观测性(jemalloc 的统计非常详细)
- Redis 就默认用 jemalloc,原因正是「碎片控制 + 可观测」
❌ 不必:
- Go 程序(有自己的分配器,libc 的 malloc 根本不参与,见第 6 章)
- 短生命周期的进程(CLI 工具、批处理)
- 单线程程序(glibc 已经够快)
- 内存占用不是瓶颈的服务
⚠️ 换之前必须实测:
不同负载的表现差异很大,「换了 jemalloc 就变好」不是普遍规律
# Redis 的例子(它把碎片率作为一个正式指标暴露出来)
redis-cli info memory | grep -E 'mem_allocator|mem_fragmentation_ratio|used_memory_rss'
# mem_allocator:jemalloc-5.2.1
# mem_fragmentation_ratio:1.08 <- RSS / used_memory
# ≈1.0 健康
# >1.5 碎片明显(可开 activedefrag)
# <1.0 部分内存被换出到 swap(❌ 更糟)
redis-cli config set activedefrag yes # jemalloc 支持的主动碎片整理
5. 碎片:用户态的形态
5.1 两种碎片
内部碎片(internal fragmentation)
原因:size class 舍入 + chunk 头部开销
例:malloc(1) 实际占 32 字节(3.1 节验证过)
malloc(129) 在 128/256 两档的分配器里会占 256 字节
衡量:实际请求字节数 / 实际占用字节数
-> 小对象多的负载里,这个损耗可能达 30%~50%
外部碎片(external fragmentation)
原因:空闲块分散,无法合并成足够大的连续区域
例:有 100 个 4KB 的空闲块,但来了一个 8KB 的请求,
如果这些块不相邻,就得向内核再要内存
衡量:RSS / 存活对象总量(Redis 的 mem_fragmentation_ratio 就是这个)
5.2 为什么用户态碎片比内核更难处理
内核可以做【内存规整】(25 篇 2.4):把物理页搬走,腾出连续空间
-> 因为有页表这个间接层,搬动物理页对进程【透明】(24 篇)
用户态【不能搬动对象】:
应用手里拿着裸指针,分配器一旦移动对象,那些指针全部失效
-> 所以用户态分配器只能「避免产生碎片」,不能「事后整理」
✅ 例外:有 GC 的运行时【可以】搬动对象(因为 GC 知道所有引用)
- Java 的压缩式 GC(mark-compact)会整理堆
- Go 的 GC 【不移动】对象(非移动式 GC)—— 这是个有意的取舍:
简化了 cgo 交互与内部指针,代价是不能做堆压缩
- 所以 Go 靠【精细的 size class + span 管理】来控制碎片,而不是靠整理
5.3 定位碎片问题
# ① 应用视角:存活对象总量(这是关键对比项)
# C/C++: mallinfo2().uordblks
# Go: runtime.MemStats.HeapAlloc
# Java: 堆使用量(jstat / JMX)
# Redis: used_memory
# ② 进程视角:真实物理占用
grep VmRSS /proc/$(pgrep -n myapp)/status
awk '/^Private_Dirty/{s+=$2} END {print s/1024 " MB 独占脏页"}' /proc/$(pgrep -n myapp)/smaps
# ③ 判断
# RSS ≈ 存活对象 -> 健康
# RSS ≫ 存活对象且存活稳定 -> ✅ 碎片或分配器保留(不是泄漏!)
# RSS 与存活对象【同步增长】 -> ❌ 真泄漏
6. Go 的分配器
6.1 开篇第四问:为什么不用 libc
① CGO_ENABLED=0 时根本没有 libc(01 篇 2.3)
静态编译的 Go 程序不链接 libc,malloc 无从调用
② GC 需要知道每个对象的元数据
「这块内存里哪些位置是指针」是 GC 扫描的前提。
libc 的 malloc 只返回一块裸内存,不携带任何类型信息。
Go 需要把「对象大小、是否含指针、所属 span」这些信息组织起来。
③ 分配单位是 goroutine 而不是线程
glibc 的 arena 是 per-thread 的,而 Go 有几十万个 goroutine
跑在几个 OS 线程上 —— per-thread 缓存的模型不匹配。
Go 用 per-P(逻辑处理器)缓存,正好对应 GMP 模型。
④ 需要与调度器、GC 协同
分配时可能触发 GC 辅助标记(mutator assist)、
可能需要抢占检查 —— 这些只能在 runtime 内部做。
⑤ 性能可控
逃逸分析让大量对象根本不上堆(分配在栈上),
这是编译器与分配器协同设计的结果,libc 无法参与。
6.2 三级结构
┌──────────────────────────────────────────────────────────┐
│ mcache(每个 P 一个,✅ 完全无锁) │
│ 为 68 个 size class 各持有一个 mspan │
│ 分配路径:从对应 size class 的 span 里取一个空闲槽位 │
└──────────────────────────────────────────────────────────┘
↓ 本地 span 用完了
┌──────────────────────────────────────────────────────────┐
│ mcentral(每个 size class 一个,全局共享,加锁) │
│ 维护该 size class 的 span 列表(有空位的 / 已满的) │
└──────────────────────────────────────────────────────────┘
↓ 没有可用 span
┌──────────────────────────────────────────────────────────┐
│ mheap(全局唯一,管理所有页) │
│ 按 page(8KB)管理,用 pageAlloc(基数树位图)找空闲页 │
└──────────────────────────────────────────────────────────┘
↓ 不够
┌──────────────────────────────────────────────────────────┐
│ 向 OS 申请:mmap 一个 64MB 的 arena │
└──────────────────────────────────────────────────────────┘
这个结构与 glibc 的对应关系很清晰:
| Go | glibc | 作用 |
|---|---|---|
mcache(per-P,无锁) |
tcache(per-thread,无锁) |
快路径 |
mcentral(per size class) |
arena 的 bins |
中央池 |
mheap + pageAlloc |
top chunk + brk | 页级管理 |
arena(64MB,mmap) |
非主 arena(64MB,mmap) |
向内核批发 |
6.3 size class 与 span
# Go 有 68 个 size class(8B 到 32KB)
# runtime/sizeclasses.go 里有完整的表,前几档:
# class bytes/obj bytes/span objects tail waste max waste
# 1 8 8192 1024 0 87.50%
# 2 16 8192 512 0 43.75%
# 3 24 8192 341 8 29.24%
# ...
# 67 32768 32768 1 0 12.50%
#
# ✅ 为什么要 68 档而不是简单的 2 的幂?
# 2 的幂(8/16/32/64…)的最坏内部碎片是 50%(申请 33 要给 64)
# 68 档精细划分把最坏浪费压到 ~12%(大对象)—— 这是「省」的直接投入
// span(mspan):一段连续的页,被切成同一 size class 的槽位
// 一个 span 里所有对象大小相同 —— 这带来三个好处:
// ① 分配 = 从位图里找一个空闲位(O(1))
// ② 不需要 chunk 头部(对象大小由 span 决定!)
// -> ✅ Go 没有 glibc 那样「每个对象 16 字节头部」的开销
// ③ GC 扫描时可以按 span 批量处理,元数据集中存放(不与用户数据相邻)
// -> 也顺带避免了 glibc 的「堆溢出篡改 chunk 头部」这类问题
三条分配路径:
① tiny 分配器(< 16 字节 且【不含指针】)
把多个微小对象打包进同一个 16 字节块
-> 大量 int/bool/小 struct 的场景节省显著
② 小对象(16B ~ 32KB)
走 mcache -> mcentral -> mheap 三级
③ 大对象(> 32KB)
直接从 mheap 分配(专属的 span),不经过 mcache
6.4 与 GC 的耦合
# Go 的 GC 是【非移动式】的三色标记清除(并发)
# 分配器为它提供三样东西:
# ① 对象的精确边界(靠 span 的 size class)
# ② 指针位图(哪些字段是指针 —— 编译器生成,存在类型元数据里)
# ③ 标记位图(每个 span 有 allocBits / gcmarkBits)
# 观察 GC 与分配
GODEBUG=gctrace=1 ./myapp 2>&1 | head -5
# gc 1 @0.021s 0%: 0.018+1.3+0.006 ms clock, 0.14+0.35/1.2/0+0.052 ms cpu,
# 4->4->1 MB, 5 MB goal, 8 P
# ^^^^^^^^^^^ GC 开始时堆大小 -> 结束时 -> 存活量
# ^^^^^^^^^ 下次触发的目标
GODEBUG=allocfreetrace=1 ./myapp # ⚠️ 极其详细(每次分配都打印),只用于调试
# 内存统计
curl -s 'localhost:6060/debug/pprof/heap?debug=1' | grep -E '^# (Sys|HeapSys|HeapAlloc|HeapIdle|HeapReleased|HeapInuse|StackSys|MSpanSys|MCacheSys) '
var m runtime.MemStats
runtime.ReadMemStats(&m)
// 关键字段(这些是 Go 内存排查的核心指标)
m.Sys // ✅ runtime 从 OS 拿到的总量(≈ 进程 RSS 的上界)
m.HeapAlloc // ✅ 【存活对象】的字节数(等价于 glibc 的 uordblks)
m.HeapSys // 堆向 OS 申请的总量
m.HeapIdle // 空闲的 span(可归还)
m.HeapReleased // ✅ 已经归还给 OS 的部分
m.HeapInuse // 正在使用的 span
m.StackSys // goroutine 栈占用(goroutine 泄漏时这个涨)
m.MSpanSys + m.MCacheSys + m.GCSys // runtime 自身的元数据开销
m.NumGC // GC 次数
m.PauseTotalNs // 累计 STW 时间
Go 的内存问题判断表(回收 13 篇 10.2):
| 现象 | 判断 |
|---|---|
HeapAlloc 持续增长 |
堆内泄漏 → pprof heap 找引用 |
HeapAlloc 稳定但 HeapIdle 大 |
碎片或 span 未归还 → 正常,或调 GOGC |
Sys ≈ RSS 但 HeapAlloc 小 |
堆外:cgo 的 C.malloc、mmap、goroutine 栈 |
StackSys 持续增长 |
goroutine 泄漏 → pprof goroutine |
HeapReleased 大但 RSS 不降 |
MADV_FREE 的延迟回收(24 篇 8.2) |
6.5 归还给 OS
// Go 有一个后台的 scavenger(清扫器)负责归还内存
// runtime/mgcscavenge.go
// ① 按「内存增长速率」计算一个归还目标(避免归还了又立刻要回来)
// ② 把长时间空闲的 span 用 madvise 交还
// ③ Go 1.16+ 默认用 MADV_DONTNEED(RSS 立即下降,24 篇 8.2)
// 手动触发
debug.FreeOSMemory() // 立刻 GC + 尽可能归还(相当于 glibc 的 malloc_trim)
// 与 GOMEMLIMIT 的配合(12/23/25 篇反复提到)
debug.SetMemoryLimit(1800 << 20)
// 它让 GC 在接近上限时更积极:
// ① 提前触发 GC
// ② 让 mutator 承担更多辅助标记工作(assist)
// ③ 更积极地归还内存
// ✅ 容器里必须设,否则 Go 只按 GOGC 的比例增长,不知道 cgroup 上限
# Go 的 VSZ 为什么大(12/24 篇讲过,这里是分配器视角的原因)
# runtime 启动时预留(reserve)大块地址空间用于 arena:
# mmap(PROT_NONE) 只占虚拟地址不占物理页
# 目的:让堆能连续增长,简化指针到 span 的映射计算
grep -c 'PROT_NONE\|---p' /proc/$(pgrep -n myapp)/maps
awk '$2=="---p" {split($1,a,"-"); s+=strtonum("0x"a[2])-strtonum("0x"a[1])} END {print s/1024/1024/1024 " GB 预留(未提交)"}' /proc/$(pgrep -n myapp)/maps
7. 实战:内存问题的分层定位
现象:进程 RSS 持续增长
|
v
① 是应用内存还是内核内存?(25 篇 3.3)
所有进程 RSS 之和 vs MemTotal - MemAvailable
├─ 差距大 -> 内核内存(slab / 页表 / 网络缓冲)-> slabtop
└─ 对得上 -> 继续
|
v
② 是哪个进程?
ps aux --sort=-rss | head 或按 PSS 排序(13 篇 2.3)
|
v
③ 存活对象是否在增长?(本篇的核心判断)
C/C++: mallinfo2().uordblks Go: MemStats.HeapAlloc
Java: 堆使用量 Redis: used_memory
├─ 增长 -> ❌ 【真泄漏】-> 用 pprof / valgrind / heaptrack 找引用
└─ 稳定 -> 继续(不是泄漏!)
|
v
④ RSS ≫ 存活对象,差在哪?
├─ glibc + 多线程 -> ✅ 【arena 膨胀】-> MALLOC_ARENA_MAX=2
├─ 长期运行 -> ✅ 【碎片】-> malloc_trim / 换 jemalloc
├─ Go 且 HeapIdle 大 -> span 未归还 -> debug.FreeOSMemory() / 调 GOGC
├─ Go 且 Sys≈RSS 但 HeapAlloc 小 -> 【堆外】cgo / mmap / goroutine 栈
└─ 24 篇的 MADV_FREE 延迟回收(Go < 1.16)
|
v
⑤ 容器场景额外检查
cgroup 的 memory.current / memory.max / memory.stat(25 篇 6.3)
MemoryHigh 是否配了(减速带)
GOMEMLIMIT / -XX:MaxRAMPercentage 是否设了
8. 面试题
Q:malloc 为什么不直接用 mmap/brk 系统调用?
三个原因都是致命的:系统调用太贵(mmap 约 1~2 微秒,而 malloc 应该在几十纳秒级 —— 差 50 倍以上)、粒度不匹配(内核最小 4KB 页,而 malloc(37) 只要 37 字节,每个小对象占一页浪费 99%)、缺页开销(即使 mmap 成功,首次访问还要触发缺页异常)。
所以分配器的核心策略是批发零售:向内核批量要内存(一次几百 KB 到几 MB),在用户态零售,释放的对象留在池子里复用而不还给内核。可以用 strace -c 验证:一百万次 malloc/free 只产生个位数的系统调用。
分配器要同时满足三个互相冲突的目标:快(纳秒级、多线程无锁竞争)、省(元数据小、碎片少)、抗碎片(长期运行不无限增长)。不同分配器的差异本质就是在这三者间选了不同的平衡点。
Q:free 之后 RSS 为什么不下降?
三个原因,其中第一个是结构性的:
brk只能从末端收缩 —— 堆是一段连续区域,如果中间有一个存活对象,它左边的空闲空间永远还不了。一个长寿命的小对象就能钉住整个堆,这不是 bug 而是brk的固有限制- 分配器故意保留(这是特性)—— 刚 free 就还给内核,下次 malloc 又要重新申请 + 缺页。glibc 的
M_TRIM_THRESHOLD(默认 128KB)决定堆顶空闲多大才收缩 - 碎片 —— 空闲块分散,无法合并成足够大的连续区域
关键结论:判断内存泄漏不能只看 RSS,要看「存活对象总量是否持续增长」(C 看 mallinfo2().uordblks、Go 看 HeapAlloc、Redis 看 used_memory)。
主动归还的手段:malloc_trim(0)、降低 M_MMAP_THRESHOLD 让大块走 mmap(free 时直接 munmap)、或换 jemalloc/tcmalloc(有后台线程主动归还)。
Q:glibc 的 arena 是什么?为什么会导致多线程程序内存膨胀?
arena 是独立的堆。多线程都操作同一个堆会有严重锁竞争,所以 glibc 给线程分配多个 arena(主 arena 用 brk,非主 arena 用 mmap 申请 64MB 区域),每个线程首次 malloc 时绑定一个 arena 并固定使用。
上限是 8 × 核数(64 位)—— 一台 32 核机器最多 256 个 arena。
膨胀的原因是各 arena 的空闲内存互不共享:线程 A 释放的内存线程 B 用不上。于是总内存占用 = 各 arena 峰值之和,而不是全局峰值。
这是很多 Java/Python/C++ 服务在容器里被 OOMKilled 的真实原因:容器限制 2GB,应用堆只用了 1GB,但 arena 保留的内存让 RSS 到了 2GB。缓解手段是 MALLOC_ARENA_MAX=2(Java 社区有专门的这条建议)—— 代价是 arena 少了锁竞争会增加。
Q:tcache 解决了什么问题?
在 glibc 2.26(2017)之前,即使命中 fastbin 也要加 arena 的锁 —— 多线程下即使各自分配小对象也会竞争同一把锁。
tcache 是纯线程本地的缓存(64 个桶,16~1032 字节,每桶最多 7 个 chunk),命中时完全无锁,只有几条指令,小对象分配性能提升数倍。
代价有两个:每个线程多占一点内存;以及新的安全问题 —— tcache 的实现简单(单向链表,早期缺少校验),使得 tcache poisoning 成为主流的堆利用手法。
顺带一个 glibc 的精妙设计:空闲 chunk 的用户数据区被复用来存链表指针,所以空闲块管理不需要额外内存。代价是 chunk 头部与用户数据相邻,缓冲区溢出可以篡改头部 —— 这是所有堆溢出利用的基础。
Q:malloc(1) 实际占多少内存?
32 字节(malloc_usable_size 返回 24,加上头部)。因为 glibc 的 chunk 有 8/16 字节头部(存 size 和标志位),且按 16 字节对齐,最小 chunk 是 32 字节。
这就是内部碎片的典型形态 —— 小对象的开销比例极高。在小对象密集的负载里,内部碎片可能损耗 30%~50%。
对比 Go:它没有 per-object 头部 —— 对象大小由所属 span 的 size class 决定,元数据集中存放在 span 里。这既省内存,也顺带避免了「堆溢出篡改对象头部」这类问题。Go 用 68 个 size class 把最坏内部碎片压到约 12%(如果只用 2 的幂,最坏是 50%)。
Q:为什么用户态的碎片比内核的更难处理?
因为用户态不能搬动对象。内核可以做内存规整(25 篇)把物理页搬走腾出连续空间 —— 因为有页表这个间接层,搬动对物理页对进程透明(24 篇)。而应用手里拿着裸指针,分配器一旦移动对象,那些指针全部失效。
所以用户态分配器只能「避免产生碎片」,不能「事后整理」。
例外是有 GC 的运行时:GC 知道所有引用,所以可以搬动对象(Java 的 mark-compact 会压缩堆)。但 Go 的 GC 是非移动式的 —— 这是个有意的取舍(简化 cgo 交互和内部指针),代价是不能做堆压缩,只能靠精细的 size class + span 管理来控制碎片。
Q:Go 为什么不用 libc 的 malloc,自己实现一套?
五个原因:
CGO_ENABLED=0时根本没有 libc(01 篇)—— 静态编译的 Go 程序不链接它- GC 需要对象元数据 —— 「这块内存里哪些位置是指针」是扫描的前提,而
malloc只返回一块裸内存,不携带任何类型信息 - 分配单位是 goroutine 而不是线程 —— glibc 的 arena 是 per-thread 的,而 Go 有几十万 goroutine 跑在几个 OS 线程上。Go 用 per-P 缓存,正好对应 GMP 模型
- 要与调度器、GC 协同 —— 分配时可能触发 GC 辅助标记(mutator assist)、抢占检查
- 性能可控 —— 逃逸分析让大量对象根本不上堆,这是编译器与分配器协同设计的结果
它的三级结构与 glibc 有清晰的对应:mcache(per-P,无锁)↔ tcache、mcentral(per size class)↔ arena 的 bins、mheap + pageAlloc ↔ top chunk + brk、64MB arena ↔ 非主 arena。
Q:Go 的内存问题怎么分层定位?
关键是对比「存活对象」和「RSS」:
| 现象 | 判断 |
|---|---|
HeapAlloc 持续增长 |
堆内泄漏 → pprof heap 找引用 |
HeapAlloc 稳定但 HeapIdle 大 |
碎片或 span 未归还(不是泄漏) |
Sys ≈ RSS 但 HeapAlloc 小 |
堆外 —— cgo 的 C.malloc、mmap、goroutine 栈 |
StackSys 持续增长 |
goroutine 泄漏 → pprof goroutine |
HeapReleased 大但 RSS 不降 |
MADV_FREE 的延迟回收(Go < 1.16) |
完整的排查顺序是:① 应用内存还是内核内存(所有进程 RSS 之和 vs MemTotal - MemAvailable,差距大就查 slabtop)→ ② 哪个进程(按 PSS 排序)→ ③ 存活对象是否增长(这一步区分「真泄漏」和「分配器保留」)→ ④ 差额在哪(arena 膨胀 / 碎片 / 堆外 / 未归还)→ ⑤ 容器额外检查 memory.current、MemoryHigh、GOMEMLIMIT。
Q:什么时候值得换 jemalloc/tcmalloc?
值得:多线程 + 大量小对象分配(glibc arena 膨胀明显);长期运行出现碎片累积(RSS 持续缓涨但 uordblks 稳定);需要精细的内存可观测性。Redis 默认用 jemalloc 正是为了「碎片控制 + 可观测」—— 它把 mem_fragmentation_ratio(RSS / used_memory)作为正式指标暴露出来。
不必:Go 程序(有自己的分配器,libc 的 malloc 根本不参与)、短生命周期进程、单线程程序、内存不是瓶颈的服务。
换的方式很简单(LD_PRELOAD=/usr/lib/.../libjemalloc.so.2,不用改代码),jemalloc 还能用 MALLOC_CONF=background_thread:true,dirty_decay_ms:5000 配置主动归还。但必须实测 —— 不同负载差异很大,「换了就变好」不是普遍规律。
小结
- 分配器的存在理由是「系统调用太贵 + 粒度不匹配」 → 批发零售,释放的对象留在池里复用
- 快、省、抗碎片三者冲突,各分配器的差异就是平衡点不同
brk只能从末端收缩 → 一个长寿命对象就能钉住整个堆 → RSS 不降不等于泄漏- 判断泄漏要看「存活对象总量」(
uordblks/HeapAlloc/used_memory),不是看 RSS - glibc 的 arena 上限是 8 × 核数,各 arena 内存互不共享 → 容器里 OOM 的常见真凶 →
MALLOC_ARENA_MAX=2 tcache(2.26+)让小对象分配无锁,代价是成了主流的堆利用入口- 用户态不能搬动对象(裸指针),所以只能避免碎片不能整理;有 GC 的运行时才能压缩,而 Go 有意选择了非移动式 GC
- Go 自己实现分配器有五个理由,核心是 GC 需要元数据 + per-P 而非 per-thread;它没有 per-object 头部,用 68 个 size class 把内部碎片压到 ~12%
- 换 jemalloc 对 Go 无意义(libc 不参与),对多线程 C/C++/Java 服务可能收益显著
下一篇讲 调度器演进:O(n) → O(1) → CFS → EEVDF —— 每一代被什么问题逼着换代、虚拟运行时与 nice 权重表怎么来的、抢占点在哪、以及容器的 CPU 限流(CFS quota)如何造成 Go 服务的尾延迟。
xingliuhua