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 = malloc(1UL << 30);   // 1 GB,立即成功
// 此刻物理内存占用:几乎为 0,只是建立了一段 VMA
memset(p, 0, 1UL << 30);       // ← 现在才真正开始消耗物理内存

首次写入某一页时触发缺页异常(page fault),内核这时才分配物理页并填进页表。所以:

指标 含义
VSZ / Virtual Size 虚拟地址空间大小 —— 只是"申请了多少",参考价值有限
RSS / Resident Set Size 真正驻留在物理内存的部分 —— 这才是关心的指标
PSS 按共享比例摊分后的 RSS(Linux 特有,统计多进程共享库更准)

Linux 还允许 overcommit:可以答应超过物理内存 + swap 总量的申请,赌你不会全用。用光了就触发 OOM Killer。

cat /proc/sys/vm/overcommit_memory   # 0=启发式(默认) 1=总是答应 2=严格核算

缺页的两种类型

minor fault(次要缺页):物理页已在内存中(比如共享库已被别的进程加载),
                        只需在本进程页表里建立映射 —— 快
major fault(主要缺页):需要从磁盘读入(换页、文件映射首次访问)—— 慢几个数量级
/usr/bin/time -v ./myprog     # 会打印 minor/major page fault 次数
perf stat -e page-faults ./myprog

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[1000000];        // .bss —— 文件里只记一个长度
int big2[1000000] = {1}; // .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 fn() {
    static int h = 7;        // .data
    static int i = rand();   // .bss(先零初始化)+ 一个守卫变量也在 .bss
    static std::string s;    // .bss(对象存储);构造在首次执行到此处时
}

一个经典 UB

char* p = "abc";
p[0] = '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
class A {
public:
    int add(int x) { return x + 1; }   // 类内定义 → 隐式 inline
};

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
class A { public: int add(int x); };
int A::add(int x) { return x + 1; }   // ✗ 强符号 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
#define DEBUG 1
#include "widget.h"     // Widget 多了一个成员,sizeof 变大

// b.cpp
#include "widget.h"     // Widget 是另一个 sizeof
// → 两边对同一对象的成员偏移理解不同 → 静默内存踩踏

对策:统一编译选项、把 #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:

gcc -Wl,-z,relro,-z,now    # now = 启动时全部解析完,然后把 GOT 设成只读

代价是启动变慢(要一次性解析所有符号),换来 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 template class std::vector<MyType>;
// a.cpp
template class std::vector<MyType>;

五、进程地址空间

典型 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 就是基于这个。

查看真实布局

cat /proc/self/maps           # 当前进程
pmap -x <pid>                 # 更易读,带物理内存统计

Windows:

VMMap(Sysinternals)
!address(WinDbg)

六、静态存储区:初始化的两个阶段

静态存储期对象(全局变量、static 变量、类的静态成员)的初始化分两步,这是 C 和 C++ 差异的根源。

阶段一:静态初始化(代码执行前)

零初始化   → 所有静态存储期对象先被清零(.bss 天然为零)
常量初始化 → 初始值是编译期常量的,直接烘进 .data

这一步不执行任何代码,纯粹是文件内容 + 零页映射。

阶段二:动态初始化(需要跑代码)

int a = 42;          // 常量初始化 → .data,阶段一完成
int b = rand();      // 动态初始化 → .bss 先清零,阶段二调用 rand()
std::string s;       // 动态初始化 → 阶段二调用构造函数

C 语言根本不允许阶段二 —— 静态存储期对象的初始化式必须是常量表达式。所以 C 里写不了 static int x = rand();。C++ 允许,因为引入构造函数后"初始化"不再是往内存里填个值那么简单。

全局对象 vs 局部静态:懒不懒是关键区别

全局(命名空间作用域)对象 块作用域 static
触发时机 TU 被激活时,通常在 main 之前 控制流首次流经声明处
触发粒度 整个 TU 的所有对象一起 精确到这一个对象
没被用到 照样构造 永远不构造
线程安全 由启动阶段的单线程性保证 C++11 起由守卫变量保证
struct A { A() { puts("A"); } };
A g;                        // main 之前构造,哪怕从没被用过
void f() { static A l; }    // 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& getLogger() {
    static Logger instance;   // 首次调用时才构造,顺序由调用关系天然保证
    return instance;
}

线程安全的守卫变量(C++11 magic statics)

块作用域静态变量的动态初始化保证线程安全,编译器展开成:

if (!(__guard & 1)) {                       // 快路径:一次原子 load
    if (__cxa_guard_acquire(&__guard)) {    // 只有一个线程进得来
        new (&obj) T(...);
        __cxa_guard_release(&__guard);
        __cxa_atexit(dtor, &obj, ...);      // 注册析构
    }
}

析构通过 __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 f(int n) {
    int  a[10];                       // N 是常量
    int  b[n];                        // VLA —— N 运行期算出
    void* c = alloca(n);              // alloca —— 同上
}
; 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 (int i = 0; i < 1000000; ++i) {
    int  vla[n];         // ✓ 每轮结束就释放(块作用域)
    void* p = alloca(n); // ✗ 累积到【函数返回】才释放 → 必爆栈
}

