[TOC]

编程语言中的一些基础语法

一、结构体中的内存对齐问题

内存对齐就是编译器为了让 CPU 访问内存更快,按照一定的规则将结构体中的数据放置在特定的内存地址上,而不是简单地紧凑排列

1、为什么要进行内存对齐?

主要有两个原因:性能和平台兼容性

性能原因(主要原因): CPU 访问内存并不是一个字节一个字节读取的,而是以“块”(通常是 Word,即机器字长,如 4 字节或 8 字节)为单位读取。

  • 如果对齐: 读取一个 int(4字节)只需要一次内存访问。
  • 如果不对齐: 数据可能跨越了两个内存块,CPU 需要进行两次内存访问,然后通过位移和拼接才能拿到完整数据。这会大大降低效率。

平台原因(移植性): 某些硬件平台(如某些 ARM 架构或早期的 SPARC)要求特定类型的数据必须存储在特定的内存地址上(例如 int 必须在 4 的倍数地址)。如果访问未对齐的地址,可能会导致硬件异常(Crash)。

2. 内存对齐的三大黄金规则

在默认情况下(未手动指定对齐系数),编译器遵循以下规则:

规则一:成员对齐

结构体中第一个成员的偏移量(Offset)为 0。以后每个成员的偏移量必须是该成员大小的整数倍。

注意: 如果成员大小超过了默认对齐模数(通常是 8),则以默认对齐模数为准(但通常 int 是 4,double 是 8,都在范围内)。

规则二:填充(Padding)

如果上一个成员结束的位置,不满足下一个成员的对齐要求,编译器会在中间填充无意义的字节(Padding),直到满足规则一。

规则三:整体对齐

结构体的总大小,必须是该结构体中最大成员大小的整数倍。如果不满足,编译器会在结构体末尾填充字节。


3. 图解案例分析

为了直观理解,我们来看几个具体的例子(假设在 64 位系统下,默认对齐系数为 8)。

案例 A:未优化的顺

1
2
3
4
5
struct A {
char a; // 1 字节
int b; // 4 字节
short c; // 2 字节
};

内存布局分析:

  1. char a: Offset 0。占 1 字节 [0]
  2. int b: 大小 4 字节。Offset 必须是 4 的倍数。当前位置是 1。
    • 动作: 填充 3 个字节 [1-3]
    • 存放: b 存放在 [4-7]
  3. short c: 大小 2 字节。Offset 必须是 2 的倍数。当前位置是 8。
    • 存放: c 存放在 [8-9]
  4. 整体检查(规则三): 当前用了 0~9 共 10 个字节。
    • 结构体最大成员是 int (4 字节)。
    • 总大小必须是 4 的倍数。10 不是 4 的倍数,向上取整到 12。
    • 动作: 末尾填充 2 个字节 [10-11]

最终大小:sizeof(A) = 12 字节

0 1 2 3 4 5 6 7 8 9 10 11
a pad pad pad b b b b c c pad pad

案例 B:优化后的顺序(小技巧)

如果我们简单地调整一下成员定义的顺序,可以节省更多的内存空间:

1
2
3
4
5
struct B {
int b; // 4 字节
short c; // 2 字节
char a; // 1 字节
};

内存布局分析:

  1. int b: Offset 0。占 [0-3]
  2. short c: 大小 2。Offset 需要是 2 的倍数。当前位置 4。符合。占 [4-5]
  3. char a: 大小 1。Offset 需要是 1 的倍数。当前位置 6。符合。占 [6]
  4. 整体检查: 当前用了 7 个字节。
    • 最大成员 int (4 字节)。
    • 7 不是 4 的倍数,向上取整到 8。
    • 动作: 末尾填充 1 个字节 [7]

最终大小:sizeof(B) = 8 字节 (比案例 A 节省了 33% 的内存!)


案例 C:嵌套结构体

1
2
3
4
5
6
7
8
9
10
struct Inner {
char x;
int y;
}; // 根据规则,Inner 大小为 8 (1 + 3pad + 4)

struct Outer {
char c; // 1 字节
struct Inner i; // 结构体成员
double d; // 8 字节
};

关键点: 当结构体包含子结构体时,子结构体的对齐基准不是它的大小,而是子结构体中最大成员的大小

  • Inner 最大成员是 int (4 字节)。
  • 所以 Outer 中的 i 必须放在 4 的倍数地址上。

分析:

  1. c: [0]
  2. i: 对齐要求 4。当前 1,填充 [1-3]i 占用 [4-11] (8字节)。
  3. d: 对齐要求 8。当前 12,填充 [12-15]d 占用 [16-23]
  4. 整体大小:24 字节。最大成员是 double (8 字节),24 是 8 的倍数。

最终大小:sizeof(Outer) = 24 字节


4. 如何人为修改对齐规则?

有时候为了节省空间(如网络传输协议头),我们需要强制取消对齐(也叫“紧凑对齐”)。

方法 1:#pragma pack (最常用)

1
2
3
4
5
6
7
8
#pragma pack(push, 1) // 设置对齐系数为 1 (即紧凑排列)
struct Compact {
char a;
int b;
};
#pragma pack(pop) // 恢复之前的对齐设置

