3、内存模型与布局:栈与堆的区别、内存对齐、结构体与类的内存布局、大小端模式

大家好,我是你们的老朋友。今天我们来聊聊 C++ 里一个绕不开的话题——内存模型与布局。说实话,这块内容我当年刚入行时也觉得挺枯燥的,不就是栈和堆嘛,new 和 delete 谁不会用?但后来在项目里踩过几次坑,才真正意识到:不理解内存布局,你写的代码就像在雷区里跳舞,随时可能炸。

好,咱们不废话,直接进入正题。

3.1 栈与堆:两个世界,两种活法

先问大家一个问题:你写的局部变量存在哪?用 new 申请的内存又存在哪?

答案是:局部变量在栈上,new 出来的在堆上。但这两者的区别远不止「位置不同」这么简单。

栈(Stack)

  • 自动管理:函数调用时自动分配,返回时自动释放。你不需要操心。
  • 速度快:因为栈是连续的内存块,CPU 缓存友好,分配释放就是移动一下栈指针,几乎零开销。
  • 大小有限:通常只有几 MB(Windows 默认 1MB,Linux 默认 8MB 左右)。递归太深或者局部变量太大,就会栈溢出。

我个人习惯:能用栈就别用堆。栈上的对象生命周期清晰,不容易内存泄漏。我在项目中见过太多因为滥用堆导致的内存碎片问题,最后性能一塌糊涂。

堆(Heap)

  • 手动管理:你用 newmalloc 申请,用 deletefree 释放。忘了释放?内存泄漏等着你。
  • 速度慢:堆分配需要查找空闲块、可能触发系统调用,开销比栈大得多。
  • 空间大:堆的大小受限于虚拟内存,你可以申请 GB 级别的内存。
  • 容易产生碎片:频繁申请释放小块内存,堆会变得支离破碎。

我曾经接手过一个服务端项目,里面大量使用 std::list 配合频繁的 new/delete,结果运行几天后内存碎片严重,分配速度越来越慢,最后不得不重启。后来改成内存池 + 连续容器,问题才解决。

说白了,栈和堆就是两种不同的资源管理策略。栈适合小对象、生命周期短的数据;堆适合大对象、生命周期需要灵活控制的数据。

3.2 内存对齐:为什么编译器会在结构体里「塞」空白?

你有没有遇到过这种情况:一个结构体里明明只有几个 int 和 char,但 sizeof 算出来比你想的大?

这就是内存对齐在作怪。

为什么要有内存对齐?因为 CPU 读取内存不是按字节来的,而是按「字」来的(比如 4 字节、8 字节)。如果数据没有对齐到合适的地址,CPU 可能需要两次内存访问才能读完,性能直接打折扣。

对齐规则

  1. 每个成员变量的起始地址必须是其自身大小的整数倍。
  2. 结构体的总大小必须是最大成员大小的整数倍。
  3. 编译器可能会在成员之间或结构体末尾插入填充字节(padding)。

来看个例子:

struct Example {
    char a;    // 1 字节
    int  b;    // 4 字节
    char c;    // 1 字节
};
// 你猜 sizeof(Example) 是多少?

直觉上:1 + 4 + 1 = 6 字节。但实际呢?

成员偏移大小说明
a01没问题
b44需要对齐到 4 的倍数,所以跳过 1-3 字节
c81没问题
填充9-113结构体总大小需是 4 的倍数

所以 sizeof(Example) = 12。白白浪费了 6 个字节。

小技巧:把大的成员往前放,小的往后放,可以减少填充。比如把上面的 int b 放到最前面,char achar c 放后面,大小就变成 8 字节了。

3.3 结构体与类的内存布局:C++ 的「隐藏」开销

在 C++ 里,结构体和类本质上是一样的,唯一的区别是默认访问权限(struct 默认 public,class 默认 private)。但它们的内存布局却有不少门道。

普通结构体/类

成员变量按声明顺序排列,受对齐规则影响。没有虚函数时,内存布局就是成员变量的简单叠加。

有虚函数的类

一旦类里有虚函数,编译器就会在对象开头插入一个虚函数表指针(vptr),指向一个虚函数表(vtable)。这个指针通常占 4 字节(32 位)或 8 字节(64 位)。

class Base {
public:
    virtual void foo() {}
    int x;
};
// sizeof(Base) 在 64 位系统上通常是 16(8 字节 vptr + 4 字节 int + 4 字节填充)

我在项目中遇到过一个性能问题:一个类里只有两个 int 成员,但因为继承了某个带虚函数的基类,每个对象凭空多了 8 字节的 vptr。当时我们创建了上千万个这样的对象,内存直接爆了。后来用 CRTP(奇异递归模板模式)替代虚函数,才把内存降下来。

继承与内存布局

单继承时,子类的成员变量排在父类之后。多继承时,情况就复杂了——可能会有多个 vptr,甚至需要调整指针偏移。

嗯,这里要注意:多继承的布局是编译器相关的,不同编译器可能有差异。我建议你尽量避免复杂多继承,除非你真的清楚自己在做什么。

3.4 大小端模式:字节序的「坑」

大小端,说白了就是多字节数据在内存里怎么排的问题。

  • 小端(Little-Endian):低位字节在低地址。x86、x64 架构都是小端。
  • 大端(Big-Endian):高位字节在低地址。网络协议、某些嵌入式芯片用大端。

举个例子,整数 0x12345678 在内存中的存储:

地址小端大端
0x000x780x12
0x010x560x34
0x020x340x56
0x030x120x78

我曾经在做一个跨平台网络通信模块时,直接拿 memcpy 把结构体塞进网络包。结果在 x86 上测试好好的,部署到 ARM 大端设备上,数据全乱套了。从那以后,我所有跨平台通信都老老实实用 htonl/ntohl 做字节序转换。

怎么判断当前系统是大端还是小端?写个简单代码就行:

bool isLittleEndian() {
    int x = 1;
    return *(char*)&x == 1;
}

如果 *(char*)&x 是 1,说明最低地址存的是最低字节,那就是小端。

知识体系总览

下面这张图把本章的核心逻辑串起来了,大家可以对照着看:

内存模型与布局 · 知识体系 栈与堆 • 栈:自动管理,速度快,大小有限 • 堆:手动管理,速度慢,空间大 • 选择原则:小对象用栈,大对象用堆 • 避坑:避免频繁堆分配导致碎片 内存对齐 • 原因:CPU 按字读取,对齐提升性能 • 规则:成员地址是自身大小的整数倍 • 总大小是最大成员的整数倍 • 优化:大成员放前面,减少填充 结构体与类布局 • 普通类:成员按声明顺序排列 • 虚函数类:开头插入 vptr(8 字节) • 单继承:子类成员排在父类之后 • 多继承:可能有多个 vptr,布局复杂 大小端模式 • 小端:低位在低地址(x86/x64) • 大端:高位在低地址(网络协议) • 跨平台通信必须做字节序转换 • 检测:用 *(char*)&x 判断 相互影响,共同决定内存布局 理解内存布局 = 写出高性能、可预测的 C++ 代码

好了,这一章的内容就到这里。内存模型这块东西多,但都是基本功。你想想看,如果你连自己写的对象在内存里长什么样都不知道,怎么去优化性能?怎么去排查那些诡异的崩溃?

下一章我们会深入聊聊「指针与引用」——嗯,到时候再细说。


公众号:蓝海资料掘金营,微信 deep3321