Theory · OS · Memory Layout & Heap

进程内存布局与堆分配

从 /proc/maps 的六段地图,到 malloc 的 brk/mmap 分流 —— 堆是用户态在虚拟内存上盖的一层"批发市场"

地图

text / data / bss / heap / mmap / stack——每段的权限、生长方向与典型问题各不相同

分流

malloc 小块走 brk(缓存复用)、大块走 mmap(释放即还)——128KB 阈值背后是碎片与成本的两难

批发

ptmalloc / tcmalloc / Go runtime 都在做同一件事:向内核批发内存、向应用零售对象

定位:OS 系列第九篇,内存三部曲之三。主线:"虚拟内存给地图,malloc 在堆上做批发零售"——收束前两篇的机制,落到分配器工程。

Why a Heap Allocator

先算笔账:如果每次 malloc 都直接找内核要内存

// 一个再普通不过的循环
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)
关键观察:内核是个只做批发的供货商——它以 4KB 页为最小单位发货,而且每次交易都要走昂贵的系统调用。应用要的是零售(几十字节、每秒百万次)。这两者之间必须有人做中间商。

没有"分段"会怎样

如果整个地址空间是一大块无差别的字节,那就无法区分"代码"(不该被改写,要能执行)和"数据"(要能改,但不能执行)。分段让操作系统能对每段分别设置权限——写只读段立刻 SIGSEGV,而不是让程序悄悄跑飞。/proc/maps 里每一行就是一条这样的规则。

没有"堆"会怎样

局部变量住在栈上,函数一返回就没了。要让数据活过函数返回、或者大小运行时才知道,就必须有一块手动管理的区域——这就是堆。它付出的代价是:谁来分配、什么时候释放、怎么避免碎片,全成了程序员与分配器的责任。

没有"分配器"会怎样

就是开场那笔账:系统调用税 + 页粒度浪费。分配器的全部价值 = 向内核批发、向应用零售、把释放的块留住复用。理解这一点,后面所有术语(arena / bin / size class)都是同一件事的不同说法。

本 deck 的路线

先看地址空间这张地图(六段各司其职)→ 再钻进(最快也最脆)→ 然后看 malloc 怎么向内核进货(brk / mmap 分流)→ 进分配器内部看仓库结构 → 最后落到排障与 Go 视角。

动机页:用"100 万次 malloc 直接找内核 = 4GB + 100 万次系统调用"这笔能自己算出来的账,讲清分配器存在的必然性(批发 vs 零售),再给出分段与堆各自解决了什么问题。

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

64 位进程的地址空间地图

开场那笔账里 malloc 要来的页,就落在这张地图的 heap 与 mmap 两段上。

64 位 Linux 进程虚拟地址空间布局 从低地址到高地址依次为:不可访问的保留区防止空指针、代码段只读可执行、只读数据、数据段存放已初始化全局变量、bss 段存放未初始化全局变量、堆向高地址生长、文件映射与匿名映射区、栈向低地址生长(默认 8MB 上限)、顶部是内核空间。各区域起点受 ASLR 随机化。 保留区(NULL 不可访问) .text 代码段 · r-x .rodata 常量 · 字符串字面量 .data 已初始化全局/静态 · r-w .bss 未初始化全局/静态 · 零填 heap 堆(brk 管理顶针) 向高地址生长 · malloc 批发市场 mmap 区(文件映射/匿名/共享库) 向下扩展 · 大块内存的居住区 stack 栈(默认 8MB 上限) 向低地址生长 · guard page 兜底 内核空间(用户不可见) 向高地址 向低地址 低地址 高地址 heap/mmap/stack 起点均被 ASLR 随机化 · cat /proc/self/maps 亲眼看

读图三问(面试高频)

权限位:.text 只读可执行(改代码 = SIGSEGV);.data/.bss 可写不可执行(NX 位)。② 生长方向:堆向高、栈向低,中间 mmap 区是缓冲。③ ASLR:栈/堆/mmap 起点随机,抬升攻击成本。

每个进程都独立一套

页表隔离让每个进程都"以为"独占这张地图——第一篇的抽象承诺在此兑现。共享库映射(libc)在 mmap 区,物理页全局只有一份。

32 位 vs 64 位(一句话版)

32 位:用户 3G/内核 1G,堆栈之间只有 3G 回旋——"内存不够"问题多为假象( fragmentation)。64 位:用户/内核各 128T,瓶颈转向真物理内存与页表开销。