// sizeof(Compact) 将会是 5 (1 + 4),没有 padding
  • 规则变动: 此时成员对齐的模数变为 min(成员大小, pack参数)。因为 pack 是 1,所以所有成员都按 1 对齐。

方法 2:C++11 alignas (指定更宽的对齐)

alignas 通常用于为了缓存行(Cache Line)优化而强制增大对齐,不能用来减小对齐。

1
2
3
4
struct alignas(32) MyStruct {
int a;
};
// sizeof(MyStruct) 将会是 32
  1. 空间换时间: 内存对齐本质上是浪费少量内存空间来换取 CPU 的访问速度。
  2. 定义顺序很重要: 在定义结构体时,习惯将占用空间大的成员写在前面,小的写在后面(或者按照 大->小->大 的波浪形,但纯降序最稳),这样通常能获得最小的内存占用。
  3. 网络传输注意: 直接发送结构体二进制流时,务必使用 #pragma pack(1) 取消对齐,否则不同机器(32位/64位)或者不同编译器可能解析出错误的数据。

二、堆和栈的区别

这里讲的“堆”和“栈”是指内存管理中的概念,而不是数据结构中的堆(Heap,一种优先队列)和栈(Stack,后进先出结构)。虽然内存栈的运作方式确实符合数据结构栈的原理。

  • 栈(Stack)就像是酒店房间里的“记事本”:随手拿来就写,用完撕掉扔进垃圾桶,速度极快,但纸张大小有限,只适合记录临时信息。
  • 堆(Heap)就像是市中心的“仓库”:空间巨大,想存什么存什么,但你必须自己去申请仓库钥匙,用完还得自己去还钥匙(退租),否则仓库就被你占死了(内存泄漏)。

1、 核心区别对比表

特性 栈 (Stack) 堆 (Heap)
管理方式 全自动。编译器自动分配和释放。 手动。程序员控制 (new/delete, malloc/free)。
分配速度 极快。仅需移动栈指针。 较慢。操作系统需要搜索可用内存块。
空间大小 很小。通常几 MB (Windows 默认 1MB/2MB)。 很大。受限于虚拟内存,可达数 GB。
生命周期 函数级。函数结束,变量立即销毁。 程序级。直到程序员手动释放或程序结束。
碎片问题 无碎片。内存是连续的。 容易产生内存碎片(频繁分配释放导致)。
生长方向 向下生长(高地址 -> 低地址)。 向上生长(低地址 -> 高地址)。

2、 详细解

栈 (Stack)

  • What:它是 CPU 直接支持的内存区。CPU 有专门的寄存器(ESP/RSP)指向栈顶。
  • 怎么工作:当你调用一个函数时,函数的参数、返回地址、局部变量会依次被“压入”栈中。函数执行完毕,指针直接弹回原来的位置,这些数据就“逻辑上”消失了。
  • 优点:速度快到飞起,不存在内存泄漏问题。
  • 缺点:灵活性差,容量太小。如果你在栈上定义一个超大数组(例如 int a[10000000]),程序会直接崩溃(Stack Overflow,栈溢出)。

堆 (Heap)

  • What:它是 C++ 程序向操作系统“借”来的一大片空闲内存空间。
  • 怎么工作:当你写 int* p = new int[100]; 时,操作系统会在堆里寻找一块足够大的空地,把地址给你。这个数据会一直存在,直到你调用 delete[] p
  • 优点:空间大,生命周期可控(你想让它存活多久都行,跨函数使用非常方便)。
  • 缺点:容易出 Bug(忘记释放导致内存泄漏,释放两次导致崩溃),分配效率相对较低。

3、 什么时候用栈?什么时候用堆

场景 1:使用 栈 (Stack)

原则: 能用栈解决的,尽量用栈。因为快且安全。

  1. 局部小变量:函数内部的 int, double, char 等基本类型。
  2. 小型结构体/对象:对象不大,且不需要在函数外部使用。
  3. 生命周期短:数据只需要在当前函数(或当前代码块 { ... })内有效。
1
2
3
4
5
6
void func() {
int a = 10; // 栈
char buffer[128]; // 栈(小数组)
MyStruct s; // 栈(对象)
// 函数结束,a, buffer, s 全部自动销毁
}

场景 2:使用 堆 (Heap)

原则: 只有在栈搞不定的时候,才用堆。

  1. 大对象/大数组:需要存储大量数据(比如读取一张 4K 图片,或者处理几万个数据点),栈放不下。
  2. 动态大小:编译时不知道需要多少内存,运行时由用户输入决定(例如 cin >> n; int* arr = new int[n];)。注:C99 虽然支持变长数组 VLA,但 C++ 标准并不推荐,通常建议用 std::vector(其底层也是堆)。
  3. 生命周期长:数据需要在函数结束后继续存在,供其他函数使用。
  4. 构建复杂数据结构:链表、树、图等,节点通常也是动态在堆上创建的。
1
2
3
4
5
6
7
8
9
10
11
int* createArray(int size) {
// 必须用堆,否则函数返回后数组就销毁了,返回的指针就是悬空指针
int* arr = new int[size];
return arr;
}

void main() {
int* p = createArray(100000); // 请求大量内存
// ... 使用 p ...
delete[] p; // 千万别忘了释放!
}