两者都不在 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 —
ulimit -s                                  # Linux 查看/修改
dumpbin /headers app.exe | findstr stack   # Windows 查看
editbin /STACK:4194304 app.exe             # Windows 修改
gcc -Wstack-usage=8192 -fstack-usage       # 编译期检查栈用量

Windows 默认只有 1 MB,比 Linux 小 8 倍 —— 跨平台代码要特别注意。

爆栈

alloca 最危险的地方是无法检测失败:malloc 返回 NULL 可以处理,alloca 只是一条 sub rsp,不检查任何东西。

三种爆栈方式:

  1. 单次分配过大 → sub rsp 一步跨过 guard page
  2. 循环累积(alloca 特有)
  3. 递归过深 —— 不用 alloca 也会爆

第 1 种最严重,因为它可能不崩溃:

┌──────────────┐
│  某个 mmap   │  ← 一步跨过来,落在这里
├──────────────┤
│  guard page  │  ← 只有一页 4KB,被整个跳过
├──────────────┤
│      栈      │
└──────────────┘

后续写入静默破坏相邻内存 —— 这就是 Stack Clash 类漏洞(CVE-2017-1000364 等),可用于本地提权。防护:

gcc -fstack-clash-protection    # 插入逐页探测,确保 guard page 被触碰

Windows 的 __chkstk 一直这么干,天然免疫(代价是大分配更慢)。

爆栈后的表现:Linux 是 SIGSEGV(想在信号处理函数里做事必须先 sigaltstack 准备备用栈);Windows 是 EXCEPTION_STACK_OVERFLOW(SEH 能捕获,但要调 _resetstkoflw() 重建 guard page)。基本等于进程死亡。

安全的替代方案

// MSVC:小走栈,大走堆
void* p = _malloca(n);
_freea(p);

