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 函数、模板实例,可被合并去重

UW 是理解链接行为的钥匙,后面会反复用到。


四、符号、链接与 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.ocall 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; };

虚基类 VD只有一份。代价是:

  • 每条虚继承路径要多一个 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(主程序和启动时加载的库,偏移编译期确定,最快)和动态 TLSdlopen 的库,需要经过 __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++ ,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 时代那一大堆 typedefrebind 结构体现在都不用写了。

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 MyAllocallocator_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
L1 缓存 32–48 KB ~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 默认关闭 ASLRset 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 Explorercppinsightscppreference

本文以 Linux x86-64 + GCC/Clang 为主线,Windows/MSVC 差异已单独标注。具体数值(缓冲区大小、增长因子、SSO 容量等)属于实现细节,可能随版本变化 —— 关键结论请在目标平台上用 Compiler Explorer 或实测验证。