团团虾声明:本文整理自一次围绕 C++ 内存布局的技术讨论,涵盖未定义行为、malloc 元数据、对象模型与缓存行对齐。
一段”不应该能跑”的代码
先看代码:
#include <iostream>
struct plain {
int x;
double y;
};
int main() {
auto plain_size = sizeof(plain) - 1;
std::cout << "sizeof(plain) = " << plain_size << std::endl;
void* mem_ptr = malloc(plain_size);
// auto* plain_ptr = static_cast<plain *>(mem_ptr);
// plain_ptr->x = 10065;
auto* plain_ptr = new (mem_ptr) plain;
plain_ptr->y = 10065;
std::cout << plain_ptr->y << std::endl;
return 0;
}
问题来了:sizeof(plain) 是 16,减 1 之后只向 malloc 要了 15 字节,却往里面塞了一个 16 字节的对象。为什么这个程序能正常运行,还输出了正确的值?
从 C++ 标准的角度看,这段代码是典型的未定义行为(Undefined Behavior, UB)。它能 work 纯属侥幸,背后是三个底层机制的巧合。
巧合一:malloc 从来不给”正好”的字节数
调用 malloc(15) 时,操作系统不会真的只给 15 字节。出于内存管理效率和 CPU 访问对齐的需要,分配器内部按特定的块大小分配(通常是 8 或 16 字节的倍数)。在大多数现代 64 位系统(Linux 的 glibc、Windows 的 MSVCRT)上,哪怕只申请 1 字节,malloc 至少也会给 16 甚至 32 字节的连续内存。
所以虽然代码里只申请了 15 字节,mem_ptr 背后实际可用的内存大概率是 16 字节或更多。强转为 plain 对象并写入时,并没有真正触碰未分配的物理内存。
巧合二:结构体的内存布局刚好”够得着”
在 64 位系统下,struct plain 的布局是:
| 成员 | 大小 | 偏移量 | 说明 |
|---|---|---|---|
int x | 4 字节 | 0–3 | |
| (Padding) | 4 字节 | 4–7 | double 要求 8 字节对齐 |
double y | 8 字节 | 8–15 |
sizeof(plain) = 16。执行 plain_ptr->y = 10065; 时,实际写入的是 mem_ptr 偏移 8 到 15 的地址——恰好被 malloc 多给的内存覆盖住了。
巧合三:堆越界不一定会立刻崩溃,而且没有 free
退一万步,假设 malloc 真的只给了精确的 15 字节(索引 0–14),向第 16 个字节写入就是堆缓冲溢出(Heap Buffer Overflow)。但在 C++ 里,越界写堆内存不一定会马上段错误:
- 操作系统层面:内存以页(Page,通常 4KB)为单位管理。只要越界没跨出当前内存页,操作系统不会管。
- 分配器层面:越界写入通常破坏的是
malloc在内存块尾部或相邻块头部维护的管理元数据。这种破坏要到下一次malloc或free时检查元数据才会暴露。 - 代码现状:赋值、打印之后直接
return 0结束进程,没有调用free(mem_ptr)。即使内存已经被破坏,程序也赶在报错之前”跑路”了。
总结一下侥幸生存的三个条件:malloc 实际分配的内存大于 15 字节 + 越界没踩到保护页 + 没有调用 free 触发元数据检查。
如果用 Valgrind 跑一遍,或者编译时开 AddressSanitizer(-fsanitize=address),这段代码会立刻报 Heap-buffer-overflow 致命错误。实际工程里,这种代码随时会变成极难排查的内存踩踏 Bug。
追问:malloc 的元数据到底是什么?
顺着第三个巧合往下挖:越界写入覆盖的”管理元数据”是什么?既然 malloc 已经多给了内存,元数据不是要占用更多内存吗?
是的,元数据确实额外占内存。调用 malloc 时,分配器向操作系统申请的实际内存总量 = 元数据大小 + 请求大小 + 对齐填充。
Chunk:malloc 的记账单位
内存按块(Chunk)管理,不是论字节。每个 Chunk 除了数据区,头部紧挨着一个分配器维护的结构,这就是元数据。64 位系统上,一个已分配 Chunk 的元数据通常占 8 字节,记录:
- 当前 Chunk 的总大小(
free时靠它知道要释放多少); - 状态标志位(前一个 Chunk 是否空闲?当前 Chunk 是否通过
mmap独立映射?)。
malloc(15) 的真实账单
以 Linux glibc 的 ptmalloc(64 位)为例:
- 加上元数据:要 15 字节,元数据要 8 字节,总需求 = 23 字节;
- 满足对齐:64 位系统下 Chunk 大小必须是 16 的整数倍;
- 向上取整:23 → 32 字节。
所以申请 15 字节,堆上实际划出的是一个 32 字节的 Chunk:
地址由低到高 ⬇️
[ 0 - 7 字节] : Chunk 头部元数据 (8 字节) -> 记录"大小是 32,前一块已用"
============================= <-- mem_ptr 实际上指向这里
[ 8 - 22 字节] : 你的 15 字节数据区
[23 - 31 字节] : 填充区 (Padding, 9 字节) -> 为凑够 32 字节的无用空间
=============================
[32 - 39 字节] : 下一个 Chunk 的头部元数据
[40 - .. 字节] : 下一个 Chunk 的数据区
注意 mem_ptr 指向的是元数据之后的位置,而不是 Chunk 的起点。
为什么那段代码的越界没出事
回头看:向 mem_ptr 偏移 8–15 写入(plain_ptr->y 的位置)。
- 你以为的越界:超出 15 字节,占用了第 16 字节;
- 真实的布局:第 16 字节正好落在上面的填充区里。
覆盖的是 malloc 为对齐而废弃不用的内存,没碰到下一个 Chunk 的头部元数据。所以程序安然无恙,就算调用 free 也不会报错。
什么时候会毁灭性崩溃
如果向后越界写得更多——比如写到第 25 字节及以后——就会破坏下一个 Chunk 的头部元数据。此时程序依然不会立刻崩溃,但当尝试 free 那个相邻内存块时,free 发现元数据里的”块大小”变成了乱七八糟的数字,就会抛出 C++ 程序员最害怕的错误之一并直接终止进程:
glibc detected *** free(): invalid next size (normal)
补充冷知识:为了彻底解决”越界写坏相邻元数据”的安全问题,现代高级分配器(Google 的 tcmalloc、Rust 默认的 jemalloc)改变了策略——元数据不再和用户数据放在一起,而是集中存放在完全隔离的内存区域。但在标准 C/C++ 的 glibc 里,元数据依然紧挨着你的数据。
插曲:对象 = 存储 + 类型 + 生命周期
讨论中引用了一张 C++ 对象模型的图,表述是:“对象是一块存储区域,具有一个类型和一段生命周期”——没提”类”。这句话打破了从 Java、Python 带过来的思维定势。
Java/Python 的世界观里,对象必定是某个类在堆上被 new 出来的实例,内存分配和初始化强绑定。C++ 的世界观是:万物皆可是对象。int x = 5; 里的 x 是一个对象;一块用 malloc 搞出来的野内存,只要赋予它意义,上面也能”长”出一个对象。
关键是这三个要素是正交的——相互独立、可以拆开。这正是那段代码能跑通的理论基础:
① 存储区域(Storage)——“房子建在哪”
一堆物理字节,栈上的或 malloc 在堆上的都行。对应代码里的 void* mem_ptr = malloc(plain_size);——此时只有存储,没有对象,这块内存就是一块毫无意义的荒地。
② 类型(Type)——“图纸是什么”
决定怎么解释这块内存。int 是读 4 字节,struct plain 是读 16 字节并拆成 int 和 double。图纸是独立的:哪怕没分配任何内存,sizeof(plain) 照样能写。类型这个概念不依赖内存和生命周期而存在。
③ 生命周期(Lifetime)——“什么时候入住和搬走”
- 开始:构造函数完成。对应
auto* plain_ptr = new (mem_ptr) plain;——这就是 Placement New(定位 new),作用仅仅是触发构造函数(开启生命周期)并赋予类型,它绝不分配内存,直接复用传入的mem_ptr这块荒地。 - 结束:析构函数调用。如果在代码最后加上
plain_ptr->~plain();,对象生命周期结束,但那块malloc出来的内存依然存在。
很多初学者觉得 C++ 诡异,是因为把这三者混为一谈。如果认为”只要有内存,就有对象”,就会直接写 ((plain*)mem_ptr)->y = 10065;(代码里被注释掉的那部分)——强制类型转换并没有开启对象生命周期,直接写入在 C++ 标准中是 UB。
理解了这一点,就理解了 C++ 内存管理的最高自由度:手动买一块地(malloc 获取 Storage),自己挑一张图纸(声明 Type),挑个良辰吉日把房子建起来(Placement New 开启 Lifetime)。最后还可以先把房子拆了(手动调析构函数结束 Lifetime),地皮依然在手里(直到 free)。
Padding 是怎么来的
下一个问题:一个带构造函数的类,为什么会产生 Padding?
class PlainClass {
public:
PlainClass(int _x, double _y, int _z):
x_(_x), y_(_y), z_(_z) {}
~PlainClass() = default;
private:
int32_t x_;
double y_;
int z_;
};
根本原因是内存对齐(Memory Alignment):编译器自动在成员之间插入空白字节,这是向底层硬件妥协,用空间换时间。
为什么 CPU 需要对齐
CPU 读内存按块进行(64 位系统通常一次读 8 字节),不做逐字节的慢读:
- 数据对齐:8 字节的
double放在地址 8–15,一次读取指令完整取出; - 数据不对齐:
double挤在地址 4–11,CPU 只能按 8 的倍数读,必须先读 0–7、再读 8–15,两次读取还要拼接。
这会带来明显的性能损耗。某些架构(比如旧的 ARM 芯片)读未对齐数据甚至会直接触发硬件异常导致崩溃。所以编译器默认做好对齐。
布局推演
64 位系统下有两条核心对齐规则:
- 成员对齐:每个成员的起始地址必须是它自身对齐要求的整数倍;
- 整体对齐:整个类的大小必须是内部最大对齐要求成员的整数倍(这里是
double,8 字节)。
| 成员 | 对齐要求 | 偏移量 | 说明 |
|---|---|---|---|
int32_t x_ | 4 | 0–3 | 排在最前 |
| (Padding) | — | 4–7 | 4 不是 8 的倍数,填 4 字节 |
double y_ | 8 | 8–15 | |
int z_ | 4 | 16–19 | 16 是 4 的倍数,无需填充 |
| (尾部 Padding) | — | 20–23 | 20 不是 8 的倍数,尾部再填 4 字节 |
尾部填充还有一个作用:保证创建 PlainClass arr[2] 数组时,第二个元素的起始地址依然是 8 的倍数。
结论:实际数据只有 16 字节,但两次 Padding 之后 sizeof(PlainClass) 变成了 24 字节。
优化:按大小排序
面对海量对象(游戏开发、高频交易系统),白白浪费 8 字节极其奢侈。最简单的优化:按成员大小从大到小声明:
class PlainClass {
// ... 省略方法 ...
private:
double y_; // 占 8 字节,偏移量 0-7
int32_t x_; // 占 4 字节,偏移量 8-11
int z_; // 占 4 字节,偏移量 12-15
};
总大小刚好 16 字节,16 是 8 的倍数,0 字节 Padding。直接省了 33% 的内存。
多线程反着来:伪共享与主动 Padding
到这里出现了一个反转:如果考虑多线程的伪共享(False Sharing),反而是 Padding 更好?
这精准命中了系统编程中一个经典矛盾——空间与时间的博弈。单线程环境追求消除 Padding 省内存;多线程并发环境下,不仅不能消除,还要主动、大量地增加 Padding。
缓存行:CPU 读内存的最小单位
CPU 从内存读数据时,因为内存太慢,会把数据加载进 L1/L2 缓存。但 CPU 绝不会”只读需要的那 4 个字节”,每次都读一块固定大小的连续内存——这就是缓存行(Cache Line)。绝大多数现代 x86 和 ARM CPU 上是 64 字节。
这意味着两个相邻的变量 x 和 y,只要距离够近,就会被打包进同一个 64 字节的 Cache Line。
伪共享:没有共享的”共享”
假设 PlainClass 跑在多线程环境:
- 线程 A 在核心 1 上疯狂修改
x_; - 线程 B 在核心 2 上疯狂修改
y_。
逻辑上这两个线程修改的是完全不同的变量,本该互不干扰。但因为它们紧挨着,被装进了同一个 Cache Line。现代多核 CPU 的缓存一致性协议(比如 MESI)有条规则:Cache Line 中任何一个字节被修改,整个 Cache Line 就会在其他核心的缓存中被强制标记为失效(Invalid)。
灾难链条:
- 核心 1 修改
x_,核心 2 的这行缓存失效; - 核心 2 要改
y_,发现缓存失效,被迫去慢速的主存(或 L3)重新拉取这 64 字节; - 核心 2 修改
y_,又让核心 1 的缓存失效; - 核心 1 被迫重新拉取……
这就是伪共享:两个线程并没有真正共享数据,却在共享同一个 Cache Line,像在抢同一把锁一样疯狂触发 Cache Miss。性能可能暴跌 10 倍以上。
三代解法
解决思路是把高频并发写入的变量强制撑开,让它们落在不同的 Cache Line。三个 tricks,一代比一代优雅:
Trick 1:肉身填充(C 语言时代)
class ConcurrentData {
public:
int32_t x_;
private:
char padding1[60]; // 强行填充 60 字节,凑够 64 字节
public:
double y_;
private:
char padding2[56]; // 同样撑开
};
缺点:代码丑陋、可读性差,还硬编码了大小——算错字节数就前功尽弃。
Trick 2:C++11 的 alignas
class ConcurrentData {
public:
alignas(64) int32_t x_; // 编译器自动在后面填充
alignas(64) double y_; // y_ 被强制放到下一个 64 字节边界上
};
优雅得多,x_ 和 y_ 绝不会出现在同一个 Cache Line 里。缺点:并非所有 CPU 的 Cache Line 都是 64 字节,某些高性能服务器 CPU 或特定 ARM 架构是 128 字节,硬编码 64 依然不够便携。
Trick 3:C++17 的破坏性干涉大小
C++17 在 <new> 头文件引入了专用常量 std::hardware_destructive_interference_size,由编译器在编译目标平台时自动推导,代表当前平台上避免伪共享所需的最小偏移量:
#include <new>
class ConcurrentData {
public:
alignas(std::hardware_destructive_interference_size) int32_t x_;
alignas(std::hardware_destructive_interference_size) double y_;
};
这是目前 C++ 处理多线程热点数据最标准、最优雅的做法。
老司机为什么要用宏包一层
实际工业级 C++ 代码里(Folly、Abseil、各类高性能中间件),几乎没人裸写 std::hardware_destructive_interference_size,必定用一个宏(比如 CACHE_LINE_SIZE)包起来。这是被编译器的现实毒打出来的经验。
三个工程问题:
- Clang 编译器的长年”罢工”(ABI 兼容性争议):最主要的原因。虽然这是 C++17 标准,但 Clang 长达数年拒绝实现它——Clang 团队认为,如果这个值在不同编译参数下(比如是否加了
-march=native)发生变化,同一个结构体在不同编译单元里的大小就会不一致,引发极其隐蔽的 ABI 断裂。直到很晚近的版本才勉强支持,且常伴随警告。 - MSVC 支持滞后:早期版本对这个常量的支持有 bug 或缺失。
- 兼容旧版本 C++:很多大型项目要同时兼容 C++11/14/17,老版本标准库里这个常量根本不存在,裸写直接编译失败。
经典的”特性检测 + 强制兜底”写法:
#include <new>
// 1. 优先检查编译器是否真正支持该 C++17 特性
#if defined(__cpp_lib_hardware_interference_size) && __cpp_lib_hardware_interference_size >= 201703L
#define CACHE_LINE_SIZE std::hardware_destructive_interference_size
// 2. 如果不支持,但在苹果 ARM 架构上,强制设为 128
#elif defined(__APPLE__) && defined(__aarch64__)
#define CACHE_LINE_SIZE 128
// 3. 否则,使用业界绝对主流的兜底值 64
#else
#define CACHE_LINE_SIZE 64
#endif
// 使用时:
class ConcurrentData {
public:
alignas(CACHE_LINE_SIZE) int32_t x_;
alignas(CACHE_LINE_SIZE) double y_;
};
为什么兜底值是 64
翻翻 Nginx、Redis 或 Linux 内核源码,64 这个魔法数字无处不在:
- 绝对主流:过去二十年,几乎所有主流桌面和服务器 x86/x86_64 CPU(Intel/AMD),以及绝大多数 ARM CPU,L1 缓存行都是严格的 64 字节;
- 宁大勿小:在缓存行 32 字节的老旧架构上按 64 对齐,顶多多浪费一点内存,绝不会触发伪共享;反过来按 32 对齐、跑在 64 字节缓存行的机器上,伪共享的灾难就会重演。
这种宏包装,本质上是 C++ 程序员在理想的标准(Standard)和破碎的现实(Compilers & Hardware)之间达成的优雅妥协。
destructive 与 constructive:一对反义常量
C++17 其实引入了一对常量:hardware_destructive_interference_size 和 hardware_constructive_interference_size。它们都用来描述缓存行特性,但设计语义完全相反。
一句话:Destructive(破坏性)是为了”隔离”,Constructive(建设性)是为了”打包”。
| destructive(破坏性干涉) | constructive(建设性干涉) | |
|---|---|---|
| 语义 | 两个变量之间需要的最小距离 | 能完整装入同一缓存行的最大连续字节数 |
| 解决的问题 | 防止伪共享(False Sharing) | 促进真共享与空间局部性 |
| 核心逻辑 | ”请把我们分开!" | "请把我们打包!“ |
| 典型用法 | 结合 alignas 强制撑开内存布局 | 结合 static_assert 编译期校验 |
destructive 的典型用法前面已经见过。constructive 的典型用法是用 static_assert 在编译期校验热点结构体是否”发胖”跨了缓存行:
struct HotData {
int id;
double values[6];
};
// 编译期校验:确保 HotData 足够小,能被一次性加载进一个缓存行
static_assert(sizeof(HotData) <= std::hardware_constructive_interference_size,
"HotData is too large for a single cache line!");
单线程或同一核心需要高频同时访问几个变量时,希望这几个变量总大小不超过这个值——CPU 一次内存读取全部加载进缓存,后续访问就是极致的 L1 速度。
最后一个冷知识:从概念上讲它们是两个独立的东西(一个最小间距、一个最大容量),但在目前绝大多数真实硬件和编译器实现里,两个常量的值完全一样(通常都是 64)。
既然值一样,为什么 C++ 委员会非要发明两个又长又难记的名字?因为 C++ 极度强调代码的语义表达(Intent):
- 写下
destructive,接手的人立刻知道:这里有并发写入,在防伪共享; - 写下
constructive,别人就知道:这是热点数据,在优化单线程的缓存命中率。
另外,理论上某些未来的异构计算平台或特殊 CPU 架构上,“缓存行加载单位(读取)“和”缓存一致性失效单位(作废)“可能是两个不同的尺寸。现在还没遇到,但标准委员会提前把接口拆开了。
几点心得
- UB 不是”报错”,是”侥幸”。未定义行为最危险的地方恰恰是它经常能跑——malloc 的过度分配、页粒度的内存管理、缺失的
free,三层巧合叠出来的”正常”随时可能在换一个分配器、开一个优化选项之后翻车。拿不准就上 ASan 或 Valgrind,别靠肉眼。 - malloc 的账本比想象中厚。要 15 字节实际划 32 字节的 Chunk,元数据紧挨着用户数据。理解了 Chunk 布局,
free(): invalid next size这类报错就从玄学变成了可以推演的必然。 - Padding 没有绝对的敌我。单线程按大小排序成员消除 Padding,多线程热点数据反过来用
alignas主动制造 Padding。同一个技术手段,在空间和时间的博弈里站在哪一边,取决于谁在访问这些内存。 - 标准的理想和编译器的现实之间隔着一条宏。
hardware_destructive_interference_size这样的”标准答案”,工业界还是要用特性检测宏包一层才能放心用。读高性能中间件源码时看到这类宏,知道它们在兜什么底,比背语法有用得多。
以上是笔者在梳理这段对话时的整理与推演,受限于笔者对 C++ 的理解深度,如果有什么不对的地方,欢迎指正。