// 通用:小缓冲区优化
template<class T, std::size_t N = 64>
class SmallBuffer {
    alignas(T) std::byte stack_[N * sizeof(T)];
    std::unique_ptr<T[]> heap_;
    T* p_;
public:
    explicit SmallBuffer(std::size_t n) {
        if (n <= N) p_ = reinterpret_cast<T*>(stack_);
        else { heap_ = std::make_unique<T[]>(n); p_ = heap_.get(); }
    }
    T* get() noexcept { return 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 ./myapp

内存碎片

外部碎片:空闲空间总量够,但没有一块连续的够大
          ┌──┬────┬──┬──────┬──┐
          │用│空闲│用│ 空闲 │用│   总空闲 300B,但要 200B 连续 → 失败
          └──┴────┴──┴──────┴──┘

内部碎片:分配器给的比你要的多
          malloc(33) 实际给 48 字节(对齐 + chunk 头)

对策:对象池、arena/线性分配器、std::pmr、size class 归一化。

对齐

std::max_align_t                       // malloc 保证的对齐(x86-64 上是 16)
alignas(64) struct CacheLine { ... };  // 手动指定
std::aligned_alloc(64, size);          // C11/C++17
operator new(size, std::align_val_t{64}); // C++17 起 new 支持过对齐类型

多线程场景下,把不同线程频繁写的变量对齐到不同缓存行,可以消除 false sharing:

struct alignas(std::hardware_destructive_interference_size) Counter {
    std::atomic<int> v;
};

九、内存映射与共享内存

mmap 是 Linux 内存管理的万能工具:动态库加载、大块 malloc、文件 I/O、共享内存、线程栈,底层全是它。

mmap 的四个维度

void* mmap(void* addr, size_t len, int prot, int flags, int fd, off_t off);
维度 取值 说明
有无后备文件 fd >= 0 / MAP_ANONYMOUS 文件映射 / 匿名映射
是否共享 MAP_SHARED / MAP_PRIVATE 写入对其他进程可见 / 写时复制
权限 PROT_READ/WRITE/EXEC 对应页表权限位
地址 NULL / MAP_FIXED 内核挑地址 / 强制指定(危险)

四种组合的用途:

匿名 + 私有  →  malloc 大块、线程栈、BSS
匿名 + 共享  →  父子进程共享内存
文件 + 私有  →  加载可执行文件和动态库(写时复制)
文件 + 共享  →  进程间共享内存、内存映射文件 I/O

内存映射文件

int fd = open("data.bin", O_RDWR);
struct stat st; fstat(fd, &st);
void* p = mmap(NULL, st.st_size, PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0);
// 之后像操作数组一样读写文件,内核负责换页和回写
msync(p, st.st_size, MS_SYNC);   // 强制刷盘
munmap(p, st.st_size);

相比 read/write 的优势:

  • 省掉一次内核缓冲区 → 用户缓冲区的拷贝
  • 随机访问大文件时不需要反复 lseek
  • 多进程映射同一文件自动共享页缓存

劣势:小文件不划算(建立映射有固定成本)、页错误不可预测(延迟抖动)、文件被截断后访问会 SIGBUS。

共享内存

POSIX:

int fd = shm_open("/myseg", O_CREAT|O_RDWR, 0666);
ftruncate(fd, SIZE);
void* p = mmap(NULL, SIZE, PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0);
close(fd);
// ...
shm_unlink("/myseg");   // ★ 必须显式删除,否则持续到系统重启

Windows:

HANDLE h = CreateFileMapping(INVALID_HANDLE_VALUE, NULL,
                             PAGE_READWRITE, 0, SIZE, "MySeg");
void* p = MapViewOfFile(h, FILE_MAP_ALL_ACCESS, 0, 0, SIZE);
// Windows 是引用计数,最后一个句柄关闭即销毁

在共享内存上使用 STL:核心难点是指针

同一块共享内存,在不同进程里被映射到不同的虚拟地址:

进程 A:  段基址 0x7f0000000000    vector 内部存 begin = 0x7f0000001000
进程 B:  段基址 0x7f9000000000    ← A 存的那个绝对地址在这里毫无意义

STL 容器内部保存的是绝对指针,跨进程读取就是野指针。解法是自相对指针:

template<class T>
class offset_ptr {
    std::ptrdiff_t off_;                      // 相对于 this 的偏移
public:
    T* get() const {
        return reinterpret_cast<T*>(
            reinterpret_cast<const char*>(this) + off_);
    }
    T& operator*()  const { return *get(); }
    T* operator->() const { return get(); }
};

因为 this 和目标在同一个段里,相对距离在任何进程里都一样。

容器怎么知道要用它? 靠 allocator_traits::pointer。标准容器必须通过它而非裸 T* 定义内部成员,这类非原始指针叫 fancy pointer。libstdc++ / libc++ 支持得不错,MSVC STL 支持不完整。

务实方案:Boost.Interprocess

#include <boost/interprocess/managed_shared_memory.hpp>
#include <boost/interprocess/allocators/allocator.hpp>
#include <boost/interprocess/containers/vector.hpp>
#include <boost/interprocess/containers/string.hpp>

namespace bip = boost::interprocess;
using SegMgr = bip::managed_shared_memory::segment_manager;
template<class T> 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::shared_memory_object::remove("MySeg");
bip::managed_shared_memory seg(bip::create_only, "MySeg", 1 << 20);
auto* v = seg.construct<ShmVector>("TheVector")(seg.get_segment_manager());
v->push_back(42);

// —— 进程 B:打开并读取 ——
bip::managed_shared_memory seg(bip::open_only, "MySeg");
auto [v, n] = seg.find<ShmVector>("TheVector");
if (v) std::cout << (*v)[0];   // 42

construct<T>("name") 把对象注册进段内目录,另一个进程用 find<T>("name") 取回 —— 解决了"入口指针怎么传"的问题。

同步:互斥量本身也必须放在共享内存里,且是 process-shared 的:

struct SharedData {
    bip::interprocess_mutex mtx;
    ShmVector data;
};
bip::scoped_lock<bip::interprocess_mutex> lk(sd->mtx);

共享内存的坑清单

坑 说明
绝对指针 所有跨进程可见的指针必须是 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 出的父子进程 相同(完整继承地址空间布局)
  1. ASLR —— 即使同一二进制,不同进程的模块基址也不同
  2. 共享内存段本身的映射地址不同 —— 与 ASLR 无关也会不同

十、对象的内存布局

对齐与填充

规则:每个成员的偏移必须是其对齐要求的整数倍;结构体整体大小必须是最大成员对齐的整数倍。

struct Bad {          // sizeof == 24
    char  a;          // 偏移 0
    // 7 字节填充
    double b;         // 偏移 8
    char  c;          // 偏移 16
    // 7 字节填充(让数组里下一个元素的 b 也能对齐)
};

struct Good {         // sizeof == 16
    double b;         // 偏移 0
    char   a;         // 偏移 8
    char   c;         // 偏移 9
    // 6 字节填充
};

按大小降序排列成员通常能省下可观的空间。一个百万级对象数组,24 → 16 字节意味着少 8 MB。

static_assert(sizeof(Good) == 16);
static_assert(offsetof(Good, c) == 9);
alignof(Good);    // 8

#pragma pack(1) 可以取消填充,但代价是非对齐访问:x86 上慢,ARM 上可能直接 SIGBUS。只在解析二进制协议时用,且用 memcpy 而非直接解引用。

位域

struct Flags {
    unsigned a : 1;
    unsigned b : 3;
    unsigned c : 4;
};   // 通常 4 字节

位域的布局(分配顺序、跨边界行为)是实现定义的,不同编译器/字节序结果不同。不要用它做跨平台的协议解析。

空类与空基类优化(EBO)

struct Empty {};
sizeof(Empty);            // 1 —— 保证不同对象有不同地址

struct A { Empty e; int x; };
sizeof(A);                // 8(e 占 1 + 3 填充)

struct B : Empty { int x; };
sizeof(B);                // 4 —— EBO:空基类不占空间

这就是为什么 std::vector 用继承而不是成员持有无状态分配器:EBO 让 sizeof(vector<int>) 保持 24 而不是 32。C++20 起可以用 [[no_unique_address]] 对成员达到同样效果:

struct C { [[no_unique_address]] Empty e; int x; };
sizeof(C);                // 4

单继承与虚函数表

struct Base    { virtual void f(); int a; };
struct Derived : Base { void f() override; int b; };
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,基类构造函数里调虚函数只会调到基类版本

多继承

struct A { virtual void f(); int a; };
struct B { virtual void g(); int b; };
struct C : A, B { int c; };
C 对象(sizeof == 32)
偏移  0  +--------------+  <- A 子对象,也是 C* 的地址
        |  vptr (A)    |
        |  a           |
偏移 16  +--------------+  <- B 子对象
        |  vptr (B)    |
        |  b           |
偏移 24  +--------------+
        |  c           |
        +--------------+

后果:

C* pc = new C;
B* pb = pc;        // 指针值【会变】!自动加上 16
assert((void*)pc != (void*)pb);

调用 pb->g() 时 this 指向偏移 16,但 C::g 的实现需要完整的 C*。解决办法是 vtable 里放 thunk(跳板代码):先把 this 减回来,再跳到真正的实现。

虚继承

struct V { int v; };
struct A : virtual V { int a; };
struct B : virtual V { int b; };
struct D : A, B { int d; };

虚基类 V 在 D 里只有一份。代价是:

  • 每条虚继承路径要多一个 vbptr(虚基类表指针),或在 vtable 里存 vbase offset
  • 访问 v 需要运行期查表得到偏移,不再是编译期常量
  • 对象变大、访问变慢

以及最关键的语义规则:虚基类由最派生类直接构造,中间类初始化列表里对虚基类的调用被忽略。

struct A : virtual V { A() : V(1) {} };   // V(1) 被忽略
struct D : A, B      { D() : V(3) {} };   // 只有这个生效

这意味着虚基类若没有默认构造函数,每一个最派生类都必须显式初始化它。

构造与析构顺序

1. 虚基类        -- 按继承 DAG 深度优先、左到右的首次出现顺序,由最派生类构造
2. 直接非虚基类  -- 按 base-specifier-list 的【声明顺序】
3. 非静态数据成员 -- 按类定义中的【声明顺序】(不是初始化列表的书写顺序!)
4. 构造函数体

析构:严格逆序

第 3 条是高频陷阱:

class C {
    int a, b;
public:
    C(int x) : b(x), a(b) {}   // ✗ a 先初始化,此时 b 还是垃圾值
};

GCC/Clang 会给 -Wreorder 警告。

内存布局的观测

clang++ -Xclang -fdump-record-layouts foo.cpp
g++ -fdump-lang-class foo.cpp
cl /d1reportSingleClassLayoutC foo.cpp

十一、存储期与生命周期

存储期 触发 位置 释放
静态 全局、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 = std::string("hi");   // 生命周期延长到 s 的作用域结束

但延长不传递:

const std::string& f() {
    return std::string("hi");   // ✗ 返回后立即悬垂
}

C++17 起 prvalue 不再是"临时对象",而是"初始化对象的配方",只在需要时才物化成 xvalue。后果是 RVO 从"允许优化"变成"语言保证":

struct NoCopyNoMove {
    NoCopyNoMove(const NoCopyNoMove&) = delete;
    NoCopyNoMove(NoCopyNoMove&&) = delete;
};
NoCopyNoMove make() { return NoCopyNoMove{}; }
NoCopyNoMove x = make();   // C++17 合法:全程只有一个对象

所以 return local; 不要写成 return std::move(local); —— 那会阻止 NRVO,把零成本变成一次移动。

常见的生命周期错误

int* f() { int x = 0; return &x; }          // 返回栈上对象的地址
auto& r = v[0]; v.push_back(1); r = 2;      // 容器扩容后引用失效
std::string_view sv = std::string("hi");    // 临时 string 立即销毁,sv 悬垂
delete p; p->f();                           // 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 + (start.cur - start.first);
return *(*(start.node + off / buf_size) + off % buf_size);

最重要的性质 —— 两端插入时的非对称失效:

操作 迭代器 指针 / 引用
两端插入 全部失效 仍然有效 ✓
中间插入 全部失效 全部失效
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 —— 红黑树

struct Node {
    Color  color;                     // 1 字节 + 填充
    Node  *parent, *left, *right;     // 24 字节
    T      value;
};

每个元素约 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 会补齐绝大部分样板,你只需提供四样东西:

template<class T>
struct MyAlloc {
    using value_type = T;                                  // ① 必需

    MyAlloc() = default;
    template<class U>                                      // ② 跨类型转换构造
    constexpr MyAlloc(const MyAlloc<U>&) noexcept {}

    T* allocate(std::size_t n) {                           // ③
        if (n > std::numeric_limits<std::size_t>::max() / sizeof(T))
            throw std::bad_array_new_length();
        if (auto p = static_cast<T*>(std::malloc(n * sizeof(T))))
            return p;
        throw std::bad_alloc();
    }
    void deallocate(T* p, std::size_t) noexcept { std::free(p); }   // ④
};

template<class T, class U>
bool operator==(const MyAlloc<T>&, const MyAlloc<U>&) noexcept { return true; }
template<class T, class U>
bool operator!=(const MyAlloc<T>&, const MyAlloc<U>&) noexcept { return false; }

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>:

struct Node { Node *prev, *next; T value; };
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 释放

class Arena {
    char* buf_; std::size_t cap_, off_ = 0;
public:
    Arena(char* b, std::size_t c) noexcept : buf_(b), cap_(c) {}
    void* alloc(std::size_t n, std::size_t align) {
        auto cur = reinterpret_cast<std::uintptr_t>(buf_ + off_);
        std::size_t pad = (align - cur % align) % align;
        if (off_ + pad + n > cap_) throw std::bad_alloc();
        void* p = buf_ + off_ + pad;
        off_ += pad + n;
        return p;
    }
    void dealloc(void*, std::size_t) noexcept {}   // 线性分配器整体释放
};

template<class T>
struct ArenaAlloc {
    using value_type = T;
    Arena* a_;

    explicit ArenaAlloc(Arena* a) noexcept : a_(a) {}
    template<class U> ArenaAlloc(const ArenaAlloc<U>& o) noexcept : a_(o.a_) {}

    T* allocate(std::size_t n) {
        return static_cast<T*>(a_->alloc(n * sizeof(T), alignof(T)));
    }
    void deallocate(T* p, std::size_t n) noexcept { a_->dealloc(p, n * sizeof(T)); }

    template<class U>
    bool operator==(const ArenaAlloc<U>& o) const noexcept { return a_ == o.a_; }

    using propagate_on_container_move_assignment = std::true_type;
    using propagate_on_container_swap            = std::true_type;
};

三个 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 就是来解决它的 —— 把多态推迟到运行期:

#include <memory_resource>

char buf[4096];
std::pmr::monotonic_buffer_resource pool(buf, sizeof(buf));
std::pmr::vector<int> v(&pool);      // 类型是 vector<int, polymorphic_allocator<int>>

void f(std::pmr::vector<int>&);      // 所有 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 handle_request(const Request& req) {
    std::byte buf[64 * 1024];
    std::pmr::monotonic_buffer_resource arena(buf, sizeof(buf));

    std::pmr::vector<std::pmr::string> tokens(&arena);
    std::pmr::unordered_map<std::pmr::string, int> counts(&arena);
    // ... 全部分配都在栈上的 buf 里线性推进,零 malloc
}   // arena 析构,一次性释放,不逐个 free

对于短生命周期、大量小对象的场景(HTTP 请求处理、编译器的一趟 pass、游戏的一帧),这个模式能带来数量级的提升。

共享内存 allocator 骨架

struct SegHeader {
    std::atomic<std::size_t> offset;
    std::size_t              capacity;
};

class ShmSegment {
    void*      base_;
    SegHeader* hdr_;
public:
    ShmSegment(const char* name, std::size_t size, bool create) {
        int fd = shm_open(name, create ? (O_CREAT|O_RDWR) : O_RDWR, 0666);
        if (create) ftruncate(fd, size);
        base_ = mmap(nullptr, size, PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0);
        close(fd);
        hdr_ = static_cast<SegHeader*>(base_);
        if (create) { hdr_->offset = sizeof(SegHeader); hdr_->capacity = size; }
    }
    // 返回【偏移】而非指针 —— 这是关键
    std::size_t alloc(std::size_t n, std::size_t align) {
        std::size_t cur, aligned;
        do {
            cur = hdr_->offset.load(std::memory_order_relaxed);
            aligned = (cur + align - 1) & ~(align - 1);
            if (aligned + n > hdr_->capacity) throw std::bad_alloc();
        } while (!hdr_->offset.compare_exchange_weak(cur, aligned + n));
        return aligned;
    }
    void* to_ptr(std::size_t off) const { return static_cast<char*>(base_) + off; }
};

template<class T>
struct ShmAlloc {
    using value_type = T;
    using pointer    = offset_ptr<T>;        // ★ fancy pointer
    ShmSegment* seg_;                        // 注意:这个指针本身是进程私有的

