目录

Linux-26 用户态分配器:从 brk 到 Go 的 mspan

25 篇讲了内核如何管理物理页。这一篇讲用户态如何把「页」切成「对象」 —— 以及为什么每个大型运行时(glibc、Go、JVM、Redis)都要自己写一套分配器。

先看五个问题:

  1. malloc 为什么不直接用 mmap/brk 系统调用?
  2. free 之后内存为什么不还给操作系统(RSS 不下降)?
  3. glibc 的 arena 是什么?为什么多线程程序的内存占用会「莫名膨胀」?
  4. Go 为什么完全不用 libc 的 malloc,自己实现一套?
  5. 用户态的内存碎片是怎么产生的,怎么衡量?

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.mallocmmap、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 为什么不下降?

三个原因,其中第一个是结构性的

  1. brk 只能从末端收缩 —— 堆是一段连续区域,如果中间有一个存活对象,它左边的空闲空间永远还不了。一个长寿命的小对象就能钉住整个堆,这不是 bug 而是 brk 的固有限制
  2. 分配器故意保留(这是特性)—— 刚 free 就还给内核,下次 malloc 又要重新申请 + 缺页。glibc 的 M_TRIM_THRESHOLD(默认 128KB)决定堆顶空闲多大才收缩
  3. 碎片 —— 空闲块分散,无法合并成足够大的连续区域

关键结论:判断内存泄漏不能只看 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,自己实现一套?

五个原因:

  1. CGO_ENABLED=0 时根本没有 libc(01 篇)—— 静态编译的 Go 程序不链接它
  2. GC 需要对象元数据 —— 「这块内存里哪些位置是指针」是扫描的前提,而 malloc 只返回一块裸内存,不携带任何类型信息
  3. 分配单位是 goroutine 而不是线程 —— glibc 的 arena 是 per-thread 的,而 Go 有几十万 goroutine 跑在几个 OS 线程上。Go 用 per-P 缓存,正好对应 GMP 模型
  4. 要与调度器、GC 协同 —— 分配时可能触发 GC 辅助标记(mutator assist)、抢占检查
  5. 性能可控 —— 逃逸分析让大量对象根本不上堆,这是编译器与分配器协同设计的结果

它的三级结构与 glibc 有清晰的对应:mcache(per-P,无锁)↔ tcachemcentral(per size class)↔ arena 的 binsmheap + pageAlloc ↔ top chunk + brk64MB arena ↔ 非主 arena

Q:Go 的内存问题怎么分层定位?

关键是对比「存活对象」和「RSS」

现象 判断
HeapAlloc 持续增长 堆内泄漏pprof heap 找引用
HeapAlloc 稳定但 HeapIdle 碎片或 span 未归还(不是泄漏)
Sys ≈ RSS 但 HeapAlloc 堆外 —— cgo 的 C.mallocmmap、goroutine 栈
StackSys 持续增长 goroutine 泄漏pprof goroutine
HeapReleased 大但 RSS 不降 MADV_FREE 的延迟回收(Go < 1.16)

完整的排查顺序是:① 应用内存还是内核内存(所有进程 RSS 之和 vs MemTotal - MemAvailable,差距大就查 slabtop)→ ② 哪个进程(按 PSS 排序)→ ③ 存活对象是否增长(这一步区分「真泄漏」和「分配器保留」)→ ④ 差额在哪(arena 膨胀 / 碎片 / 堆外 / 未归还)→ ⑤ 容器额外检查 memory.currentMemoryHighGOMEMLIMIT

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 服务的尾延迟