面试金句:"/proc/maps 是进程内存的城市地图:地段(地址)、产权(权限)、生长方向都写死在 VMA 里——排障第一步就是来看地图。"
布局图是本篇主图:六段 + 内核区。三问(权限/方向/ASLR)覆盖所有常规追问。Go 进程的"多出来的大片 anon"是 runtime arena(第 10 页呼应)。

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 出来的
.bss 为什么不占文件:内容全为零,没必要在 ELF 里存零——只记大小,加载时由内核分配全零页(还能映射到共享的 zero-page 省物理内存)。这就是"text+data 决定文件大小,bss 只决定运行时内存"的原因。

ELF 加载视角

可执行文件按段(segment)映射进地址空间:代码段映射 .text/.rodata(r-x/r--),数据段映射 .data(r-w,文件兜底)与 .bss(匿名零页)。动态链接库 libc 映射进 mmap 区——多进程共享同一物理页。

字符串字面量的陷阱

char *p = "hi"; 的字面量在 .rodata(写它 = SIGSEGV);char a[] = "hi"; 把字面量复制到栈数组(可写)——一字之差,段位置不同,面试经典。

环境变量与 argv

住在栈底之上(进程启动时内核铺好)——这也是"栈顶往下就是命令行参数"的历史由来;改环境变量(setenv)可能移动它。

变量住址表是 C/OS 交叉题的弹药库。两个精确性考点:显式零进 bss(不占文件)、字面量在 rodata(写即崩)。ELF 视角把"段"与"映射"接起来。

Stack Frames · 8MB · Guard Page

栈:函数调用的机械,也是最容易崩的段

栈帧解剖

每次调用压一帧:返回地址 → 保存的 rbp(帧指针)→ 局部变量/寄存器保存区 → 实参溢出区。rsp 管栈顶、rbp 管帧底;leave/ret 恢复现场。递归深度 = 栈帧高度 × 每帧大小。

为什么快

分配 = rsp 移一条指令(无搜索、无元数据、天然 LIFO 与作用域对齐);释放 = 出函数即回收。局部变量首选栈——Go 的逃逸分析拼命把对象留在这里。

栈溢出与 guard page

默认栈上限 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 栈帧带指针位图的原因)
面试金句:"栈是编译器管理的零成本内存——快到极致,但容量与生命周期都被函数作用域焊死;堆的存在意义就是解开这两个约束。"
安全彩蛋:返回地址在栈上 → 栈溢出攻击的经典靶子。现代防御:栈不可执行(NX)+ canary 金丝雀值 + ASLR。
栈帧解剖 + "分配=移动 rsp"的零成本本质。8MB/guard page/递归崩溃是高频实验题。Go 连续栈对照(2KB + 复制扩容)为 Go 视角页铺垫。

malloc 的两条路 · 128KB

malloc 的分流:小块 brk,大块 mmap

malloc 不是系统调用

它是 C 库的内存批发商:向内核批发(brk/mmap),向应用零售对象。两条进货通道:brk() 把堆顶指针上移扩堆;mmap() 在 mmap 区开一块私有匿名映射。

128KB 阈值(glibc 默认)

请求 < 128KB → brk(堆内切分);≥ 128KB → mmap(独立映射)。为什么这么分:mmap 系统调用贵(建映射、清页、TLB 刷新)不适合频繁小块;brk 复用好但释放的内存滞留堆内(见下)。

free 之后去哪了

brk 小块:free 不还 OS,回内存池待复用(复用快、RSS 不降)——只有堆顶整块空闲时才可能收缩(mallopt M_TRIM_THRESHOLD)。mmap 大块:free 即 munmap,立刻归还 OS。malloc 返回的是虚拟内存,首次写入触发缺页才占物理页。

对比brk(<128KB)mmap(≥128KB)
位置堆(连续区间)mmap 区(独立映射)
系统调用频率低(批发一次切多次)每次分配一次
free 后回池复用,RSS 常不降munmap 立即归还
风险堆内碎片(洞难复用)大块频繁映射开销 + VMA 数量
碎片的两难(分流的存在理由):全用 brk → 连续 malloc/free 后堆里全是洞,valgrind 都测不出的"假泄漏";全用 mmap → 每个小对象都付一次系统调用税。128KB 是把两种税的中位成本做平的经验值(可 mallopt M_MMAP_THRESHOLD 调,新版 glibc 会动态自适应)。
面试金句:"malloc 的分流是碎片 vs 系统调用税的平衡点:小块忍受碎片换复用,大块忍受调用税换干净归还。"
128KB 阈值 + 两条路的成本表 + "free 后去哪"的实验结论(brk 不还、mmap 还)。"malloc 给虚拟内存"与第 7 篇 demand paging 无缝衔接。