    pointer allocate(std::size_t n) {
        return pointer(static_cast<T*>(
            seg_->to_ptr(seg_->alloc(n * sizeof(T), alignof(T)))));
    }
    void deallocate(pointer, std::size_t) noexcept {}
};

生产级还需要:空闲链表 / 伙伴系统(上面只是 bump 分配,不能回收)、命名对象目录、崩溃恢复、段扩容。所以实际项目建议直接用 Boost.Interprocess。

全局替换 operator new

另一条路是不动容器,直接换掉全局分配:

void* operator new(std::size_t n) {
    if (void* p = my_malloc(n)) return p;
    throw std::bad_alloc();
}
void operator delete(void* p) noexcept { my_free(p); }
void operator delete(void* p, std::size_t) noexcept { my_free(p); }   // sized delete

或者更简单,运行时替换整个 malloc 实现:

LD_PRELOAD=/usr/lib/libtcmalloc.so ./myapp
LD_PRELOAD=/usr/lib/libjemalloc.so ./myapp

多线程小对象密集的服务,换 tcmalloc / jemalloc / mimalloc 常常有 10–30% 的提升,且不用改一行代码。


十四、智能指针的内存布局

std::unique_ptr —— 零开销

sizeof(std::unique_ptr<T>);                       // 8 —— 就是一个裸指针
sizeof(std::unique_ptr<T, void(*)(T*)>);          // 16 —— 函数指针型删除器占空间
sizeof(std::unique_ptr<T, MyEmptyDeleter>);       // 8 —— 无状态删除器靠 EBO 消失

默认删除器 std::default_delete<T> 是空类,通过 EBO(或 [[no_unique_address]])不占空间。所以 unique_ptr 是真正的零成本抽象:布局和裸指针一样,生成的代码也一样。

教训:删除器优先用无状态的函数对象(lambda 类型、仿函数),别用函数指针。

auto del = [](FILE* f){ fclose(f); };
std::unique_ptr<FILE, decltype(del)> fp(fopen("x", "r"), del);  // 8 字节

std::shared_ptr —— 两个指针 + 一个控制块

sizeof(std::shared_ptr<T>);   // 16 —— 两个指针
sizeof(std::weak_ptr<T>);     // 16
shared_ptr 对象                    控制块(堆上)
+----------------+              +----------------------+
|  ptr  ---------+------------> |  强引用计数 (atomic) |
+----------------+              |  弱引用计数 (atomic) |
|  ctrl ---------+------------> |  删除器              |
+----------------+              |  分配器              |
                                |  [对象本身]          | ← 仅 make_shared
                                +----------------------+

为什么要两个指针?因为 ptr 和"控制块管理的对象"可以不是同一个:

struct Big { int x; std::string s; };
auto sp = std::make_shared<Big>();
std::shared_ptr<int> px(sp, &sp->x);   // 别名构造:共享控制块,但指向成员
// px 让整个 Big 保持存活,自己却只暴露那个 int

make_shared vs shared_ptr<T>(new T)

std::shared_ptr<T> a(new 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 f(std::shared_ptr<T> p);          // ✗ 每次调用一次原子增 + 一次原子减
void f(const std::shared_ptr<T>& p);   // ✓ 不改引用计数
void f(T& t);                          // ✓✓ 最好:函数不关心所有权就别收智能指针

准则:只有需要参与所有权的接口才收 shared_ptr;只是用一下对象,就收 T& 或 T*。

enable_shared_from_this

class Widget : public std::enable_shared_from_this<Widget> {
    void register_self() {
        some_list.push_back(shared_from_this());   // ✓
        // some_list.push_back(std::shared_ptr<Widget>(this));  // ✗ 第二个控制块 → 双重释放
    }
};

基类里藏了一个 weak_ptr<Widget>,shared_ptr 构造时会检测到并回填。所以 sizeof(Widget) 会多 16 字节,且必须先由 shared_ptr 管理才能调 shared_from_this()(C++17 起未被管理时抛 bad_weak_ptr)。

循环引用

struct Node {
    std::shared_ptr<Node> next;   // A → B
    std::shared_ptr<Node> prev;   // B → A  ✗ 引用计数永远不归零 → 泄漏
};

解法:一个方向用 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
std::hardware_constructive_interference_size

推论:读一个 int 的成本 ≈ 读 16 个连续 int 的成本。

顺序 vs 随机访问

// 行优先遍历:每个 cache line 被完整利用
for (int i = 0; i < N; ++i)
    for (int j = 0; j < N; ++j) sum += m[i][j];   // 快

// 列优先遍历:每次访问都是新的 cache line
for (int j = 0; j < N; ++j)
    for (int i = 0; i < N; ++i) sum += m[i][j];   // 慢 5–10 倍

同样的算法复杂度,同样的指令数,差别全在访存模式。

False Sharing(伪共享)

两个线程写不同的变量,但它们在同一个 cache line 上 —— 缓存一致性协议(MESI)会让这条 line 在核间反复弹跳:

struct Bad {
    std::atomic<int> a;   // 线程 1 写
    std::atomic<int> b;   // 线程 2 写   ← 和 a 在同一 cache line!
};

struct Good {
    alignas(64) std::atomic<int> a;
    alignas(64) std::atomic<int> b;   // 分到不同 cache line
};

实测差距可以到 10 倍以上。诊断:

perf c2c record ./myprog && perf c2c report    # 专门找 false sharing

AoS vs SoA

// Array of Structs —— 直观,但遍历单个字段时浪费带宽
struct Particle { float x, y, z, vx, vy, vz; float mass; int id; };
std::vector<Particle> ps;
for (auto& p : ps) p.x += p.vx;      // 每个 cache line 只有 1/8 有用

// Struct of Arrays —— 遍历单字段时带宽利用率 100%,且可自动向量化
struct Particles {
    std::vector<float> x, y, z, vx, vy, vz;
};
for (size_t i = 0; i < n; ++i) ps.x[i] += ps.vx[i];   // SIMD 友好

这是**数据导向设计(Data-Oriented Design)**的核心思想,游戏引擎的 ECS 架构就建立在此之上。

成员排序省内存

struct Bad  { char a; double b; char c; };   // 24 字节
struct Good { double b; char a; char c; };   // 16 字节

百万级数组上,这是 8 MB 的差别 —— 也直接意味着少 8 MB 的内存带宽和缓存压力。

容器选择的实际影响

对于几百个元素以内的集合:

vector 的线性查找  常常快过  map 的 O(log n)
vector 的线性查找  常常快过  unordered_map 的 O(1)

因为 vector 是一次连续扫描(硬件预取器可以完美预测),而树和哈希表是指针追逐(每次都是 cache miss)。渐进复杂度在小规模下经常输给常数因子。

经验阈值(仅供参考,务必实测):

  • < 32 个元素:vector + 线性查找几乎总是最快
  • 32–1000:视元素大小和比较代价而定
  • > 1000:哈希表/树的复杂度优势开始体现

预取

__builtin_prefetch(&data[i + 16]);   // GCC/Clang
_mm_prefetch((char*)&data[i+16], _MM_HINT_T0);   // Intel intrinsic

硬件预取器能自动识别顺序和固定步长的访问模式。手动预取只在访问模式不规则但可预测时(如链表遍历、哈希表探测)才有价值,而且很容易适得其反 —— 必须实测。

大页(Huge Pages)

4 KB 页意味着 1 GB 数据需要 262144 个页表项,TLB 装不下。2 MB 大页把这个数字降到 512。

# 透明大页(THP),自动
cat /sys/kernel/mm/transparent_hugepage/enabled
# 显式大页
mmap(..., MAP_HUGETLB, ...);
madvise(p, len, MADV_HUGEPAGE);

对大堆的数据库、JVM、科学计算通常有 5–20% 提升。副作用是内存放大和分配延迟抖动。

NUMA

多路服务器上,访问本地节点的内存比跨节点快 1.5–2 倍:

numactl --hardware              # 查看拓扑
numactl --cpunodebind=0 --membind=0 ./myprog

C++ 层面没有标准 API,需要用 libnuma 或依赖操作系统的 first-touch 策略(哪个线程第一次写某页,那页就分配在该线程所在节点)。

优化的正确顺序

1. 测量(perf / VTune)——【绝不跳过这一步】
2. 改进算法与数据结构
3. 改进内存布局(AoS→SoA、成员排序、减少间接层)
4. 减少分配次数(arena / pmr / reserve)
5. 消除 false sharing
6. 向量化(自动 or 手写 SIMD)
7. 手动预取、大页、NUMA 绑定
perf stat -e cache-misses,cache-references,LLC-load-misses ./myprog
perf record -e cache-misses ./myprog && perf report
valgrind --tool=cachegrind ./myprog

十六、内存安全机制

现代二进制加固是一套组合拳,单独任何一项都能被绕过。

全景

机制 防什么 谁实现 开启方式
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
gcc -fPIE -pie main.c            # 现代发行版默认
gcc -no-pie main.c               # 关掉,固定加载在 0x400000
cat /proc/sys/kernel/randomize_va_space   # 0=关 1=栈/mmap/库 2=+堆(默认)
setarch $(uname -m) -R ./prog    # 临时关掉(调试用)

# Windows
# /DYNAMICBASE(VS2012 起默认)+ /HIGHENTROPYVA(64 位)
dumpbin /headers app.exe | findstr /i "Dynamic base"

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 也设为只读
gcc -Wl,-z,relro,-z,now      # Full RELRO
checksec --file=./myprog     # 查看所有加固状态

代价是启动变慢(一次性解析所有符号)。

FORTIFY_SOURCE

编译器在能推断出缓冲区大小时,把 memcpy/strcpy/sprintf 替换成带检查的版本:

char buf[10];
strcpy(buf, very_long_string);   // 编译期能判断则直接报错,否则运行期 abort

必须配合 -O1 以上才生效(需要优化器做常量传播)。

CFI / CFG

限制间接跳转(虚函数调用、函数指针)的合法目标集合:

clang -fsanitize=cfi -flto -fvisibility=hidden     # LLVM CFI
cl /guard:cf                                       # Windows Control Flow Guard

对 C++ 尤其有意义:虚函数调用是 ROP 的常见跳板,CFI 能保证 vptr 被篡改后跳不到任意位置。

Shadow Stack(Intel CET)

硬件维护一份只有 call/ret 能修改的影子栈,返回时比对。这是对 ROP 的硬件级根治:

gcc -fcf-protection=full     # 需要 CPU 支持(Tiger Lake / Zen 3 起)

一键检查

checksec --file=./myprog
# RELRO   STACK CANARY   NX   PIE   RPATH   RUNPATH   FORTIFY

推荐的生产编译选项:

-O2 -D_FORTIFY_SOURCE=2 \
-fstack-protector-strong -fstack-clash-protection \
-fPIE -pie -Wl,-z,relro,-z,now -Wl,-z,noexecstack \
-fcf-protection=full

十七、常见内存错误与排查

错误分类

错误 典型场景 后果
缓冲区溢出 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* f() { int x = 0; return &x; }             // 返回栈上对象地址
auto& r = v[0]; v.push_back(1); r = 2;         // 扩容后引用失效
std::string_view sv = std::string("hi");       // 临时 string 立即销毁
const char* p = std::string("hi").c_str();     // 同上
for (auto it = m.begin(); it != m.end(); ++it)
    if (cond) m.erase(it);                     // erase 后 it 失效(应 it = m.erase(it))
delete[] p;  /* 但 p 是 new 出来的 */           // new/delete 与 new[]/delete[] 必须配对
Base* b = new Derived; delete b;               // Base 析构非虚 → Derived 部分不析构
memcpy(&obj, src, sizeof(obj));                // obj 非平凡可复制 → UB

Sanitizer —— 首选工具

# AddressSanitizer:越界、UAF、double free、泄漏。开销 ~2x
g++ -fsanitize=address -fno-omit-frame-pointer -g main.cpp

# UndefinedBehaviorSanitizer:整数溢出、空指针解引用、对齐、类型混淆
g++ -fsanitize=undefined -g main.cpp

# ThreadSanitizer:数据竞争。开销 ~5-15x(不能和 ASan 同用)
g++ -fsanitize=thread -g main.cpp

# MemorySanitizer:未初始化读(仅 Clang,要求所有依赖都用 MSan 编译)
clang++ -fsanitize=memory -fsanitize-memory-track-origins -g main.cpp

# 推荐日常组合
g++ -fsanitize=address,undefined -fno-omit-frame-pointer -g -O1

ASan 的原理是影子内存 + 红区(redzone):每 8 字节应用内存对应 1 字节影子内存记录可用状态,在分配的对象前后插入毒化的红区,任何越界访问立即被捕获。

[红区][  对象  ][红区]
 毒化            毒化    ← 读写这里立刻报错并打印完整调用栈

Valgrind

valgrind --leak-check=full --show-leak-kinds=all --track-origins=yes ./myprog
valgrind --tool=massif ./myprog && ms_print massif.out.*   # 堆用量随时间变化
valgrind --tool=cachegrind ./myprog                        # 缓存命中分析
valgrind --tool=helgrind ./myprog                          # 竞争检测

ASan vs Valgrind:ASan 快得多(2x vs 20-50x)、需要重新编译、检测更精确;Valgrind 不需要重编译、能查未初始化读、能跑第三方二进制。日常开发用 ASan,疑难杂症上 Valgrind。

泄漏排查

# 快速定位
ASAN_OPTIONS=detect_leaks=1 ./myprog

# 生产环境的堆剖析
HEAPPROFILE=/tmp/prof ./myprog          # tcmalloc
MALLOC_CONF=prof:true ./myprog          # jemalloc
jeprof --show_bytes --pdf ./myprog jeprof.*.heap > prof.pdf

# 观察 RSS 趋势
while true; do ps -o rss= -p $PID; sleep 1; done

"内存不释放"不一定是泄漏:free 的内存可能被分配器缓存着没还给 OS(堆顶不连续空闲就 brk 不回去)。可以主动归还:

malloc_trim(0);                          // glibc

静态分析

clang-tidy --checks='clang-analyzer-*,bugprone-*,cppcoreguidelines-*' main.cpp
cppcheck --enable=all main.cpp
g++ -fanalyzer main.c                    # GCC 10+ 内置静态分析器

编译期就该开满的警告:

-Wall -Wextra -Wpedantic -Wshadow -Wconversion -Wold-style-cast \
-Wnon-virtual-dtor -Woverloaded-virtual -Wnull-dereference -Wdouble-promotion

从源头避免

做法 消除的问题
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 + 手工看
nm -C prog | grep ' [TUWtBD] '     # 快速看符号类型
size -A prog                       # .text/.data/.bss 各占多少
readelf -lW prog                   # program headers(运行时的 segment 划分)

看进程里有什么

目的 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 —
# 找出占内存最多的映射
pmap -x $PID | sort -k3 -n | tail -20

# smaps_rollup:一个进程的内存汇总(比 smaps 快得多)
cat /proc/$PID/smaps_rollup

看内存怎么被用掉的

# 堆用量随时间变化
valgrind --tool=massif ./prog && ms_print massif.out.*

# 泄漏
valgrind --leak-check=full --track-origins=yes ./prog
ASAN_OPTIONS=detect_leaks=1 ./prog

# 分配热点(tcmalloc / jemalloc)
HEAPPROFILE=/tmp/p ./prog
MALLOC_CONF=prof:true,prof_prefix:/tmp/jeprof ./prog

# glibc 自带的统计
malloc_stats();      // 打印 arena 使用情况
mallinfo2();         // 结构化统计

看性能瓶颈

perf stat -e cache-misses,cache-references,page-faults,dTLB-load-misses ./prog
perf record -e cache-misses -g ./prog && perf report
perf c2c record ./prog && perf c2c report        # false sharing 专用
valgrind --tool=cachegrind ./prog

看对象布局

clang++ -Xclang -fdump-record-layouts -c foo.cpp
g++ -fdump-lang-class -c foo.cpp
cl /d1reportSingleClassLayoutMyClass foo.cpp

也可以直接在代码里断言:

static_assert(sizeof(Foo) == 16);
static_assert(alignof(Foo) == 8);
static_assert(offsetof(Foo, bar) == 8);
static_assert(std::is_standard_layout_v<Foo>);
static_assert(std::is_trivially_copyable_v<Foo>);

在线工具

  • 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 或实测验证。