Theory · OS · Memory Layout & Heap
从 /proc/maps 的六段地图,到 malloc 的 brk/mmap 分流 —— 堆是用户态在虚拟内存上盖的一层"批发市场"
text / data / bss / heap / mmap / stack——每段的权限、生长方向与典型问题各不相同
malloc 小块走 brk(缓存复用)、大块走 mmap(释放即还)——128KB 阈值背后是碎片与成本的两难
ptmalloc / tcmalloc / Go runtime 都在做同一件事:向内核批发内存、向应用零售对象
Why a Heap Allocator
// 一个再普通不过的循环 for (i = 0; i < 1000000; i++) { p[i] = malloc(32); // 只要 32 字节 } // 假设 malloc 是"薄封装",每次直接找内核: // ① 100 万次系统调用(用户态↔内核态切换) // 每次约 1~2 微秒 → 光切换就 1~2 秒 // ② 内核按"页"给内存,最小 4096 字节 // 32 字节的对象占掉一整页 → 浪费 99% // 100 万个对象 → 4 GB(本该只要 32 MB)
如果整个地址空间是一大块无差别的字节,那就无法区分"代码"(不该被改写,要能执行)和"数据"(要能改,但不能执行)。分段让操作系统能对每段分别设置权限——写只读段立刻 SIGSEGV,而不是让程序悄悄跑飞。/proc/maps 里每一行就是一条这样的规则。
局部变量住在栈上,函数一返回就没了。要让数据活过函数返回、或者大小运行时才知道,就必须有一块手动管理的区域——这就是堆。它付出的代价是:谁来分配、什么时候释放、怎么避免碎片,全成了程序员与分配器的责任。
就是开场那笔账:系统调用税 + 页粒度浪费。分配器的全部价值 = 向内核批发、向应用零售、把释放的块留住复用。理解这一点,后面所有术语(arena / bin / size class)都是同一件事的不同说法。
先看地址空间这张地图(六段各司其职)→ 再钻进栈(最快也最脆)→ 然后看 malloc 怎么向内核进货(brk / mmap 分流)→ 进分配器内部看仓库结构 → 最后落到排障与 Go 视角。
Prerequisites & Glossary
| 术语 | 一句话理解(先记住这个,细节后面展开) |
|---|---|
| 虚拟地址空间 | 每个进程"以为"自己独占的一整套地址,由页表映射到物理内存,彼此看不见 |
| 段 segment | 地址空间里用途相同的一块连续区域,各有自己的权限(可读/可写/可执行) |
| .data / .bss | .data 存已初始化的全局变量;.bss 存未初始化或显式为零的(载入时补零,不占文件) |
| 栈帧 stack frame | 一次函数调用在栈上占的一格:返回地址、局部变量的栖身之所 |
| 堆 heap | 手动申请、生命周期不受函数返回限制的那块区域,由分配器管理 |
| brk / sbrk | 把堆顶指针往上捅来扩堆的系统调用,分配器进货通道之一 |
| mmap 匿名映射 | 在 mmap 区另开一块独立区域的系统调用,释放时整块归还,不进堆 |
| 页 page | 内核管理内存的最小单位(通常 4KB),分配器向内核进货的粒度 |
| RSS 常驻内存 | 进程此刻真正占着物理内存的量;与 VIRT(申请的虚拟地址总量)是两回事 |
| 碎片 fragmentation | 内部碎片 = 给了你一页却只用 32 字节;外部碎片 = 空闲总量够但被切成碎块拼不出连续大块 |
先读这两篇再回来,本 deck 默认你已经知道它们:
虚拟内存 → 页表、缺页异常、地址是怎么翻译的
进程 · 线程 · 协程 → 进程是什么、线程栈从哪来
内核按页批发,分配器零售给对象;free 只是把货退回货架,不一定退回内核。理解了"批发—零售—回收复用"这条链,/proc/maps 的每一行、malloc 的每一次分流、RSS 为什么涨了不降,全部能推出来。
内存管理里没有免费的快:栈快是因为生命周期被函数作用域焊死;堆灵活是因为引入了碎片与分配成本;分配器快是因为把释放的块留在手里不还——于是 RSS 就不降了。每一个优点都对应一个代价。
The /proc/maps Map
开场那笔账里 malloc 要来的页,就落在这张地图的 heap 与 mmap 两段上。
① 权限位:.text 只读可执行(改代码 = SIGSEGV);.data/.bss 可写不可执行(NX 位)。② 生长方向:堆向高、栈向低,中间 mmap 区是缓冲。③ ASLR:栈/堆/mmap 起点随机,抬升攻击成本。
页表隔离让每个进程都"以为"独占这张地图——第一篇的抽象承诺在此兑现。共享库映射(libc)在 mmap 区,物理页全局只有一份。
32 位:用户 3G/内核 1G,堆栈之间只有 3G 回旋——"内存不够"问题多为假象( fragmentation)。64 位:用户/内核各 128T,瓶颈转向真物理内存与页表开销。
text · data · bss
| 声明 | 住在哪 |
|---|---|
int a = 1;(全局已初始化) | .data |
int b;(全局未初始化) | .bss(载入时零填) |
int c = 0;(显式零) | .bss(编译器优化,不占文件) |
static int d = 2; | .data(static 只影响可见性) |
char *p = "hi"; | p 在 .data,"hi" 在 .rodata |
| 局部变量 / 参数 | 栈 |
| malloc / new 出来的 | 堆 |
可执行文件按段(segment)映射进地址空间:代码段映射 .text/.rodata(r-x/r--),数据段映射 .data(r-w,文件兜底)与 .bss(匿名零页)。动态链接库 libc 映射进 mmap 区——多进程共享同一物理页。
char *p = "hi"; 的字面量在 .rodata(写它 = SIGSEGV);char a[] = "hi"; 把字面量复制到栈数组(可写)——一字之差,段位置不同,面试经典。
住在栈底之上(进程启动时内核铺好)——这也是"栈顶往下就是命令行参数"的历史由来;改环境变量(setenv)可能移动它。
Stack Frames · 8MB · Guard Page
每次调用压一帧:返回地址 → 保存的 rbp(帧指针)→ 局部变量/寄存器保存区 → 实参溢出区。rsp 管栈顶、rbp 管帧底;leave/ret 恢复现场。递归深度 = 栈帧高度 × 每帧大小。
分配 = rsp 移一条指令(无搜索、无元数据、天然 LIFO 与作用域对齐);释放 = 出函数即回收。局部变量首选栈——Go 的逃逸分析拼命把对象留在这里。
默认栈上限 8MB(ulimit -s)。栈底端一页 guard page(PROT_NONE):越界踩到即 SIGSEGV——这就是"深递归/大局部数组崩溃"的机制;但如果一次跳过 guard page(alloca 大块/越界很远),可能静默踩到别的 VMA。
每线程独立栈(pthread 默认 8MB 预留,可调)——进程线程数量限制的"栈内存闸"来自这里;goroutine 2KB 起步的对照就在下一段(Go 视角页)。
// 栈溢出的两种经典姿势 int deep(int n) { char buf[1024]; // 每帧 ~1KB+ return n > 0 ? deep(n-1) : 0; } // deep(100000) → SIGSEGV void boom(void) { char big[8 * 1024 * 1024]; // 一帧打满 8MB big[0] = 1; // 触发 guard page } // Go 对照:初始 2KB 连续栈 // 溢出前检测 → 复制到 2 倍新栈 //(栈复制让"栈上指针"也要重写—— // 这是 Go 栈帧带指针位图的原因)
malloc 的两条路 · 128KB
它是 C 库的内存批发商:向内核批发(brk/mmap),向应用零售对象。两条进货通道:brk() 把堆顶指针上移扩堆;mmap() 在 mmap 区开一块私有匿名映射。
请求 < 128KB → brk(堆内切分);≥ 128KB → mmap(独立映射)。为什么这么分:mmap 系统调用贵(建映射、清页、TLB 刷新)不适合频繁小块;brk 复用好但释放的内存滞留堆内(见下)。
brk 小块:free 不还 OS,回内存池待复用(复用快、RSS 不降)——只有堆顶整块空闲时才可能收缩(mallopt M_TRIM_THRESHOLD)。mmap 大块:free 即 munmap,立刻归还 OS。malloc 返回的是虚拟内存,首次写入触发缺页才占物理页。
| 对比 | brk(<128KB) | mmap(≥128KB) |
|---|---|---|
| 位置 | 堆(连续区间) | mmap 区(独立映射) |
| 系统调用频率 | 低(批发一次切多次) | 每次分配一次 |
| free 后 | 回池复用,RSS 常不降 | munmap 立即归还 |
| 风险 | 堆内碎片(洞难复用) | 大块频繁映射开销 + VMA 数量 |
Arena · Bin · Chunk
全局锁是吞吐杀手 → ptmalloc 开多个 arena(主 arena 用 brk 堆;从 arena 用 64MB 子堆 mmap),线程绑定 arena 减少争抢(数量上限默认 8×核数)。仍不够就用 per-thread 缓存。
每个线程 64 个单链 bin、各缓存 7 块(上限约 1KB):free 先进 tcache、malloc 先查 tcache——无锁快路径,绝大多数分配在这里完成。
fastbin(≤128B,LIFO 不合并,最快);small bin(精确大小,<1KB,FIFO);large bin(范围大小,best-fit);unsorted bin(free 的中转站,下次分配顺手找);top chunk(仓库最底的余货)。free 时相邻块合并成大块对抗碎片。
每块前有元数据头(大小、标志位;64 位典型 16B 对齐)——free 向左偏移即可读到块大小;这就是"越界写 1 字节可能踩坏堆元数据"的堆溢出原理。
| 结构 | 大小带 | 特点 |
|---|---|---|
| tcache | ≤ ~1KB | per-thread 无锁,各 7 块 |
| fastbin | ≤ 128B | LIFO,不合并,最快 |
| small bin | < 1KB | 精确匹配,FIFO |
| large bin | ≥ 1KB | 范围匹配,best-fit |
| unsorted bin | 中转 | free 先进,分配时翻检 |
| top chunk | 余货 | 不够时向内核扩堆 |
tcmalloc · jemalloc
多核高并发下:arena 数有限(争抢残留)、bins 全局结构锁竞争、碎片随复杂分配模式恶化。SSD/多核时代出现了两代"重新设计":tcmalloc(Google)与 jemalloc(Facebook/FreeBSD)。
thread cache(每线程无锁)→ central cache(按 size class 全局桶)→ page heap(向内核要整页)。对象按大小分类(size class),同类用自由链表管理——分配退化为"从链表头摘一块",O(1) 且几乎无锁。
tcmalloc:线程本地缓存 + 定期均衡,小对象性能极强,与 Google 基建深度绑定;jemalloc:arena 划分更细 + 显式 mallctl 调优面,碎片控制与监控更细腻(Redis 官方推荐)。两者都支持 LD_PRELOAD 替换 malloc。
高并发 C/C++ 服务(QPS 高、分配频繁)换分配器常见 5–20% 吞吐提升或碎片改善;先 heaptrack/perf 证明瓶颈在分配,再换——先测量后替换。
| 分配器 | 核心结构 | 长项 |
|---|---|---|
| ptmalloc(glibc) | arena + tcache + bins | 默认自带、通用 |
| tcmalloc | thread cache + size class + page heap | 小对象吞吐、集成 TCM 治理 |
| jemalloc | 多 arena + size class + 脏页治理 | 碎片控制、可观测性 |
| Go runtime | mcache/mcentral/mheap(借 tcmalloc 思想) | 与 GC/栈调度一体化 |
Leak · RSS Drift · Fragmentation
分配后引用丢失且永不 free。工具:valgrind memcheck(慢但准)、heaptrack(低开销、带分配栈)、ASan(编译期插桩,泄漏+越界一起抓)。判据:分配栈稳定出现且只增不减。
先想三层:① demand paging 还在进行(冷启动后 RES 爬升是正常);② ptmalloc 缓存:free 过的小块滞留池中,RSS 不降(可 mallopt/M_TRIM_THRESHOLD、malloc_trim 主动还);③ madvise 差异:Go/新版 glibc 的归还语义影响 RES 观感。pmap -x 看堆段与 anon 段分布再下结论。
特征:RES 高但活跃对象不多、堆里全是洞、新大块分配变慢(要去 mmap)。治理:换 jemalloc/tcmalloc、按 size class 收敛分配模式、长周期大对象独立池。
# 快速三连:判断属于哪个案件 pmap -x <pid> | tail -20 # 看 [heap] 与 anon 段谁在涨 malloc_trim(0) # 手动还内存试试 # RSS 立降 → 案件二(池缓存) # 不降且 heaptrack 显示稳定 # 分配栈 → 案件一(真泄漏) # Go 侧对应姿势 # pprof heap: inuse_space 看"活"对象 # alloc_space 看累计——区别泄漏与抖动
inuse_space 是"此刻活着"(找泄漏),alloc_space 是"累计分配"(找分配热点/GC 压力)——两个指标搞混是排查失误第一名。
Go Runtime as Allocator
GC 需要知道每个对象的元数据(span、类型、指针位图)才能扫;ptmalloc 的 chunk 头帮不上忙。所以 runtime 自建:mheap(页堆,对接 OS)→ mcentral(按 span class 中央仓)→ mcache(每 P 无锁缓存)——tcmalloc 三件套的 Go 版,但分级单位是 span(67/68 个大小类)。
编译期决定:对象生命周期能被证明不超过函数 → 栈(零 GC 压力);被外部引用/接口装箱/大小未知 → 逃逸到堆。go build -gcflags=-m 看逃逸结论——"让对象留在栈上"是 Go 性能优化的第一原则。
mmap 预留 arena(VIRT 巨大的原因);mprotect 按需提交;GC 后 madvise 归还冷 span(RES 回落)。GOMEMLIMIT(1.19+)让 GC 在逼近限额时更卖力回收——容器内存 limit 的官方搭档。
| 层 | Go 结构 | ptmalloc 对应 |
|---|---|---|
| 线程缓存 | mcache(每 P) | tcache |
| 中央仓 | mcentral(按 class) | bins |
| 页堆 | mheap(span 管理) | top chunk / brk 扩堆 |
| 内核接口 | mmap/mprotect/madvise | brk/mmap |
| 元数据 | span + 指针位图(GC 用) | chunk 头(free 用) |
Cheat Sheet
| .text | r-x 代码;改写即 SIGSEGV |
| .rodata | 只读常量、字符串字面量 |
| .data | r-w,已初始化全局/静态,占文件 |
| .bss | r-w,未初始化或显式零,不占文件,载入零填 |
| heap | brk 管理,向高地址生长 |
| mmap 区 + stack | 共享库/大块映射;栈 8MB 上限、向低地址生长 |
static 只改可见性,不改段char *p = "hi":指针在 .data,字面量在 .rodata(写即崩);char a[] = "hi" 则复制到栈| 分配速度 | 栈 = 移动 rsp 一条指令(零成本);堆 = 搜索 + 可能的系统调用 |
| 生命周期 | 栈随函数返回结束;堆手动管理(可长可短,也可泄漏) |
| 容量 | 栈默认 8MB,越界踩 guard page → SIGSEGV;堆受限于地址空间与物理内存 |
| < 128KB → brk | 堆内切分,复用快;free 回池,RSS 通常不降 |
| ≥ 128KB → mmap | 独立映射;free 即 munmap,立即归还 |
| 分流理由 | 平衡"碎片 vs 系统调用税"两种成本 |
| malloc 给的 | 只是虚拟地址,首次写入触发缺页才占物理页 |
| ① 先排除爬坡 | 冷启动 demand paging 期间 RES 涨是正常的 |
| ② malloc_trim(0) | RSS 立降 → 分配器缓存不还(非泄漏);不降 → 看 heaptrack |
| ③ 看分配栈 | 稳定出现且只增不减 → 真泄漏;RES 高但活跃少 → 碎片 |
Interview QA · Part 1
先盖住答案自己答一遍,再展开对照——想不起来比看得顺眼记得牢;答不出的直接翻回速查页。
保留区(防 NULL 解引用)、.text(r-x)、.rodata、.data(已初始化全局)、.bss(未初始化全局,载入零填)、heap(brk,向上)、mmap 区(库/大块)、stack(8MB,向下)、内核空间。/proc/maps 可见。
已初始化全局/静态 → .data;未初始化或显式零 → .bss;字符串字面量 → .rodata(写即崩);局部变量与参数 → 栈;malloc/new → 堆。static 只改可见性不改段。
.data 存初始值占文件;.bss 只登记大小,加载时映射全零页(甚至共享 zero-page),运行时才占内存。所以二进制大小看 text+data,bss 决定运行时开销。
栈分配 = 移动栈指针一条指令,无搜索无元数据;深递归或超大局部数组超过 8MB 上限时触发 guard page → SIGSEGV。每线程独立栈也是线程数受限的原因之一。
堆从 .bss 之后向高长、栈从顶端向低长——两头向中间最大化可用空间,中间留给 mmap 区。这是设计取舍(把不确定容量的两段背对背),不是硬件限制。
栈/堆/mmap/库的基址随机偏移,使"写死地址"的攻击(ret2libc、ROP 依赖固定布局)失效。代价:可重现地址消失(调试器要 set disable-randomization)。现代发行版默认全开。
Interview QA · Part 2
同样建议先自答。这一页的题都需要"结论 + 一句代价/边界"才完整——只答结论会被追问。
<128KB 走 brk 扩堆切小块(复用快,free 回池不还 OS);≥128KB 走 mmap 独立映射(free 即 munmap 归还)。分流平衡"碎片 vs 系统调用税"。
小块回 ptmalloc 的 tcache/bins 复用池,RSS 通常不降;只有堆顶超阈值才 trim。大块(mmap)立即归还。观察:pmap -x 对比 free 前后;malloc_trim(0) 手动试还。
返回指针前面藏着块元数据(大小、标志位),free 向左偏移读取——这也是"越界写踩坏堆头"导致崩溃/堆溢出利用的根源。所以 free 传"中间指针"是未定义行为。
默认 ptmalloc;高并发小对象多换 tcmalloc;碎片敏感/要可观测换 jemalloc(Redis 官方推荐)。通用原则:heaptrack 证明分配是瓶颈 → LD_PRELOAD 替换 → A/B 对比 RSS 与 P99。
① 线程缓存(每线程无锁快路径);② 按大小分类(size class,自由链表 O(1));③ 页堆对接内核(批量批发)。Go 的 mcache/mcentral/mheap 同构——一句"批发零售"贯穿所有分配器。
内核分配器(Slab)优化自己的对象模式;用户进程的分配模式千差万别(高频小对象/大缓冲/池化),且每次分配走内核代价太高——把零售放在用户态,把批发留给系统调用,是分层设计的必然。
Related & References
虚拟内存 →(缺页/overcommit 是 malloc 的机制土壤)
页面置换 →(内存不够时另一侧的驱逐逻辑)
进程线程协程 →(线程栈上限与 goroutine 栈的对照)
Go 内存分配与逃逸分析 →(mheap/mcentral/mcache 全景)
三色标记与混合写屏障 →(Go 元数据为什么给 GC 用)
参考来源(本 deck 结论可溯源至下列一手材料)
| CSAPP ch.9.9(动态内存分配) | 隐式/显式空闲链表、碎片分析、分配器设计权衡 |
| glibc malloc 源码(malloc.c)与 man 3 mallopt | 128KB 阈值、tcache(2.26+)、fastbin 上限、trim 行为 |
| tcmalloc / jemalloc 官方设计文档 | thread cache + size class + page heap 三件套 |
| Go src/runtime/malloc.go / mem.go(本机 Go 版本) | arena 预留、span class、madvise 归还、GOMEMLIMIT |
| xiaolincoding.com《图解系统》malloc.html / linux_mem2.html | brk/mmap 分流实验与 free 归还行为验证 |
| Intel SDM Vol.3(NX/ASLR 语境) | 段权限位、栈保护机制 |