Arena · Bin · Chunk

ptmalloc 内部:批发商的仓库结构

arena:仓库分区

全局锁是吞吐杀手 → ptmalloc 开多个 arena(主 arena 用 brk 堆;从 arena 用 64MB 子堆 mmap),线程绑定 arena 减少争抢(数量上限默认 8×核数)。仍不够就用 per-thread 缓存。

tcache:线程手边的小抽屉(glibc 2.26+)

每个线程 64 个单链 bin、各缓存 7 块(上限约 1KB):free 先进 tcache、malloc 先查 tcache——无锁快路径,绝大多数分配在这里完成。

bins:按大小分级摆架

fastbin(≤128B,LIFO 不合并,最快);small bin(精确大小,<1KB,FIFO);large bin(范围大小,best-fit);unsorted bin(free 的中转站,下次分配顺手找);top chunk(仓库最底的余货)。free 时相邻块合并成大块对抗碎片。

chunk 头

每块前有元数据头(大小、标志位;64 位典型 16B 对齐)——free 向左偏移即可读到块大小;这就是"越界写 1 字节可能踩坏堆元数据"的堆溢出原理。

结构大小带特点
tcache≤ ~1KBper-thread 无锁,各 7 块
fastbin≤ 128BLIFO,不合并,最快
small bin< 1KB精确匹配,FIFO
large bin≥ 1KB范围匹配,best-fit
unsorted bin中转free 先进,分配时翻检
top chunk余货不够时向内核扩堆
一条 free 的完整旅程:tcache 未满 → 进 tcache;满了 → 尝试合并邻居 → 按大小进 fast/small/large bin 或 unsorted;恰在堆顶 → 并入 top chunk,堆顶超阈值才 trim 归还 OS。
面试金句:"ptmalloc 的三级缓存 = tcache 手边抽屉 → bins 仓库货架 → 内核批发市场,越近越快越无锁,越远越慢越全局。"
三级结构(arena/tcache/bins)+ free 旅程。"RSS 不降"的根因在这页:小块 free 后滞留 tcache/bins,堆顶不 trim 不归还。

tcmalloc · jemalloc

替代分配器:多线程时代的重新设计

ptmalloc 的天花板

多核高并发下: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默认自带、通用
tcmallocthread cache + size class + page heap小对象吞吐、集成 TCM 治理
jemalloc多 arena + size class + 脏页治理碎片控制、可观测性
Go runtimemcache/mcentral/mheap(借 tcmalloc 思想)与 GC/栈调度一体化
思想同构(本篇的收束点):不管哪家,答案都是同一句话——向内核批发、向线程零售、按大小分仓。差别只在批发粒度、锁的分布与治理策略。
面试金句:"所有现代分配器 = 线程缓存消除锁 + 大小分类消除碎片搜索 + 页堆对接内核——背下这个三件套,ptmalloc/jemalloc/tcmalloc/Go 一次全答。"
"三件套"(线程缓存/大小分类/页堆)是全篇收束模型:ptmalloc、tcmalloc、jemalloc、Go runtime 全部落在同一坐标系。先测量后替换是工程纪律。

Leak · RSS Drift · Fragmentation

内存问题三件案:泄漏、RSS 不降、碎片

案件一:真泄漏

分配后引用丢失且永不 free。工具:valgrind memcheck(慢但准)、heaptrack(低开销、带分配栈)、ASan(编译期插桩,泄漏+越界一起抓)。判据:分配栈稳定出现且只增不减。

案件二:RSS 持续涨但"没有泄漏"

先想三层:① 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 vs alloc(Go 岗要点):Go 的 heap profile 里 inuse_space 是"此刻活着"(找泄漏),alloc_space 是"累计分配"(找分配热点/GC 压力)——两个指标搞混是排查失误第一名。
面试金句:"RES 涨不一定是泄漏:先排除正常爬坡与分配器缓存,再谈泄漏——malloc_trim 一试便知是案件几。"
三案件框架(泄漏/缓存不还/碎片)+ malloc_trim 分诊法。Go 的 inuse vs alloc 双指标是 Go 岗高频。工具链 valgrind/heaptrack/ASan 各一句定位。

Go Runtime as Allocator

Go 的堆:把"批发零售"做进 runtime

Go 为什么不用系统 malloc

