内存布局
C/C++ 内存布局全景
从虚拟内存的分页机制,到可执行文件的段,到进程的地址空间,到对象在内存中的字节排布, 再到 STL 容器、分配器与性能调优 —— 一条线串起来。
适用范围:C/C++,以 Linux x86-64 + GCC/Clang 为主线,Windows/MSVC 的差异单独标注。
目录
| # | 章节 | 主题 |
|---|---|---|
| 一 | 虚拟内存基础 | 页、MMU、页表、TLB、缺页、惰性提交 |
| 二 | 两个视角 | section vs VMA |
| 三 | 编译链接视角:可执行文件的段 | .text / .data / .bss / .rodata … |
| 四 | 符号、链接与 ODR | 强弱符号、COMDAT、静态库/动态库、GOT/PLT |
| 五 | 进程地址空间 | 完整布局图、32 vs 64 位 |
| 六 | 静态存储区 | 两阶段初始化、初始化顺序、magic statics |
| 七 | 栈 | 栈帧、调用约定、alloca/VLA、爆栈 |
| 八 | 堆 | brk vs mmap、ptmalloc、碎片、对齐 |
| 九 | 内存映射与共享内存 | mmap 家族、共享内存上用 STL |
| 十 | 对象的内存布局 | 对齐、EBO、vptr、多继承、虚继承 |
| 十一 | 存储期与生命周期 | 四种存储期、TLS、临时对象、RVO |
| 十二 | STL 容器的内存组织 | 各容器内部结构与选型 |
| 十三 | 分配器 | 自定义 allocator、pmr、共享内存分配器 |
| 十四 | 智能指针的内存布局 | 控制块、make_shared 的一次分配 |
| 十五 | 缓存、局部性与性能 | cache line、false sharing、AoS vs SoA |
| 十六 | 内存安全机制 | ASLR / DEP / Canary / RELRO / CFI |
| 十七 | 常见内存错误与排查 | 越界、UAF、泄漏 + sanitizer |
| 十八 | 观测工具速查 | Linux / Windows 全套命令 |
| 十九 | 面试速答卡 | 高频问题浓缩答案 |
| — | 附录:术语表 |
一、虚拟内存基础
在谈任何"布局"之前,必须先接受一个事实:程序看到的地址全是假的。
虚拟地址 → 物理地址
进程 A 进程 B
┌────────┐ ┌────────┐
│0x400000│ │0x400000│ 两个进程的同一个地址
└───┬────┘ └───┬────┘
│ 页表 A │ 页表 B
▼ ▼
┌─────────────────────────────────┐
│ 物理内存(RAM) │ 映射到完全不同的物理页
└─────────────────────────────────┘
- 页(page):映射的最小单位,x86-64 上通常 4 KB,也支持 2 MB / 1 GB 的大页
- MMU:CPU 里的硬件单元,每次访存都自动做地址翻译
- 页表:翻译规则,x86-64 用四级页表(PML4 → PDPT → PD → PT),
CR3寄存器指向当前进程的页表根 - TLB:页表翻译的缓存。TLB miss 要走一遍页表(最多四次内存访问),所以TLB 命中率对性能影响巨大 —— 这也是大页(huge page)能提升数据库/JVM 性能的原因
页表项里的权限位
每个页表项除了物理页号,还有一组标志位:
| 标志 | 含义 |
|---|---|
| P (Present) | 该页是否在物理内存中 |
| R/W | 可写 |
| U/S | 用户态可否访问 |
| NX | 不可执行(DEP/NX 的硬件基础) |
| D (Dirty) | 被写过(换页时决定是否要写回) |
| A (Accessed) | 被访问过(页面置换算法用) |
这解释了很多现象:写字符串字面量崩溃(.rodata 页只读)、解引用空指针崩溃(第 0 页不 Present)、栈上代码不能执行(NX 位)。
惰性提交:分配 ≠ 占用物理内存
void* p = ; // 1 GB,立即成功
// 此刻物理内存占用:几乎为 0,只是建立了一段 VMA
; // ← 现在才真正开始消耗物理内存
首次写入某一页时触发缺页异常(page fault),内核这时才分配物理页并填进页表。所以:
| 指标 | 含义 |
|---|---|
| VSZ / Virtual Size | 虚拟地址空间大小 —— 只是"申请了多少",参考价值有限 |
| RSS / Resident Set Size | 真正驻留在物理内存的部分 —— 这才是关心的指标 |
| PSS | 按共享比例摊分后的 RSS(Linux 特有,统计多进程共享库更准) |
Linux 还允许 overcommit:可以答应超过物理内存 + swap 总量的申请,赌你不会全用。用光了就触发 OOM Killer。
缺页的两种类型
minor fault(次要缺页):物理页已在内存中(比如共享库已被别的进程加载),
只需在本进程页表里建立映射 —— 快
major fault(主要缺页):需要从磁盘读入(换页、文件映射首次访问)—— 慢几个数量级
Copy-on-Write(写时复制)
fork() 不复制内存,而是让父子进程共享所有物理页并全部标记为只读。任一方写入时触发缺页,内核才真正复制那一页。
这也是为什么 fork() 一个占用 10 GB 的进程几乎是瞬间完成的 —— 直到子进程开始大量写入。
二、两个视角:静态的文件 vs 运行的进程
谈"内存布局"时,很多混乱来自没分清两个层次:
| 编译链接视角 | 运行时视角 | |
|---|---|---|
| 单位 | 段(section) | 区域(region / VMA) |
| 名字 | .text .data .bss .rodata … |
代码区、数据区、堆、栈、mmap 区 |
| 谁定义 | 编译器 + 链接器 | 内核加载器 + 运行时库 |
| 存在于 | .o / 可执行文件 |
进程的虚拟地址空间 |
链接器把多个 .o 里同名的段合并,再按权限(读/写/执行)打包成若干 segment,内核按 segment 建立映射。所以 .text 和 .rodata 通常被塞进同一个只读可执行的映射里 —— 段不是一一对应到运行时区域的。
一句话:
.o 的 section --链接器合并--> 可执行文件的 segment --内核映射--> 进程的 VMA
三、编译链接视角:可执行文件的段
常见段一览
| 段 | 内容 | 占文件体积 | 运行时权限 |
|---|---|---|---|
.text |
机器指令 | ✓ | r-x |
.rodata |
常量、字符串字面量、跳转表 | ✓ | r-- |
.data |
初值非零的可变全局/静态变量 | ✓ | rw- |
.bss |
初值为零或未初始化的可变全局/静态变量 | ✗ | rw- |
.data.rel.ro |
需重定位的"逻辑只读"数据(虚表等) | ✓ | rw- → r--(RELRO 后) |
.init_array |
静态对象构造函数的指针表 | ✓ | r-- |
.fini_array |
析构函数指针表 | ✓ | r-- |
.got / .plt |
全局偏移表 / 过程链接表(动态链接) | ✓ | rw- / r-x |
.eh_frame |
异常展开信息 | ✓ | r-- |
.debug_* |
调试信息(-g) |
✓ | 不加载 |
.bss 为什么单独存在
纯粹为了省文件体积:
int big; // .bss —— 文件里只记一个长度
int big2 = ; // .data —— 文件里实打实躺着 4MB
.bss 在加载时由内核映射匿名零页,天然全零。这也是"未初始化的全局变量自动为 0"的实现原理 —— 不是编译器帮你写了 0,是这块内存本来就是零页。
对照表
int a; // .bss
int b = 0; // .bss(写不写 = 0 一样)
int c = 42; // .data
const int d = 42; // .rodata(且常被常量折叠掉,完全不占空间)
char e = "hi"; // .data —— 数组本体可写
const char* f = "hi"; // 指针 f 在 .data;"hi" 在 .rodata
static int g = 7; // .data(static 只影响链接性,不影响所在段)
void
一个经典 UB
char* p = "abc";
p = 'A'; // ✗ 写 .rodata → SIGSEGV
这也是 C++11 移除 const char* → char* 隐式转换的原因。
.data.rel.ro 与 RELRO
虚函数表、含指针的 const 全局对象,"逻辑上只读"但动态链接时需要写入重定位后的地址。链接器把它们放进 .data.rel.ro:
加载时可写 → 重定位完成 → mprotect 成只读
配合 -Wl,-z,relro,-z,now 就是 Full RELRO,防止攻击者覆写 GOT 表劫持控制流。
符号类型(nm 输出)
| 字母 | 含义 |
|---|---|
T / t |
.text 中已定义(大写=全局,小写=本文件可见) |
D / d |
.data 中已定义 |
B / b |
.bss 中已定义 |
R / r |
.rodata 中已定义 |
U |
未定义 —— 本文件要用,等链接器解决 |
W / V |
弱符号 —— inline 函数、模板实例,可被合并去重 |
U 和 W 是理解链接行为的钥匙,后面会反复用到。
四、符号、链接与 ODR
段决定了"数据放在哪",符号决定了"谁能找到谁"。这一节解释头文件、编译单元和链接器之间的实际关系。
编译的单位是 .cpp,不是 .h
.h 从来不会被单独编译。它只是一段文本,#include 就是把这段文本原样贴进 .cpp:
a.cpp + 它 include 的所有头 → a.o
b.cpp + 它 include 的所有头 → b.o
c.cpp + 它 include 的所有头 → c.o
三次编译互不知情,各自从零开始。推论:
#pragma once/ include guard 只防同一个 TU 内重复展开,不跨 TU。b.cpp 和 c.cpp 会各自完整展开一遍同一个头。a.h被谁 include 与a.cpp编译几次毫无关系。c.o不包含a.o的任何代码,只有一条"未定义符号 + 重定位记录"。
.o 之间是引用关系,不是包含关系
a.o: 0000 T foo T = Text,已定义,代码在本文件
c.o: U foo U = Undefined,"我要用它,但不知道在哪"
c.o 里 call foo 那条指令的目标地址是个空洞,附带一条重定位项:"链接器,请把这里填成 foo 的最终地址"。真正的复制只发生在两处:静态库链接进可执行文件时,以及下面说的 inline 去重。
强符号、弱符号与 COMDAT
头文件里带函数体的成员函数,会在每个 include 它的 TU 里真实编译一遍:
// a.h
;
b.cpp 和 c.cpp 各自完整地词法分析、语义检查、生成代码。两个 .o 里都有 A::add 的机器码。不冲突的原因是编译器给它打了特殊标记:
b.o: 0000 W _ZN1A3addEi W = Weak(弱符号)
c.o: 0000 W _ZN1A3addEi
这类函数被放进可合并的 COMDAT section(ELF 下是 .text._ZN1A3addEi,带 G 标志;COFF 下是 IMAGE_COMDAT_SELECT_ANY)。链接器见到多份同名 COMDAT 就任选一份保留、其余丢弃,最终程序里只有一份,取地址结果全局一致。
漏写 inline 的类外定义会直接链接失败:
// a.h
;
int // ✗ 强符号 T,b.o 和 c.o 各一份
// → error: multiple definition of `A::add(int)'
两种写法自动获得 inline:类内定义(隐式),类外定义加 inline(显式)。头文件里的非成员函数和全局变量同理 —— 需要 inline(C++17 起变量也能 inline)或 constexpr。
头文件里各种东西的链接行为
| 头文件里的东西 | 每个 TU | 链接后 | 全局地址唯一 |
|---|---|---|---|
inline 函数 / 类内定义成员函数 |
各编译一份(弱符号 W) |
合并为一份 | ✓ |
| 模板(函数模板、类模板成员) | 各自实例化 | 合并为一份 | ✓ |
constexpr / inline 变量 |
各一份 | 合并为一份 | ✓ |
static 函数 / 匿名 namespace |
各一份(局部符号 t) |
不合并 | ✗ 每个 TU 不同! |
| 非 inline 的普通函数定义 | 各一份(强符号 T) |
链接错误 | — |
static 那一行是个隐蔽的坑:头文件里写 static 函数或 static 变量,每个 include 它的 TU 都会得到一个独立副本。如果那是个计数器或缓存,不同 .cpp 看到的值会对不上。
ODR(One Definition Rule)
链接器"任选一份"的前提是所有副本完全相同。如果不同 TU 看到的定义不一致 —— 宏、编译选项、#pragma pack、include 顺序造成差异 —— 就是 ODR 违反,属于 IFNDR(ill-formed, no diagnostic required):
编译器不报错,链接器不报错,程序照常生成,但行为未定义。
这是 C++ 里最难查的一类 bug。典型场景:
// a.cpp
// b.cpp
// → 两边对同一对象的成员偏移理解不同 → 静默内存踩踏
对策:统一编译选项、把 #pragma pack 限制在最小范围、用 -flto 让链接器有机会发现类型不一致、开 -Wodr(GCC + LTO)。
静态库 vs 动态库
静态库 .a / .lib |
动态库 .so / .dll |
|
|---|---|---|
| 本质 | .o 文件的打包(ar 归档) |
一个可被加载的模块,自带符号表和重定位信息 |
| 链接时机 | 链接期,把用到的 .o 拷进可执行文件 |
运行期由动态链接器加载 |
| 磁盘占用 | 每个程序一份副本 | 全系统一份 |
| 内存占用 | 每进程一份 | 代码段跨进程共享(只读页) |
| 更新库 | 必须重新链接所有程序 | 替换文件即可 |
| 启动速度 | 快 | 慢(要解析符号、重定位) |
静态库的链接顺序敏感:链接器从左到右扫描,只在遇到未解决符号时才从库里拉 .o。所以 gcc -lfoo main.o 常常失败,gcc main.o -lfoo 才对。
GOT 与 PLT
动态库的代码要能被加载到任意地址(PIC),所以不能把外部符号的地址写死在指令里。解法是加一层间接:
GOT(Global Offset Table):数据的间接表,存放外部变量/函数的真实地址
PLT(Procedure Linkage Table):函数调用的跳板
**惰性绑定(lazy binding)**的流程:
第一次调用 printf:
call printf@plt
→ PLT[n] 跳到 GOT[n]
→ GOT[n] 初始指向 PLT[n] 的下一条指令(还没解析)
→ 跳到 PLT[0] → 调用 _dl_runtime_resolve
→ 解析出 printf 的真实地址,【写回 GOT[n]】
→ 跳过去执行
第二次调用:
call printf@plt → PLT[n] → GOT[n] 已是真实地址 → 直接跳
GOT 可写这件事正是攻击者的目标(覆写 GOT 劫持控制流),所以有了 Full RELRO:
代价是启动变慢(要一次性解析所有符号),换来 GOT 不可写。
减少编译时间的手段
头文件里放实现的真实代价是:解析和语义分析随 include 它的 TU 数量线性增长。注意未被使用的 inline 函数已经不会生成代码了(不 emit 符号),但解析和语义检查躲不掉(ODR 要求"未使用的函数也必须合法")。
按投入产出排序:
| 手段 | 效果 | 代价 |
|---|---|---|
| 实现移出头文件(只留声明) | 最直接 | 失去跨 TU 内联 → 用 -flto 补回 |
| Pimpl idiom | 切断依赖传递,改实现不触发重编译 | 一次间接、一次堆分配 |
前向声明代替 #include |
零成本 | 只适用于用到 T* / T& 的场合 |
extern template |
抑制重复实例化 | 需要一个 TU 集中实例化 |
| 预编译头(PCH) | 解析一次多处复用 | 头一变全量重建 |
| Unity build | 3–5 倍提速 | 破坏增量编译,符号易冲突 |
ccache / sccache |
切分支场景效果极好 | 需要缓存空间 |
| C++20 Modules | 根本解法:BMI 只解析一次 | 工具链成熟度仍在爬坡 |
// extern template:告诉编译器"别在这实例化,别处有"
// a.h
extern ;
// a.cpp
;
五、进程地址空间
典型 Linux x86-64 布局(地址从高到低):
0xFFFFFFFFFFFFFFFF ┌────────────────────────────┐
│ 内核空间 │ 用户态不可访问
0xFFFF800000000000 ├────────────────────────────┤
│ (巨大的空洞) │ 47 位有效地址
0x00007FFFFFFFFFFF ├────────────────────────────┤
│ 栈 │ ↓ 向低地址增长
│ ──────────────────────── │
│ (随机化的间隙) │ ASLR + stack_guard_gap
│ ──────────────────────── │
│ mmap 区 │ 共享库、大块 malloc、
│ │ 文件映射、线程栈
│ ──────────────────────── │
│ 堆 │ ↑ 向高地址增长(brk)
├────────────────────────────┤
│ .bss │
│ .data │ rw-
├────────────────────────────┤
│ .rodata │ r--
├────────────────────────────┤
│ .text │ r-x
0x0000555555554000 ├────────────────────────────┤ PIE 时基址随机
│ (不可访问) │ 捕获空指针解引用
0x0000000000000000 └────────────────────────────┘
关键点
① 地址空间是虚拟的。每个进程都以为自己独占整个空间,MMU 通过页表映射到物理页。两个进程的 0x400000 是完全不同的物理内存。
② 最低几页永远不映射。这就是解引用空指针必然崩溃的原因 —— 不是语言检查,是硬件缺页。
③ 栈和堆相向而行,中间是巨大的空洞。64 位下这个空洞大到不可能撞上(当年 32 位是真的会撞)。
④ 内存是惰性提交的。malloc(1GB) 成功不代表有 1GB 物理内存,只是建立了 VMA。真正的物理页在首次写入时才由缺页异常分配。Linux 的 overcommit 就是基于这个。
查看真实布局
Windows:
VMMap(Sysinternals)
!address(WinDbg)
六、静态存储区:初始化的两个阶段
静态存储期对象(全局变量、static 变量、类的静态成员)的初始化分两步,这是 C 和 C++ 差异的根源。
阶段一:静态初始化(代码执行前)
零初始化 → 所有静态存储期对象先被清零(.bss 天然为零)
常量初始化 → 初始值是编译期常量的,直接烘进 .data
这一步不执行任何代码,纯粹是文件内容 + 零页映射。
阶段二:动态初始化(需要跑代码)
int a = 42; // 常量初始化 → .data,阶段一完成
int b = ; // 动态初始化 → .bss 先清零,阶段二调用 rand()
std::string s; // 动态初始化 → 阶段二调用构造函数
C 语言根本不允许阶段二 —— 静态存储期对象的初始化式必须是常量表达式。所以 C 里写不了 static int x = rand();。C++ 允许,因为引入构造函数后"初始化"不再是往内存里填个值那么简单。
全局对象 vs 局部静态:懒不懒是关键区别
| 全局(命名空间作用域)对象 | 块作用域 static |
|
|---|---|---|
| 触发时机 | TU 被激活时,通常在 main 之前 |
控制流首次流经声明处 |
| 触发粒度 | 整个 TU 的所有对象一起 | 精确到这一个对象 |
| 没被用到 | 照样构造 | 永远不构造 |
| 线程安全 | 由启动阶段的单线程性保证 | C++11 起由守卫变量保证 |
;
A g; // main 之前构造,哪怕从没被用过
void // f() 不被调用,l 就永远不构造
标准允许把全局对象的动态初始化"推迟到该 TU 中任何非内联函数/变量首次 odr-use 之前",但主流实现对静态链接进映像的 TU 不使用这个许可 —— 启动代码遍历 .init_array 一次性全跑完。真正会推迟的是 dlopen / LoadLibrary 加载的模块。
static initialization order fiasco
跨 TU 的全局对象初始化顺序未定义:
// a.cpp
Logger logger;
// b.cpp
Config config; // 构造函数里用了 logger —— 可能还没构造!
标准解法就是利用局部静态的懒初始化(Meyers' Singleton):
Logger&
线程安全的守卫变量(C++11 magic statics)
块作用域静态变量的动态初始化保证线程安全,编译器展开成:
if
析构通过 __cxa_atexit 注册,程序退出时按构造完成的反序执行,与 atexit 注册的函数在同一条链上交错。
七、栈
栈帧
高地址
┌─────────────────────┐
│ 调用者的栈帧 │
├─────────────────────┤
│ 参数(超出寄存器) │
├─────────────────────┤
│ 返回地址 │ ← call 指令压入
├─────────────────────┤
│ 保存的 rbp │ ← push rbp
rbp → ├─────────────────────┤
│ Canary(如启用) │
├─────────────────────┤
│ 局部变量 │
│ ... │
├─────────────────────┤
│ 寄存器保存区 │
rsp → ├─────────────────────┤
│ (未使用) │
低地址 └─────────────────────┘
典型的函数序言/尾声:
push rbp
mov rbp, rsp
sub rsp, 64 ; ← "分配"就是这一条,64 是编译期算好的常量
...
mov rsp, rbp ; ← "释放"就是恢复 rsp,一条指令回收全部
pop rbp
ret
重要澄清:局部变量不是"编译期分配内存"。编译器只决定布局(谁在 rbp-4、谁在 rbp-8),实际分配发生在运行期执行 sub rsp, N 那一刻。而且开优化后,很多局部变量根本不落栈 —— 全程待在寄存器里。
调用约定
参数不一定都在栈上:
| ABI | 整型参数寄存器 | 浮点参数寄存器 |
|---|---|---|
| System V AMD64(Linux/macOS) | rdi, rsi, rdx, rcx, r8, r9 | xmm0–xmm7 |
| Microsoft x64(Windows) | rcx, rdx, r8, r9 | xmm0–xmm3 |
超出的部分才压栈。所以"形参在栈上开辟空间"只在 -O0(编译器把寄存器参数 spill 到栈上便于调试)时成立。
栈上的动态分配
栈的分配都发生在运行期,区别在于 sub rsp, N 里的 N 是编译期常量还是运行期变量:
void
; alloca(n) / VLA 的展开
mov rax, rdi
add rax, 15
and rax, -16 ; 16 字节对齐
sub rsp, rax ; ← 减的是运行期的值
用了 alloca/VLA 的函数必须保留帧指针(rbp),因为 rsp 不再可预测,-fomit-frame-pointer 自动失效。
alloca 和 VLA 的释放时机不同:
for
两者都不在 C++ 标准里。alloca 是编译器扩展(MSVC 叫 _alloca),VLA 是 C99 特性、C11 降为可选、C++ 从未纳入。
栈大小限制
| 平台 | 主线程 | 子线程 | 机制 |
|---|---|---|---|
| Linux | 8 MB(RLIMIT_STACK) |
默认同上 | 主线程惰性增长;子线程是固定大小的 mmap |
| Windows | 1 MB(PE 头 /STACK) |
同 PE 头或 CreateThread 指定 |
guard page 逐页提交 |
| macOS | 8 MB | 512 KB | — |
|
Windows 默认只有 1 MB,比 Linux 小 8 倍 —— 跨平台代码要特别注意。
爆栈
alloca 最危险的地方是无法检测失败:malloc 返回 NULL 可以处理,alloca 只是一条 sub rsp,不检查任何东西。
三种爆栈方式:
- 单次分配过大 →
sub rsp一步跨过 guard page - 循环累积(
alloca特有) - 递归过深 —— 不用
alloca也会爆
第 1 种最严重,因为它可能不崩溃:
┌──────────────┐
│ 某个 mmap │ ← 一步跨过来,落在这里
├──────────────┤
│ guard page │ ← 只有一页 4KB,被整个跳过
├──────────────┤
│ 栈 │
└──────────────┘
后续写入静默破坏相邻内存 —— 这就是 Stack Clash 类漏洞(CVE-2017-1000364 等),可用于本地提权。防护:
Windows 的 __chkstk 一直这么干,天然免疫(代价是大分配更慢)。
爆栈后的表现:Linux 是 SIGSEGV(想在信号处理函数里做事必须先 sigaltstack 准备备用栈);Windows 是 EXCEPTION_STACK_OVERFLOW(SEH 能捕获,但要调 _resetstkoflw() 重建 guard page)。基本等于进程死亡。
安全的替代方案
// MSVC:小走栈,大走堆
void* p = ;
;
// 通用:小缓冲区优化
;
std::string 的 SSO 就是这个思路的特例。
八、堆
两条获取内存的路径
malloc(小块) → 从 ptmalloc 维护的空闲链表切一块
→ 不够时 brk() 抬高堆顶,或 mmap() 新建一段
malloc(大块) → 直接 mmap(),free 时直接 munmap 还给内核
(glibc 默认阈值 128 KB,M_MMAP_THRESHOLD,会动态调整)
关键区别:brk 分配的内存只有在堆顶连续空闲时才能还给内核 —— 中间有一个还活着的小对象,整片就退不回去。这是"程序 RSS 只涨不跌"的常见原因。
glibc ptmalloc 的结构
Arena(每线程一个,减少锁竞争,上限 8×核数)
├─ tcache 每线程缓存,64 个 bin,每 bin 最多 7 块,无锁最快
├─ fastbin ≤ 128 字节,单链表,不合并(LIFO)
├─ unsorted bin 刚 free 的先扔这里,下次分配时顺路整理
├─ smallbin < 1024 字节,每个 bin 固定大小,双链表
├─ largebin ≥ 1024 字节,每个 bin 一个大小范围,按大小排序
└─ top chunk 堆顶那块,不够就 brk 扩张
每个 chunk 的头部记录大小和标志位(PREV_INUSE 等)。free 时相邻的空闲 chunk 会合并(coalescing),fastbin 是例外(为了快,不合并)。
这个结构也是堆利用(heap exploitation)的战场:tcache poisoning、fastbin dup、unlink attack 等,都是利用这些链表指针做文章。
其他常见分配器
| 分配器 | 特点 |
|---|---|
| ptmalloc2 | glibc 默认,通用,arena 机制减少锁竞争 |
| tcmalloc(Google) | 每线程缓存 + 中央堆,多线程小对象极快 |
| jemalloc(FreeBSD/Facebook) | size class 精细,碎片率低,可观测性强 |
| mimalloc(Microsoft) | 分片自由链表,性能与碎片俱佳 |
替换很简单,不用改代码:
LD_PRELOAD=/usr/lib/libtcmalloc.so
内存碎片
外部碎片:空闲空间总量够,但没有一块连续的够大
┌──┬────┬──┬──────┬──┐
│用│空闲│用│ 空闲 │用│ 总空闲 300B,但要 200B 连续 → 失败
└──┴────┴──┴──────┴──┘
内部碎片:分配器给的比你要的多
malloc(33) 实际给 48 字节(对齐 + chunk 头)
对策:对象池、arena/线性分配器、std::pmr、size class 归一化。
对齐
std::max_align_t // malloc 保证的对齐(x86-64 上是 16)
alignas ; // 手动指定
; // C11/C++17
operator new; // C++17 起 new 支持过对齐类型
多线程场景下,把不同线程频繁写的变量对齐到不同缓存行,可以消除 false sharing:
;
九、内存映射与共享内存
mmap 是 Linux 内存管理的万能工具:动态库加载、大块 malloc、文件 I/O、共享内存、线程栈,底层全是它。
mmap 的四个维度
void* ;
| 维度 | 取值 | 说明 |
|---|---|---|
| 有无后备文件 | fd >= 0 / MAP_ANONYMOUS |
文件映射 / 匿名映射 |
| 是否共享 | MAP_SHARED / MAP_PRIVATE |
写入对其他进程可见 / 写时复制 |
| 权限 | PROT_READ/WRITE/EXEC |
对应页表权限位 |
| 地址 | NULL / MAP_FIXED |
内核挑地址 / 强制指定(危险) |
四种组合的用途:
匿名 + 私有 → malloc 大块、线程栈、BSS
匿名 + 共享 → 父子进程共享内存
文件 + 私有 → 加载可执行文件和动态库(写时复制)
文件 + 共享 → 进程间共享内存、内存映射文件 I/O
内存映射文件
int fd = ;
struct stat st; ;
void* p = ;
// 之后像操作数组一样读写文件,内核负责换页和回写
; // 强制刷盘
;
相比 read/write 的优势:
- 省掉一次内核缓冲区 → 用户缓冲区的拷贝
- 随机访问大文件时不需要反复
lseek - 多进程映射同一文件自动共享页缓存
劣势:小文件不划算(建立映射有固定成本)、页错误不可预测(延迟抖动)、文件被截断后访问会 SIGBUS。
共享内存
POSIX:
int fd = ;
;
void* p = ;
;
// ...
; // ★ 必须显式删除,否则持续到系统重启
Windows:
HANDLE h = ;
void* p = ;
// Windows 是引用计数,最后一个句柄关闭即销毁
在共享内存上使用 STL:核心难点是指针
同一块共享内存,在不同进程里被映射到不同的虚拟地址:
进程 A: 段基址 0x7f0000000000 vector 内部存 begin = 0x7f0000001000
进程 B: 段基址 0x7f9000000000 ← A 存的那个绝对地址在这里毫无意义
STL 容器内部保存的是绝对指针,跨进程读取就是野指针。解法是自相对指针:
;
因为 this 和目标在同一个段里,相对距离在任何进程里都一样。
容器怎么知道要用它? 靠 allocator_traits::pointer。标准容器必须通过它而非裸 T* 定义内部成员,这类非原始指针叫 fancy pointer。libstdc++ / libc++ 支持得不错,MSVC STL 支持不完整。
务实方案:Boost.Interprocess
;
using SegMgr = bip::managed_shared_memory::segment_manager;
using ShmAlloc = bip::allocator<T, SegMgr>;
using ShmString = bip::basic_string<char, std::char_traits<char>, ShmAlloc<char>>;
using ShmVector = bip::vector<int, ShmAlloc<int>>;
// —— 进程 A:创建并写入 ——
;
bip::managed_shared_memory ;
auto* v = seg.;
v->;
// —— 进程 B:打开并读取 ——
bip::managed_shared_memory ;
auto = seg.;
if std::cout << ; // 42
construct<T>("name") 把对象注册进段内目录,另一个进程用 find<T>("name") 取回 —— 解决了"入口指针怎么传"的问题。
同步:互斥量本身也必须放在共享内存里,且是 process-shared 的:
;
bip::scoped_lock<bip::interprocess_mutex> ;
共享内存的坑清单
| 坑 | 说明 |
|---|---|
| 绝对指针 | 所有跨进程可见的指针必须是 offset_ptr |
| 虚函数 | vptr 指向进程私有的 vtable → 不能放多态对象 |
std::string / std::vector 成员 |
默认用 std::allocator,指向进程私有堆 |
函数指针 / typeid 指针 |
ASLR 下不同进程地址不同 |
| 锁泄漏 | 持锁进程崩溃 → 死锁。用 robust mutex 或带超时的锁 |
| 段的生命周期 | POSIX 下持续到重启,必须 shm_unlink;Windows 是引用计数 |
| ABI 一致性 | 不同编译器/优化选项/#pragma pack → 布局不同 → 静默数据损坏。加版本号 + static_assert(sizeof(X) == N) |
MAP_FIXED 不可靠 |
ASLR 让"哪个地址一定空着"不可预测,硬指定可能撞上已有映射 |
关于 ASLR 的精确说法
"共享内存里不能存指针"的原因是两条叠加:
| 场景 | 同一个 vtable 在两个进程里地址相同吗 |
|---|---|
| 两个无关进程,PIE 二进制 | 不同(可执行文件基址被随机化) |
两个无关进程,-no-pie |
主程序里的 vtable 相同;共享库里的不同 |
fork 出的父子进程 |
相同(完整继承地址空间布局) |
- ASLR —— 即使同一二进制,不同进程的模块基址也不同
- 共享内存段本身的映射地址不同 —— 与 ASLR 无关也会不同
十、对象的内存布局
对齐与填充
规则:每个成员的偏移必须是其对齐要求的整数倍;结构体整体大小必须是最大成员对齐的整数倍。
;
;
按大小降序排列成员通常能省下可观的空间。一个百万级对象数组,24 → 16 字节意味着少 8 MB。
static_assert;
static_assert;
alignof; // 8
#pragma pack(1) 可以取消填充,但代价是非对齐访问:x86 上慢,ARM 上可能直接 SIGBUS。只在解析二进制协议时用,且用 memcpy 而非直接解引用。
位域
; // 通常 4 字节
位域的布局(分配顺序、跨边界行为)是实现定义的,不同编译器/字节序结果不同。不要用它做跨平台的协议解析。
空类与空基类优化(EBO)
;
sizeof; // 1 —— 保证不同对象有不同地址
;
sizeof; // 8(e 占 1 + 3 填充)
;
sizeof; // 4 —— EBO:空基类不占空间
这就是为什么 std::vector 用继承而不是成员持有无状态分配器:EBO 让 sizeof(vector<int>) 保持 24 而不是 32。C++20 起可以用 [[no_unique_address]] 对成员达到同样效果:
;
sizeof; // 4
单继承与虚函数表
;
;
Derived 对象(64 位,sizeof == 24)
偏移 0 +--------------+
| vptr | ---> Derived 的 vtable ---> [ offset-to-top ]
偏移 8 +--------------+ [ RTTI 指针 ]
| a (int) | [ &Derived::f ]
偏移 12 +--------------+
| b (int) |
偏移 16 +--------------+ + 4 填充 = 24
要点:
- vptr 每个对象一份,vtable 每个类一份(放在
.data.rel.ro) - 单继承时基类子对象在偏移 0,所以
Derived*转Base*是零成本的指针复用 - vtable 里还有 RTTI 指针和 offset-to-top,供
dynamic_cast使用 - 构造/析构期间虚函数不多态:每个阶段完成后编译器更新 vptr,基类构造函数里调虚函数只会调到基类版本
多继承
;
;
;
C 对象(sizeof == 32)
偏移 0 +--------------+ <- A 子对象,也是 C* 的地址
| vptr (A) |
| a |
偏移 16 +--------------+ <- B 子对象
| vptr (B) |
| b |
偏移 24 +--------------+
| c |
+--------------+
后果:
C* pc = new C;
B* pb = pc; // 指针值【会变】!自动加上 16
;
调用 pb->g() 时 this 指向偏移 16,但 C::g 的实现需要完整的 C*。解决办法是 vtable 里放 thunk(跳板代码):先把 this 减回来,再跳到真正的实现。
虚继承
;
;
;
;
虚基类 V 在 D 里只有一份。代价是:
- 每条虚继承路径要多一个 vbptr(虚基类表指针),或在 vtable 里存 vbase offset
- 访问
v需要运行期查表得到偏移,不再是编译期常量 - 对象变大、访问变慢
以及最关键的语义规则:虚基类由最派生类直接构造,中间类初始化列表里对虚基类的调用被忽略。
; // V(1) 被忽略
; // 只有这个生效
这意味着虚基类若没有默认构造函数,每一个最派生类都必须显式初始化它。
构造与析构顺序
1. 虚基类 -- 按继承 DAG 深度优先、左到右的首次出现顺序,由最派生类构造
2. 直接非虚基类 -- 按 base-specifier-list 的【声明顺序】
3. 非静态数据成员 -- 按类定义中的【声明顺序】(不是初始化列表的书写顺序!)
4. 构造函数体
析构:严格逆序
第 3 条是高频陷阱:
;
GCC/Clang 会给 -Wreorder 警告。
内存布局的观测
十一、存储期与生命周期
| 存储期 | 触发 | 位置 | 释放 |
|---|---|---|---|
| 静态 | 全局、static、类静态成员 |
.data / .bss |
程序结束(反序析构) |
| 线程 | thread_local |
TLS 块 | 线程结束 |
| 自动 | 局部非 static 变量 | 栈 | 离开作用域 |
| 动态 | new / malloc |
堆 | delete / free |
thread_local 的存储
每个线程一份独立副本,放在 TLS(Thread Local Storage) 块里,通过 fs: / gs: 段寄存器访问:
mov rax, fs:[0xfffffff8] ; 访问 thread_local 变量
有两种模型:静态 TLS(主程序和启动时加载的库,偏移编译期确定,最快)和动态 TLS(dlopen 的库,需要经过 __tls_get_addr,慢很多)。
thread_local 对象的构造析构与局部静态类似:首次使用时构造,线程退出时析构。
临时对象的生命周期
const std::string& s = ; // 生命周期延长到 s 的作用域结束
但延长不传递:
const std::string&
C++17 起 prvalue 不再是"临时对象",而是"初始化对象的配方",只在需要时才物化成 xvalue。后果是 RVO 从"允许优化"变成"语言保证":
;
NoCopyNoMove
NoCopyNoMove x = ; // C++17 合法:全程只有一个对象
所以 return local; 不要写成 return std::move(local); —— 那会阻止 NRVO,把零成本变成一次移动。
常见的生命周期错误
int* // 返回栈上对象的地址
auto& r = v; v.; r = 2; // 容器扩容后引用失效
std::string_view sv = ; // 临时 string 立即销毁,sv 悬垂
delete p; p->; // use-after-free
delete p; delete p; // double free
-fsanitize=address,undefined 能抓住绝大多数。
十二、STL 容器的内存组织
std::vector —— 单块连续
vector 对象(栈上或作为成员,sizeof == 24)
+--------+--------+----------------+
| begin | end | end_of_storage |
+---|----+---|----+-------|--------+
| | |
v v v
+--+--+--+--+--+--+--+--+ 堆上一整块
|# |# |# |# |. |. |. |. |
+--+--+--+--+--+--+--+--+
size() = 4 capacity() = 8
- 默认构造零堆分配,
size() == capacity() == 0,三个指针全空 - 增长因子:libstdc++/libc++ 2×,MSVC 1.5×(标准只要求摊还 O(1))
- 扩容会移动全部元素,所有迭代器/指针/引用失效
- 元素的移动构造要标
noexcept,否则扩容时move_if_noexcept会退化成拷贝 pop_back不还内存;shrink_to_fit是非约束性请求vector<bool>是按位存储的特化,operator[]返回代理对象,不是真正的容器
std::deque —— 分段连续
map(T** 指针数组,自身连续,两端留空槽)
+------+------+------+------+------+------+
| null | * | * | * | null | null |
+------+--|---+--|---+--|---+------+------+
| | |
v v v
+--------++--------++--------+
|..#### ||########||##......| 等长缓冲区
+--------++--------++--------+
^ ^
start finish
迭代器携带四个指针:cur / first / last / node(指回 map 槽位),sizeof == 32。
随机访问 O(1),但需要两次间接寻址:
off = n + ;
return *;
最重要的性质 —— 两端插入时的非对称失效:
| 操作 | 迭代器 | 指针 / 引用 |
|---|---|---|
| 两端插入 | 全部失效 | 仍然有效 ✓ |
| 中间插入 | 全部失效 | 全部失效 |
pop_front / pop_back |
仅被删元素 + end() |
仅被删元素 |
| 中间 erase | 全部失效 | 全部失效 |
原因:两端插入可能导致 map 重新居中或扩容,迭代器里的 node 指针悬垂 —— 但元素本身从未搬家。这正是 std::stack / std::queue 默认用 deque 的理由。
缓冲区大小差异极大:libstdc++ 512 字节,libc++ 4096 字节或 16 个元素,MSVC 在 sizeof(T) > 8 时每块只放 1 个元素(性能陷阱)。
std::list / forward_list —— 完全离散
+------+------+------+ +------+------+------+
| prev | next | T | <-> | prev | next | T |
+------+------+------+ +------+------+------+
每个元素额外 16 字节(双向)或 8 字节(单向)指针开销,节点分散在堆上 —— 缓存表现最差。换来的是:任何插删都是 O(1),且除被删元素外所有迭代器/引用永久有效。
std::map / set —— 红黑树
;
每个元素约 32 字节额外开销。有序、O(log n)、迭代器稳定(除被删元素)。
std::unordered_map / set —— 链地址法哈希表
bucket 数组(连续)
+----+----+----+----+----+
| * |null| * |null| * |
+-|--+----+-|--+----+-|--+
v v v
节点->节点 节点 节点->节点->节点
- 标准要求引用在 rehash 后依然有效 → 只能用链地址法,不能用开放寻址
- 这也是标准
unordered_map性能不如absl::flat_hash_map/tsl::robin_map的根本原因 load_factor超过max_load_factor(默认 1.0)触发 rehash- rehash 使迭代器失效,引用不失效
std::string —— SSO
短字符串(libstdc++,<= 15 字符):
+--------+--------+----------------+
| ptr | size | 内嵌缓冲区 | ptr 指向自身缓冲区,零堆分配
+--------+--------+----------------+
长字符串:
+--------+--------+----------------+
| ptr --+-> 堆 | capacity |
+--------+--------+----------------+
| 实现 | sizeof(std::string) |
SSO 容量 |
|---|---|---|
| libstdc++ | 32 | 15 字符 |
| libc++ | 24 | 22 字符 |
| MSVC | 32 | 15 字符 |
libc++ 用最高位做标志位,把 24 字节几乎全用来存字符,SSO 容量更大。
选型速查
| vector | deque | list | map | unordered_map | |
|---|---|---|---|---|---|
| 随机访问 | O(1) 最快 | O(1) 两次寻址 | ✗ | ✗ | ✗ |
| 头部插删 | O(n) | O(1) | O(1) | — | — |
| 尾部插删 | 摊还 O(1) | O(1) | O(1) | — | — |
| 中间插删 | O(n) | O(n) | O(1) | O(log n) | O(1) 均摊 |
| 查找 | O(n) | O(n) | O(n) | O(log n) | O(1) 均摊 |
| 扩容搬元素 | 要 | 不要(只搬指针) | — | — | rehash 时 |
| 每元素开销 | 0 | 极小 | 16 字节 | ~32 字节 | ~8–16 字节 |
| 缓存友好 | 最好 | 中等 | 最差 | 差 | 中等 |
| 连续内存 | ✓ | ✗ | ✗ | ✗ | ✗ |
默认用 vector。元素数量在几百以内时,vector 的线性查找往往比 map / unordered_map 还快 —— 缓存局部性的威力常常压过渐进复杂度。
十三、分配器
最小自定义 allocator(C++11 起)
allocator_traits 会补齐绝大部分样板,你只需提供四样东西:
;
bool
bool
allocator_traits 替你补了什么
| 成员 | 默认推导 |
|---|---|
pointer |
T* |
const_pointer |
pointer_traits<pointer>::rebind<const T> |
size_type / difference_type |
size_t / ptrdiff_t |
rebind_alloc<U> |
把模板第一个参数换成 U |
construct / destroy |
placement new / 显式析构 |
max_size() |
SIZE_MAX / sizeof(T) |
select_on_container_copy_construction |
返回自身副本 |
propagate_on_container_* |
全部 false_type |
C++03 时代那一大堆 typedef 和 rebind 结构体现在都不用写了。
rebind 为什么必要
std::list<T> 从不分配 T,它分配的是 Node<T>:
;
using NodeAlloc = std::allocator_traits<Alloc>::template rebind_alloc<Node>;
只要 allocator 写成 template<class T> class MyAlloc,allocator_traits 就能自动推出 rebind。只有模板参数不止一个且第一个不是 value_type 时,才需要手写 rebind。 上面 ② 那个跨类型转换构造函数就是配合 rebind 用的。
有状态 allocator
C++11 起才被正式支持。关键是 operator== 的语义:
a == b为真 ⟺a分配的内存可以由b释放
;
;
三个 propagate_on_container_* 决定容器做拷贝赋值 / 移动赋值 / swap 时 allocator 要不要跟着走。如果 allocator 有状态而没设 propagate_on_container_move_assignment,移动赋值在两个 allocator 不相等时会退化成逐元素移动 —— 性能会莫名其妙掉下来。
痛点:allocator 是类型的一部分
std::vector<int> a;
std::vector<int, ArenaAlloc<int>> b;
a = b; // ✗ 不同类型,函数签名、ABI 全不兼容
这会污染整个接口。C++17 的 std::pmr 就是来解决它的 —— 把多态推迟到运行期:
char buf;
std::pmr::monotonic_buffer_resource ;
std::pmr::vector<int> ; // 类型是 vector<int, polymorphic_allocator<int>>
void ; // 所有 pmr 容器类型统一,可互相赋值
标准提供的 memory resource:
| resource | 特点 |
|---|---|
monotonic_buffer_resource |
只分配不回收,析构时整体释放。最快,适合请求级/帧级生命周期 |
unsynchronized_pool_resource |
按 size class 分池,单线程 |
synchronized_pool_resource |
同上,加锁,多线程安全 |
new_delete_resource() |
默认,转发给 operator new/delete |
null_memory_resource() |
任何分配都抛异常 —— 用来断言"这里不该分配" |
代价:每次 allocate 多一次虚调用。但注意 pmr 用的是裸指针,不支持 fancy pointer,所以它能用于共享内存的唯一前提是所有进程映射到同一地址 —— 在 ASLR 下不可靠。
一个典型的 pmr 用法:请求级 arena
void // arena 析构,一次性释放,不逐个 free
对于短生命周期、大量小对象的场景(HTTP 请求处理、编译器的一趟 pass、游戏的一帧),这个模式能带来数量级的提升。
共享内存 allocator 骨架
;
;
;
生产级还需要:空闲链表 / 伙伴系统(上面只是 bump 分配,不能回收)、命名对象目录、崩溃恢复、段扩容。所以实际项目建议直接用 Boost.Interprocess。
全局替换 operator new
另一条路是不动容器,直接换掉全局分配:
void* operator new
void operator delete noexcept
void operator delete noexcept // sized delete
或者更简单,运行时替换整个 malloc 实现:
LD_PRELOAD=/usr/lib/libtcmalloc.so
LD_PRELOAD=/usr/lib/libjemalloc.so
多线程小对象密集的服务,换 tcmalloc / jemalloc / mimalloc 常常有 10–30% 的提升,且不用改一行代码。
十四、智能指针的内存布局
std::unique_ptr —— 零开销
sizeof; // 8 —— 就是一个裸指针
sizeof; // 16 —— 函数指针型删除器占空间
sizeof; // 8 —— 无状态删除器靠 EBO 消失
默认删除器 std::default_delete<T> 是空类,通过 EBO(或 [[no_unique_address]])不占空间。所以 unique_ptr 是真正的零成本抽象:布局和裸指针一样,生成的代码也一样。
教训:删除器优先用无状态的函数对象(lambda 类型、仿函数),别用函数指针。
auto del = ;
std::unique_ptr<FILE, decltype> ; // 8 字节
std::shared_ptr —— 两个指针 + 一个控制块
sizeof; // 16 —— 两个指针
sizeof; // 16
shared_ptr 对象 控制块(堆上)
+----------------+ +----------------------+
| ptr ---------+------------> | 强引用计数 (atomic) |
+----------------+ | 弱引用计数 (atomic) |
| ctrl ---------+------------> | 删除器 |
+----------------+ | 分配器 |
| [对象本身] | ← 仅 make_shared
+----------------------+
为什么要两个指针?因为 ptr 和"控制块管理的对象"可以不是同一个:
;
auto sp = std::make_shared<Big>;
std::shared_ptr<int> ; // 别名构造:共享控制块,但指向成员
// px 让整个 Big 保持存活,自己却只暴露那个 int
make_shared vs shared_ptr<T>(new T)
std::shared_ptr<T> ; // 两次堆分配:对象一次,控制块一次
auto b = std::make_shared<T>; // 一次堆分配:对象和控制块相邻
shared_ptr<T>(new T) |
make_shared<T>() |
|
|---|---|---|
| 堆分配次数 | 2 | 1 |
| 缓存局部性 | 差(两块分开) | 好(相邻) |
| 异常安全 | f(shared_ptr<T>(new T), g()) 有泄漏窗口 |
安全 |
| 内存释放时机 | 对象析构即释放对象内存 | 只有最后一个 weak_ptr 也没了才释放 |
| 自定义删除器 | 支持 | 不支持(要用 allocate_shared) |
最后一行是 make_shared 唯一的缺点:因为对象和控制块在同一块内存里,只要还有 weak_ptr 活着(控制块必须留着),对象那块内存就还不了。对大对象 + 长期 weak_ptr 的场景要留意。
引用计数的原子开销
强引用计数是 std::atomic,拷贝 shared_ptr 会产生原子递增 —— 多线程高频拷贝时这是真实的竞争点。
void ; // ✗ 每次调用一次原子增 + 一次原子减
void ; // ✓ 不改引用计数
void ; // ✓✓ 最好:函数不关心所有权就别收智能指针
准则:只有需要参与所有权的接口才收 shared_ptr;只是用一下对象,就收 T& 或 T*。
enable_shared_from_this
;
基类里藏了一个 weak_ptr<Widget>,shared_ptr 构造时会检测到并回填。所以 sizeof(Widget) 会多 16 字节,且必须先由 shared_ptr 管理才能调 shared_from_this()(C++17 起未被管理时抛 bad_weak_ptr)。
循环引用
;
解法:一个方向用 weak_ptr。通常是"父持有子用 shared_ptr,子指回父用 weak_ptr"。
十五、缓存、局部性与性能
内存布局最终要落到性能上,而性能的关键在缓存 —— 不是"访问了多少内存",而是"访问模式对不对"。
存储层次与延迟
| 层级 | 容量(典型) | 延迟(周期) | 相对代价 |
|---|---|---|---|
| 寄存器 | 数百字节 | 0 | 1× |
| L1 缓存 | 32–48 KB | ~4 | 4× |
| L2 缓存 | 512 KB–2 MB | ~12 | 12× |
| L3 缓存 | 8–64 MB(共享) | ~40 | 40× |
| 内存 | GB 级 | ~200–300 | ~200× |
| NVMe SSD | TB 级 | ~10⁵ | 100000× |
一次 L1 命中和一次内存访问差 50 倍。这就是为什么"O(n) 的连续遍历常常打败 O(log n) 的指针追逐"。
缓存行
数据以 cache line 为单位在层级间移动,x86-64 上是 64 字节。
std::hardware_destructive_interference_size // C++17,通常 64
推论:读一个 int 的成本 ≈ 读 16 个连续 int 的成本。
顺序 vs 随机访问
// 行优先遍历:每个 cache line 被完整利用
for
for sum += m; // 快
// 列优先遍历:每次访问都是新的 cache line
for
for sum += m; // 慢 5–10 倍
同样的算法复杂度,同样的指令数,差别全在访存模式。
False Sharing(伪共享)
两个线程写不同的变量,但它们在同一个 cache line 上 —— 缓存一致性协议(MESI)会让这条 line 在核间反复弹跳:
;
;
实测差距可以到 10 倍以上。诊断:
&&
AoS vs SoA
// Array of Structs —— 直观,但遍历单个字段时浪费带宽
;
std::vector<Particle> ps;
for p.x += p.vx; // 每个 cache line 只有 1/8 有用
// Struct of Arrays —— 遍历单字段时带宽利用率 100%,且可自动向量化
;
for ps.x += ps.vx; // SIMD 友好
这是**数据导向设计(Data-Oriented Design)**的核心思想,游戏引擎的 ECS 架构就建立在此之上。
成员排序省内存
; // 24 字节
; // 16 字节
百万级数组上,这是 8 MB 的差别 —— 也直接意味着少 8 MB 的内存带宽和缓存压力。
容器选择的实际影响
对于几百个元素以内的集合:
vector 的线性查找 常常快过 map 的 O(log n)
vector 的线性查找 常常快过 unordered_map 的 O(1)
因为 vector 是一次连续扫描(硬件预取器可以完美预测),而树和哈希表是指针追逐(每次都是 cache miss)。渐进复杂度在小规模下经常输给常数因子。
经验阈值(仅供参考,务必实测):
- < 32 个元素:
vector+ 线性查找几乎总是最快 - 32–1000:视元素大小和比较代价而定
- > 1000:哈希表/树的复杂度优势开始体现
预取
; // GCC/Clang
; // Intel intrinsic
硬件预取器能自动识别顺序和固定步长的访问模式。手动预取只在访问模式不规则但可预测时(如链表遍历、哈希表探测)才有价值,而且很容易适得其反 —— 必须实测。
大页(Huge Pages)
4 KB 页意味着 1 GB 数据需要 262144 个页表项,TLB 装不下。2 MB 大页把这个数字降到 512。
# 透明大页(THP),自动
# 显式大页
;
;
对大堆的数据库、JVM、科学计算通常有 5–20% 提升。副作用是内存放大和分配延迟抖动。
NUMA
多路服务器上,访问本地节点的内存比跨节点快 1.5–2 倍:
C++ 层面没有标准 API,需要用 libnuma 或依赖操作系统的 first-touch 策略(哪个线程第一次写某页,那页就分配在该线程所在节点)。
优化的正确顺序
1. 测量(perf / VTune)——【绝不跳过这一步】
2. 改进算法与数据结构
3. 改进内存布局(AoS→SoA、成员排序、减少间接层)
4. 减少分配次数(arena / pmr / reserve)
5. 消除 false sharing
6. 向量化(自动 or 手写 SIMD)
7. 手动预取、大页、NUMA 绑定
&&
十六、内存安全机制
现代二进制加固是一套组合拳,单独任何一项都能被绕过。
全景
| 机制 | 防什么 | 谁实现 | 开启方式 |
|---|---|---|---|
| DEP / NX | 执行注入的 shellcode | CPU(NX 位)+ OS | 默认,-z noexecstack |
| ASLR | 复用已有代码(ROP / ret2libc) | 编译器 + OS | -fPIE -pie / /DYNAMICBASE |
| Stack Canary | 栈溢出覆盖返回地址 | 编译器 | -fstack-protector-strong |
| RELRO | 覆写 GOT 劫持控制流 | 链接器 + OS | -Wl,-z,relro,-z,now |
| FORTIFY_SOURCE | 已知长度的缓冲区溢出 | 编译器 + libc | -D_FORTIFY_SOURCE=2 -O2 |
| Stack Clash Protection | 一步跨过 guard page | 编译器 | -fstack-clash-protection |
| CFI / CFG | 间接跳转到任意地址 | 编译器 + OS | -fsanitize=cfi / /guard:cf |
| Shadow Stack / CET | ROP(硬件级) | CPU + OS | -fcf-protection=full |
DEP / NX
页表里的 NX 位让数据页不可执行。攻击者注入的 shellcode 落在栈或堆上 —— 都是数据页,跳过去直接崩。
绕过方式:不注入代码,改为复用已有代码 → return-to-libc、ROP。于是有了 ASLR。
ASLR
每次进程启动,OS 随机化各内存区域的基址:
| 区域 | 是否随机化 |
|---|---|
| 栈 | ✓ |
| 堆(brk) | ✓ |
| mmap 区(含所有共享库) | ✓ |
| 可执行文件自身 | ✓ 仅当编译成 PIE 时 |
需要编译器和 OS 协作,缺一不可:
编译器/链接器:生成位置无关代码,并在文件头打上"我支持被随机加载"的标记
↓
操作系统加载器:读到标记,加载时掷骰子选随机基址
编译器单方面做不到(随机基址是运行时才决定的);OS 单方面也做不到(写死绝对地址的二进制挪不动)。
# Linux
# Windows
# /DYNAMICBASE(VS2012 起默认)+ /HIGHENTROPYVA(64 位)
|
GDB 默认关闭 ASLR(set disable-randomization on),所以"gdb 里正常、直接跑崩溃"经常和这个有关。
局限:ASLR 只提高门槛,不根治。信息泄露(泄露一个地址就能推出整个模块基址)、32 位熵不足(8–16 位可暴力猜)、fork 型服务器(子进程继承布局,可逐字节爆破)都能绕过。
性能代价:x86-64 上几乎为零(RIP 相对寻址);x86-32 上要牺牲 ebx 常驻保存 GOT 基址,代价明显。
Stack Canary
在返回地址前放一个随机值,函数返回前检查是否被改动:
; 序言
mov rax, fs:[0x28]
mov [rbp-8], rax
; 尾声
mov rax, [rbp-8]
sub rax, fs:[0x28]
jne __stack_chk_fail ; 被改了 → 直接终止进程
| 级别 | 保护范围 |
|---|---|
-fstack-protector |
只保护含 char 数组的函数 |
-fstack-protector-strong |
推荐:含任何数组或取了局部变量地址的函数 |
-fstack-protector-all |
所有函数,开销最大 |
绕过方式:不覆盖返回地址而改写函数指针 / 局部变量、通过格式化字符串泄露 canary、或直接改 fs:[0x28]。
RELRO
Partial RELRO:.init_array / .data.rel.ro 只读,但 GOT 仍可写(惰性绑定需要)
Full RELRO :启动时解析全部符号,然后 GOT 也设为只读
代价是启动变慢(一次性解析所有符号)。
FORTIFY_SOURCE
编译器在能推断出缓冲区大小时,把 memcpy/strcpy/sprintf 替换成带检查的版本:
char buf;
; // 编译期能判断则直接报错,否则运行期 abort
必须配合 -O1 以上才生效(需要优化器做常量传播)。
CFI / CFG
限制间接跳转(虚函数调用、函数指针)的合法目标集合:
对 C++ 尤其有意义:虚函数调用是 ROP 的常见跳板,CFI 能保证 vptr 被篡改后跳不到任意位置。
Shadow Stack(Intel CET)
硬件维护一份只有 call/ret 能修改的影子栈,返回时比对。这是对 ROP 的硬件级根治:
一键检查
# RELRO STACK CANARY NX PIE RPATH RUNPATH FORTIFY
推荐的生产编译选项:
十七、常见内存错误与排查
错误分类
| 错误 | 典型场景 | 后果 |
|---|---|---|
| 缓冲区溢出 | strcpy 到小数组、下标越界 |
覆盖相邻数据/返回地址 |
| Use-After-Free | delete p; p->f(); |
访问已回收内存,可被堆喷利用 |
| Double Free | 两条路径都释放 | 破坏分配器元数据 |
| 悬垂指针/引用 | 返回局部变量地址、容器扩容后的旧引用 | UAF 的一种 |
| 内存泄漏 | 忘记 delete、循环引用 |
RSS 持续增长 |
| 未初始化读 | int x; if (x) |
不确定行为、信息泄露 |
| 类型混淆 | 错误的 reinterpret_cast、错误的 static_cast 向下转型 |
按错误布局解释内存 |
| 对齐错误 | #pragma pack 后直接解引用 |
ARM 上 SIGBUS |
| 数据竞争 | 无同步的多线程读写 | 结果不确定,可能撕裂 |
高频陷阱代码
int* // 返回栈上对象地址
auto& r = v; v.; r = 2; // 扩容后引用失效
std::string_view sv = ; // 临时 string 立即销毁
const char* p = .; // 同上
for
if m.; // erase 后 it 失效(应 it = m.erase(it))
delete[] p; /* 但 p 是 new 出来的 */ // new/delete 与 new[]/delete[] 必须配对
Base* b = new Derived; delete b; // Base 析构非虚 → Derived 部分不析构
; // obj 非平凡可复制 → UB
Sanitizer —— 首选工具
# AddressSanitizer:越界、UAF、double free、泄漏。开销 ~2x
# UndefinedBehaviorSanitizer:整数溢出、空指针解引用、对齐、类型混淆
# ThreadSanitizer:数据竞争。开销 ~5-15x(不能和 ASan 同用)
# MemorySanitizer:未初始化读(仅 Clang,要求所有依赖都用 MSan 编译)
# 推荐日常组合
ASan 的原理是影子内存 + 红区(redzone):每 8 字节应用内存对应 1 字节影子内存记录可用状态,在分配的对象前后插入毒化的红区,任何越界访问立即被捕获。
[红区][ 对象 ][红区]
毒化 毒化 ← 读写这里立刻报错并打印完整调用栈
Valgrind
&&
ASan vs Valgrind:ASan 快得多(2x vs 20-50x)、需要重新编译、检测更精确;Valgrind 不需要重编译、能查未初始化读、能跑第三方二进制。日常开发用 ASan,疑难杂症上 Valgrind。
泄漏排查
# 快速定位
ASAN_OPTIONS=detect_leaks=1
# 生产环境的堆剖析
HEAPPROFILE=/tmp/prof MALLOC_CONF=prof:true
# 观察 RSS 趋势
while ; do ; ; done
"内存不释放"不一定是泄漏:free 的内存可能被分配器缓存着没还给 OS(堆顶不连续空闲就 brk 不回去)。可以主动归还:
; // glibc
静态分析
编译期就该开满的警告:
从源头避免
| 做法 | 消除的问题 |
|---|---|
| RAII + 智能指针 | 泄漏、双重释放、异常路径漏释放 |
std::vector / std::array 代替裸数组 |
越界(at() 还有边界检查) |
std::span / string_view(注意生命周期) |
数组退化成指针后丢失长度 |
make_unique / make_shared |
new 表达式的异常安全窗口 |
基类析构函数声明 virtual(或 protected 非虚) |
通过基类指针 delete 的泄漏 |
| Rule of Zero:不写任何特殊成员函数 | 拷贝/移动语义写错 |
-fsanitize 常驻 CI |
早发现 |
十八、观测工具速查
看文件里有什么
| 目的 | Linux | Windows |
|---|---|---|
| 段大小统计 | size -A prog |
dumpbin /headers prog.exe |
| 符号表 | nm -C prog / readelf -sW |
dumpbin /symbols prog.obj |
| 段详情 | readelf -SW prog |
dumpbin /section |
| 反汇编 | objdump -dC prog |
dumpbin /disasm |
| 动态库依赖 | ldd prog |
dumpbin /dependents |
| 动态符号 | readelf -dW prog |
— |
| 加固状态 | checksec --file=prog |
dumpbin /headers + 手工看 |
|
看进程里有什么
| 目的 | Linux | Windows |
|---|---|---|
| 完整地址空间 | cat /proc/PID/maps |
VMMap(Sysinternals) |
| 带物理内存统计 | pmap -x PID |
任务管理器 / VMMap |
| 详细分解 | cat /proc/PID/smaps |
!address(WinDbg) |
| RSS/VSZ | ps -o pid,vsz,rss,comm |
Get-Process |
| 系统级 | free -h, vmstat 1 |
性能监视器 |
| 资源限制 | cat /proc/PID/limits |
— |
# 找出占内存最多的映射
| |
# smaps_rollup:一个进程的内存汇总(比 smaps 快得多)
看内存怎么被用掉的
# 堆用量随时间变化
&&
# 泄漏
ASAN_OPTIONS=detect_leaks=1
# 分配热点(tcmalloc / jemalloc)
HEAPPROFILE=/tmp/p
MALLOC_CONF=prof:true,prof_prefix:/tmp/jeprof
# glibc 自带的统计
看性能瓶颈
&&
&&
看对象布局
也可以直接在代码里断言:
static_assert;
static_assert;
static_assert;
static_assert;
static_assert;
在线工具
- Compiler Explorer(godbolt.org)—— 对比不同编译器/优化等级生成的汇编,验证内存布局和优化效果的最快途径
- cppinsights.io —— 展开模板、lambda、range-for、隐式转换,看编译器眼中的代码
- quick-bench.com —— 微基准对比
十九、面试速答卡
内存分区
静态存储区(
.data已初始化非零 /.bss零初始化,编译链接期确定布局、加载时分配)、栈(自动存储期,函数序言sub rsp, N分配,尾声恢复rsp回收)、堆(动态存储期,brk/mmap向内核要,手动或 RAII 管理)、常量区.rodata(只读,写入 SIGSEGV)、代码区.text(只读可执行)。此外还有 TLS(每线程一份)和 mmap 区(共享库、大块分配、文件映射)。
堆和栈的区别
| 栈 | 堆 | |
|---|---|---|
| 管理 | 编译器自动 | 手动 / RAII |
| 分配速度 | 一条 sub rsp |
要走分配器,找空闲块 |
| 大小 | 固定(Linux 8MB / Windows 1MB) | 受限于虚拟地址空间和物理内存 |
| 增长方向 | 向低地址 | 向高地址(brk) |
| 碎片 | 无(LIFO) | 有 |
| 缓存局部性 | 好(热数据集中) | 取决于分配模式 |
| 失败表现 | SIGSEGV,基本不可恢复 | nullptr / bad_alloc,可处理 |
.data / .bss / .rodata
.data存初值非零的可变数据,占文件体积;.bss存零初始化数据,不占文件体积(只记长度,加载时映射零页);.rodata存常量和字符串字面量,运行时只读。此外还有.data.rel.ro:逻辑上只读但需要重定位的数据(虚表等),重定位后被 RELRO 设为只读。
静态局部变量什么时候初始化
分两阶段:零初始化和常量初始化在任何代码执行前完成(值烘进
.data/.bss);需要跑代码的动态初始化,C 语言不允许(初始化式必须是常量表达式),C++ 允许。全局对象的动态初始化在main之前完成(跨 TU 顺序不确定);块作用域 static 推迟到首次执行到该声明时,C++11 起由守卫变量保证线程安全。析构通过__cxa_atexit注册,按构造完成的反序执行。
派生类构造顺序
① 虚基类(按继承 DAG 深度优先左到右的首次出现顺序,由最派生类构造,中间类的调用被忽略)→ ② 直接非虚基类(按 base-specifier-list 的声明顺序)→ ③ 非静态数据成员(按类定义中的声明顺序,与初始化列表书写顺序无关)→ ④ 构造函数体。析构严格逆序。
引用的实现
语义上引用是别名,对形参的操作等价于对实参本体的操作。实现上标准规定"引用是否占存储由实现决定":未优化时编译器通常传地址并间接寻址,开优化后(尤其内联之后)这块空间和间接寻址往往被完全消除。注意引用不是对象 ——
&r得到的是被引用对象的地址,sizeof(r)是被引用对象的大小,所以没有指向引用的指针、没有引用的数组;"引用的引用"通过 typedef/模板间接形成时会折叠(有一个&就是&)。
std::move / std::forward
std::move是无条件的static_cast<remove_reference_t<T>&&>。返回类型必须先remove_reference再加&&,否则传左值时引用折叠会让它退化成左值引用而失效。std::forward是有条件的static_cast<T&&>,靠引用折叠还原原始值类别;参数写成remove_reference_t<T>&制造非推导语境,强制显式指定T(因为具名变量永远是左值,值类别信息只存在于外层T中)。 两者都是纯类型转换,编译后不产生任何指令。
vector 默认分配多少
零堆分配,
size() == capacity() == 0。vector 对象自身是三个指针共 24 字节。首次push_back才分配,libstdc++/libc++ 按 2 倍增长,MSVC 按 1.5 倍 —— 标准只要求摊还 O(1)。
deque 的内存组织
一个连续的指针数组(map)管理若干等长缓冲区,元素分段连续。迭代器保存 cur/first/last/node 四个指针,支持 O(1) 随机访问但需两次间接寻址。两端插入时若 map 空间不足会重新居中或扩容,但只搬指针不搬元素 —— 所以两端插入使所有迭代器失效,而指针和引用仍然有效。这正是
stack/queue默认用它的原因。
为什么 unordered_map 用链地址法
因为标准要求引用在 rehash 后依然有效,开放寻址做不到(元素会被搬移)。这也是它性能不如
absl::flat_hash_map的根本原因 —— 标准把接口保证摆在了性能前面。
shared_ptr 线程安全吗
引用计数是原子的(多线程拷贝/析构同一个
shared_ptr安全),但被指向的对象不是,且对同一个shared_ptr实例的并发读写(一边reset一边拷贝)也不安全。需要用std::atomic<std::shared_ptr<T>>(C++20)或atomic_load/store(C++11,C++20 弃用)。
make_shared 的优缺点
优点:一次堆分配(对象和控制块相邻,缓存友好)、无异常安全窗口。缺点:不支持自定义删除器(要用
allocate_shared);因为对象和控制块同一块内存,只要还有weak_ptr活着,对象那块内存就还不了。
alloca 会爆栈吗
会,而且比
malloc失败严重:alloca只是一条sub rsp,不检查也不返回错误。空间不足时要么直接 SIGSEGV(基本无法恢复),要么一步跨过 guard page 静默破坏相邻内存(Stack Clash 类漏洞)。而且alloca累积到函数返回才释放(VLA 是块作用域结束就释放),循环里用必爆。尺寸必须有编译期可控的上界。
ASLR 是编译器的还是操作系统的
两边都要。编译器/链接器负责生成位置无关代码并打上标记(
-fPIE -pie//DYNAMICBASE),操作系统加载器负责加载时选随机基址(Linux 由randomize_va_space控制)。编译器不知道运行时的随机值,OS 也挪不动写死绝对地址的二进制 —— 缺一不可。
内存对齐为什么
① 硬件要求:部分架构(老 ARM、SPARC)非对齐访问直接异常;x86 虽然允许但要拆成两次访存 + 拼接。② 缓存行:对齐能保证一个对象不跨两条 cache line。③ 原子性:
std::atomic要求自然对齐才能保证单指令原子操作。规则是"成员偏移必须是其对齐要求的整数倍,结构体大小必须是最大成员对齐的整数倍",所以按成员大小降序排列能省内存。
什么时候用 virtual 析构函数
只要这个类可能被通过基类指针 delete,析构函数就必须是
virtual。否则只调基类析构,派生类部分泄漏(UB)。反过来,如果一个类不打算被多态删除,可以把析构函数声明成protected非虚 —— 从语言层面禁止delete base_ptr,同时避免 vptr 开销。
附录:术语表
| 术语 | 含义 |
|---|---|
| TU(Translation Unit) | 翻译单元:一个 .cpp 加上预处理展开后的全部内容,编译的基本单位 |
| VMA(Virtual Memory Area) | 进程地址空间里一段连续的、属性相同的映射 |
| section / segment | section 是链接期概念(.text 等);segment 是加载期概念(按权限打包的 section 组) |
| COMDAT | 可合并的段,链接器见到多份同名的只保留一份 |
| ODR | One Definition Rule,同一实体在整个程序中只能有一个定义(inline 等允许多份但必须完全相同) |
| IFNDR | ill-formed, no diagnostic required:违反了标准但编译器不必报错 |
| odr-use | 一个实体被"真正使用"到必须有定义的程度 |
| PIC / PIE | 位置无关代码 / 位置无关可执行文件 |
| GOT / PLT | 全局偏移表 / 过程链接表,动态链接的间接层 |
| RELRO | RELocation Read-Only,重定位后把相关段设为只读 |
| RSS / VSZ / PSS | 常驻内存 / 虚拟大小 / 按共享比例摊分的常驻内存 |
| COW | Copy-on-Write,写时复制 |
| EBO | Empty Base Optimization,空基类优化 |
| NSDMI | Non-Static Data Member Initializer,类内成员默认初始化式 |
| RVO / NRVO | 返回值优化 / 具名返回值优化,C++17 起 prvalue 情形成为语言保证 |
| SSO | Small String Optimization,短字符串内嵌不分配堆 |
| fancy pointer | 非原始指针的 allocator_traits::pointer,如 offset_ptr |
| arena / bump allocator | 线性分配器:只推进游标,不单独回收,整体释放 |
| false sharing | 不同线程写同一 cache line 上的不同变量导致的缓存弹跳 |
| AoS / SoA | Array of Structs / Struct of Arrays |
| ROP | Return-Oriented Programming,用已有代码片段拼接出攻击逻辑 |
| magic statics | C++11 起块作用域静态变量的线程安全初始化 |
参考资料
- 书:《深入理解计算机系统》(CSAPP) 第 7、9 章;《程序员的自我修养 —— 链接、装载与库》;《Effective Modern C++》条款 18–22、41
- 标准:
[basic.stc]存储期、[basic.start.dynamic]动态初始化、[class.base.init]构造顺序、[expr]值类别、[allocator.requirements] - 文档:Itanium C++ ABI(对象布局、vtable、
__cxa_atexit的事实标准)、System V AMD64 ABI - 工具:Compiler Explorer、cppinsights、cppreference
本文以 Linux x86-64 + GCC/Clang 为主线,Windows/MSVC 差异已单独标注。具体数值(缓冲区大小、增长因子、SSO 容量等)属于实现细节,可能随版本变化 —— 关键结论请在目标平台上用 Compiler Explorer 或实测验证。
暂无评论,欢迎留下第一条评论。