3、内存模型与布局:栈与堆的区别、内存对齐、结构体与类的内存布局、大小端模式
大家好,我是你们的老朋友。今天我们来聊聊 C++ 里一个绕不开的话题——内存模型与布局。说实话,这块内容我当年刚入行时也觉得挺枯燥的,不就是栈和堆嘛,new 和 delete 谁不会用?但后来在项目里踩过几次坑,才真正意识到:不理解内存布局,你写的代码就像在雷区里跳舞,随时可能炸。
好,咱们不废话,直接进入正题。
3.1 栈与堆:两个世界,两种活法
先问大家一个问题:你写的局部变量存在哪?用 new 申请的内存又存在哪?
答案是:局部变量在栈上,new 出来的在堆上。但这两者的区别远不止「位置不同」这么简单。
栈(Stack)
- 自动管理:函数调用时自动分配,返回时自动释放。你不需要操心。
- 速度快:因为栈是连续的内存块,CPU 缓存友好,分配释放就是移动一下栈指针,几乎零开销。
- 大小有限:通常只有几 MB(Windows 默认 1MB,Linux 默认 8MB 左右)。递归太深或者局部变量太大,就会栈溢出。
我个人习惯:能用栈就别用堆。栈上的对象生命周期清晰,不容易内存泄漏。我在项目中见过太多因为滥用堆导致的内存碎片问题,最后性能一塌糊涂。
堆(Heap)
- 手动管理:你用
new或malloc申请,用delete或free释放。忘了释放?内存泄漏等着你。 - 速度慢:堆分配需要查找空闲块、可能触发系统调用,开销比栈大得多。
- 空间大:堆的大小受限于虚拟内存,你可以申请 GB 级别的内存。
- 容易产生碎片:频繁申请释放小块内存,堆会变得支离破碎。
我曾经接手过一个服务端项目,里面大量使用 std::list 配合频繁的 new/delete,结果运行几天后内存碎片严重,分配速度越来越慢,最后不得不重启。后来改成内存池 + 连续容器,问题才解决。
说白了,栈和堆就是两种不同的资源管理策略。栈适合小对象、生命周期短的数据;堆适合大对象、生命周期需要灵活控制的数据。
3.2 内存对齐:为什么编译器会在结构体里「塞」空白?
你有没有遇到过这种情况:一个结构体里明明只有几个 int 和 char,但 sizeof 算出来比你想的大?
这就是内存对齐在作怪。
为什么要有内存对齐?因为 CPU 读取内存不是按字节来的,而是按「字」来的(比如 4 字节、8 字节)。如果数据没有对齐到合适的地址,CPU 可能需要两次内存访问才能读完,性能直接打折扣。
对齐规则
- 每个成员变量的起始地址必须是其自身大小的整数倍。
- 结构体的总大小必须是最大成员大小的整数倍。
- 编译器可能会在成员之间或结构体末尾插入填充字节(padding)。
来看个例子:
struct Example {
char a; // 1 字节
int b; // 4 字节
char c; // 1 字节
};
// 你猜 sizeof(Example) 是多少?
直觉上:1 + 4 + 1 = 6 字节。但实际呢?
| 成员 | 偏移 | 大小 | 说明 |
|---|---|---|---|
| a | 0 | 1 | 没问题 |
| b | 4 | 4 | 需要对齐到 4 的倍数,所以跳过 1-3 字节 |
| c | 8 | 1 | 没问题 |
| 填充 | 9-11 | 3 | 结构体总大小需是 4 的倍数 |
所以 sizeof(Example) = 12。白白浪费了 6 个字节。
小技巧:把大的成员往前放,小的往后放,可以减少填充。比如把上面的 int b 放到最前面,char a 和 char 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 在内存中的存储:
| 地址 | 小端 | 大端 |
|---|---|---|
| 0x00 | 0x78 | 0x12 |
| 0x01 | 0x56 | 0x34 |
| 0x02 | 0x34 | 0x56 |
| 0x03 | 0x12 | 0x78 |
我曾经在做一个跨平台网络通信模块时,直接拿 memcpy 把结构体塞进网络包。结果在 x86 上测试好好的,部署到 ARM 大端设备上,数据全乱套了。从那以后,我所有跨平台通信都老老实实用 htonl/ntohl 做字节序转换。
怎么判断当前系统是大端还是小端?写个简单代码就行:
bool isLittleEndian() {
int x = 1;
return *(char*)&x == 1;
}
如果 *(char*)&x 是 1,说明最低地址存的是最低字节,那就是小端。
知识体系总览
下面这张图把本章的核心逻辑串起来了,大家可以对照着看:
好了,这一章的内容就到这里。内存模型这块东西多,但都是基本功。你想想看,如果你连自己写的对象在内存里长什么样都不知道,怎么去优化性能?怎么去排查那些诡异的崩溃?
下一章我们会深入聊聊「指针与引用」——嗯,到时候再细说。