GC 需要知道每个对象的元数据(span、类型、指针位图)才能扫;ptmalloc 的 chunk 头帮不上忙。所以 runtime 自建:mheap(页堆,对接 OS)→ mcentral(按 span class 中央仓)→ mcache(每 P 无锁缓存)——tcmalloc 三件套的 Go 版,但分级单位是 span(67/68 个大小类)。

栈 or 堆:逃逸分析

编译期决定:对象生命周期能被证明不超过函数 → 栈(零 GC 压力);被外部引用/接口装箱/大小未知 → 逃逸到堆。go build -gcflags=-m 看逃逸结论——"让对象留在栈上"是 Go 性能优化的第一原则。

与 OS 的三个接口

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/madvisebrk/mmap
元数据span + 指针位图(GC 用)chunk 头(free 用)
面试金句:"Go 堆是带 GC 视角的 tcmalloc:同样的三件套批发零售,只是元数据从'给 free 用'换成了'给 GC 用'。"
深挖链接:mcache/mcentral/mheap 的位级结构、tiny allocator、逃逸分析规则全集,见 Go 内存分配与逃逸分析 deck
对照表五层全对齐(mcache/tcache 等)。"元数据服务对象不同"(free vs GC)是 Go 与 C 分配器的本质差异一句话。逃逸分析是性能第一原则。

Cheat Sheet

一页带走:地图、住址、分流、三件套、分诊

① 六段地图(/proc/maps 逐行对照)

.textr-x 代码;改写即 SIGSEGV
.rodata只读常量、字符串字面量
.datar-w,已初始化全局/静态,占文件
.bssr-w,未初始化或显式零不占文件,载入零填
heapbrk 管理,向高地址生长
mmap 区 + stack共享库/大块映射;栈 8MB 上限、向低地址生长

② 变量住址速判

· 全局变量 → .data(有初值)/ .bss(无初值或 = 0)
· static 只改可见性,不改段
· char *p = "hi":指针在 .data,字面量在 .rodata(写即崩);char a[] = "hi"复制到栈
· 局部/参数 → 栈;malloc/new → 堆

③ 栈 vs 堆

分配速度栈 = 移动 rsp 一条指令(零成本);堆 = 搜索 + 可能的系统调用
生命周期栈随函数返回结束;堆手动管理(可长可短,也可泄漏)
容量栈默认 8MB,越界踩 guard page → SIGSEGV;堆受限于地址空间与物理内存

④ malloc 分流(glibc 默认 128KB)

< 128KB → brk堆内切分,复用快;free 回池,RSS 通常不降
≥ 128KB → mmap独立映射;free 即 munmap,立即归还
分流理由平衡"碎片 vs 系统调用税"两种成本
malloc 给的只是虚拟地址,首次写入触发缺页才占物理页

⑤ 分配器三件套(背这一句通吃四家)

线程缓存(无锁快路径)+ 按 size class 分类(O(1) 摘链)+ 页堆对接内核(批量批发)。
ptmalloc = arena + tcache + bins|tcmalloc = thread cache + central + page heap
jemalloc = 多 arena + size class|Go = mcache / mcentral / mheap

⑥ 内存问题分诊三步

① 先排除爬坡冷启动 demand paging 期间 RES 涨是正常的
② malloc_trim(0)RSS 立降 → 分配器缓存不还(非泄漏);不降 → 看 heaptrack
③ 看分配栈稳定出现且只增不减 → 真泄漏;RES 高但活跃少 → 碎片
一句话背下来:内核按页批发、分配器零售、free 只是退回货架——RSS 不降不等于泄漏
速查页:六块按使用顺序排——六段地图、变量住址、栈堆对照、malloc 分流、分配器三件套、问题分诊。回看时只看这一页。

Interview QA · Part 1

高频追问:布局与栈

先盖住答案自己答一遍,再展开对照——想不起来比看得顺眼记得牢;答不出的直接翻回速查页。

1 · 进程地址空间分几段?各干什么?

六段地图

保留区(防 NULL 解引用)、.text(r-x)、.rodata、.data(已初始化全局)、.bss(未初始化全局,载入零填)、heap(brk,向上)、mmap 区(库/大块)、stack(8MB,向下)、内核空间。/proc/maps 可见。

2 · 全局变量、静态变量、字面量、局部变量各在哪?

住址表

已初始化全局/静态 → .data;未初始化或显式零 → .bss;字符串字面量 → .rodata(写即崩);局部变量与参数 → 栈;malloc/new → 堆。static 只改可见性不改段。

