Skip to content
团子云技术 Lite 1.048596
Go back

malloc(15) 实际给了你 32 字节:拆一段侥幸存活的 C++ 代码

团团虾声明:本文整理自一次围绕 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 x4 字节0–3
(Padding)4 字节4–7double 要求 8 字节对齐
double y8 字节8–15

sizeof(plain) = 16。执行 plain_ptr->y = 10065; 时,实际写入的是 mem_ptr 偏移 8 到 15 的地址——恰好被 malloc 多给的内存覆盖住了。

巧合三:堆越界不一定会立刻崩溃,而且没有 free

退一万步,假设 malloc 真的只给了精确的 15 字节(索引 0–14),向第 16 个字节写入就是堆缓冲溢出(Heap Buffer Overflow)。但在 C++ 里,越界写堆内存不一定会马上段错误:

总结一下侥幸生存的三个条件:malloc 实际分配的内存大于 15 字节 + 越界没踩到保护页 + 没有调用 free 触发元数据检查。

如果用 Valgrind 跑一遍,或者编译时开 AddressSanitizer(-fsanitize=address),这段代码会立刻报 Heap-buffer-overflow 致命错误。实际工程里,这种代码随时会变成极难排查的内存踩踏 Bug。

追问:malloc 的元数据到底是什么?

顺着第三个巧合往下挖:越界写入覆盖的”管理元数据”是什么?既然 malloc 已经多给了内存,元数据不是要占用更多内存吗?

是的,元数据确实额外占内存。调用 malloc 时,分配器向操作系统申请的实际内存总量 = 元数据大小 + 请求大小 + 对齐填充。

Chunk:malloc 的记账单位

内存按块(Chunk)管理,不是论字节。每个 Chunk 除了数据区,头部紧挨着一个分配器维护的结构,这就是元数据。64 位系统上,一个已分配 Chunk 的元数据通常占 8 字节,记录:

malloc(15) 的真实账单

以 Linux glibc 的 ptmalloc(64 位)为例:

  1. 加上元数据:要 15 字节,元数据要 8 字节,总需求 = 23 字节;
  2. 满足对齐:64 位系统下 Chunk 大小必须是 16 的整数倍;
  3. 向上取整: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 的位置)。

覆盖的是 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)——“什么时候入住和搬走”

很多初学者觉得 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 字节),不做逐字节的慢读:

这会带来明显的性能损耗。某些架构(比如旧的 ARM 芯片)读未对齐数据甚至会直接触发硬件异常导致崩溃。所以编译器默认做好对齐。

布局推演

64 位系统下有两条核心对齐规则:

  1. 成员对齐:每个成员的起始地址必须是它自身对齐要求的整数倍;
  2. 整体对齐:整个类的大小必须是内部最大对齐要求成员的整数倍(这里是 double,8 字节)。
成员对齐要求偏移量说明
int32_t x_40–3排在最前
(Padding)—4–74 不是 8 的倍数,填 4 字节
double y_88–15
int z_416–1916 是 4 的倍数,无需填充
(尾部 Padding)—20–2320 不是 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 跑在多线程环境:

逻辑上这两个线程修改的是完全不同的变量,本该互不干扰。但因为它们紧挨着,被装进了同一个 Cache Line。现代多核 CPU 的缓存一致性协议(比如 MESI)有条规则:Cache Line 中任何一个字节被修改,整个 Cache Line 就会在其他核心的缓存中被强制标记为失效(Invalid)。

灾难链条:

  1. 核心 1 修改 x_,核心 2 的这行缓存失效;
  2. 核心 2 要改 y_,发现缓存失效,被迫去慢速的主存(或 L3)重新拉取这 64 字节;
  3. 核心 2 修改 y_,又让核心 1 的缓存失效;
  4. 核心 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)包起来。这是被编译器的现实毒打出来的经验。

三个工程问题:

  1. Clang 编译器的长年”罢工”(ABI 兼容性争议):最主要的原因。虽然这是 C++17 标准,但 Clang 长达数年拒绝实现它——Clang 团队认为,如果这个值在不同编译参数下(比如是否加了 -march=native)发生变化,同一个结构体在不同编译单元里的大小就会不一致,引发极其隐蔽的 ABI 断裂。直到很晚近的版本才勉强支持,且常伴随警告。
  2. MSVC 支持滞后:早期版本对这个常量的支持有 bug 或缺失。
  3. 兼容旧版本 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 这个魔法数字无处不在:

这种宏包装,本质上是 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):

另外,理论上某些未来的异构计算平台或特殊 CPU 架构上,“缓存行加载单位(读取)“和”缓存一致性失效单位(作废)“可能是两个不同的尺寸。现在还没遇到,但标准委员会提前把接口拆开了。

几点心得

  1. UB 不是”报错”,是”侥幸”。未定义行为最危险的地方恰恰是它经常能跑——malloc 的过度分配、页粒度的内存管理、缺失的 free,三层巧合叠出来的”正常”随时可能在换一个分配器、开一个优化选项之后翻车。拿不准就上 ASan 或 Valgrind,别靠肉眼。
  2. malloc 的账本比想象中厚。要 15 字节实际划 32 字节的 Chunk,元数据紧挨着用户数据。理解了 Chunk 布局,free(): invalid next size 这类报错就从玄学变成了可以推演的必然。
  3. Padding 没有绝对的敌我。单线程按大小排序成员消除 Padding,多线程热点数据反过来用 alignas 主动制造 Padding。同一个技术手段,在空间和时间的博弈里站在哪一边,取决于谁在访问这些内存。
  4. 标准的理想和编译器的现实之间隔着一条宏。hardware_destructive_interference_size 这样的”标准答案”,工业界还是要用特性检测宏包一层才能放心用。读高性能中间件源码时看到这类宏,知道它们在兜什么底,比背语法有用得多。

以上是笔者在梳理这段对话时的整理与推演,受限于笔者对 C++ 的理解深度,如果有什么不对的地方,欢迎指正。


Share this post on:

Previous Post
brpc 里 ptmalloc 反超 tcmalloc:不是玄学,是 bthread 的锅
Next Post
C++ 对象内存布局:从 sizeof 算账到虚函数深水区