3 · .bss 和 .data 的区别?为什么 bss 不占文件?

零页省文件

.data 存初始值占文件;.bss 只登记大小,加载时映射全零页(甚至共享 zero-page),运行时才占内存。所以二进制大小看 text+data,bss 决定运行时开销。

4 · 栈为什么分配快?栈溢出怎么发生?

移动 rsp

栈分配 = 移动栈指针一条指令,无搜索无元数据;深递归或超大局部数组超过 8MB 上限时触发 guard page → SIGSEGV。每线程独立栈也是线程数受限的原因之一。

5 · 为什么堆和栈相向生长?

两头向中间

堆从 .bss 之后向高长、栈从顶端向低长——两头向中间最大化可用空间,中间留给 mmap 区。这是设计取舍(把不确定容量的两段背对背),不是硬件限制。

6 · 什么是 ASLR?为什么需要?

地址随机化

栈/堆/mmap/库的基址随机偏移,使"写死地址"的攻击(ret2libc、ROP 依赖固定布局)失效。代价:可重现地址消失(调试器要 set disable-randomization)。现代发行版默认全开。

布局组六题:地图、住址表、bss、栈、生长方向、ASLR。第 5 题"两头向中间"是设计观题。

Interview QA · Part 2

高频追问:堆与分配器

同样建议先自答。这一页的题都需要"结论 + 一句代价/边界"才完整——只答结论会被追问。

7 · malloc 大于/小于 128KB 分别怎么走?为什么?

brk vs mmap

<128KB 走 brk 扩堆切小块(复用快,free 回池不还 OS);≥128KB 走 mmap 独立映射(free 即 munmap 归还)。分流平衡"碎片 vs 系统调用税"。

8 · free 之后内存还给 OS 了吗?

大概率没有

小块回 ptmalloc 的 tcache/bins 复用池,RSS 通常不降;只有堆顶超阈值才 trim。大块(mmap)立即归还。观察:pmap -x 对比 free 前后;malloc_trim(0) 手动试还。

9 · free 怎么知道该释放多大?

chunk 头

返回指针前面藏着块元数据(大小、标志位),free 向左偏移读取——这也是"越界写踩坏堆头"导致崩溃/堆溢出利用的根源。所以 free 传"中间指针"是未定义行为。

10 · ptmalloc/jemalloc/tcmalloc 怎么选?

先测后换

默认 ptmalloc;高并发小对象多换 tcmalloc;碎片敏感/要可观测换 jemalloc(Redis 官方推荐)。通用原则:heaptrack 证明分配是瓶颈 → LD_PRELOAD 替换 → A/B 对比 RSS 与 P99。

11 · 现代分配器共同的三件套?

缓存/分类/页堆

① 线程缓存(每线程无锁快路径);② 按大小分类(size class,自由链表 O(1));③ 页堆对接内核(批量批发)。Go 的 mcache/mcentral/mheap 同构——一句"批发零售"贯穿所有分配器。

12 · 内核为什么不管这些,要用户态分配器?

通才 vs 专才

内核分配器(Slab)优化自己的对象模式;用户进程的分配模式千差万别(高频小对象/大缓冲/池化),且每次分配走内核代价太高——把零售放在用户态,把批发留给系统调用,是分层设计的必然。

堆组六题:分流、free 去向、chunk 头、选型、三件套、分层理由。第 12 题把用户态分配器的存在必然性讲清(通才/专才 + 调用成本)。

Related & References

相关知识点与参考

OS 系列(本分类)

虚拟内存 →(缺页/overcommit 是 malloc 的机制土壤)
页面置换 →(内存不够时另一侧的驱逐逻辑)
进程线程协程 →(线程栈上限与 goroutine 栈的对照)

跨领域联动

Go 内存分配与逃逸分析 →(mheap/mcentral/mcache 全景)
三色标记与混合写屏障 →(Go 元数据为什么给 GC 用)

参考来源(本 deck 结论可溯源至下列一手材料)

CSAPP ch.9.9(动态内存分配)隐式/显式空闲链表、碎片分析、分配器设计权衡
glibc malloc 源码(malloc.c)与 man 3 mallopt128KB 阈值、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.htmlbrk/mmap 分流实验与 free 归还行为验证
Intel SDM Vol.3(NX/ASLR 语境)段权限位、栈保护机制
收尾:CSAPP ch9.9 是分配器设计正源,glibc 源码是 128KB/tcache 的一手出处。总页数 12。内存三部曲完成,下一篇进入文件系统。