编程语言知识点二
[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 | struct A { |
内存布局分析:
char a: Offset 0。占 1 字节[0]。int b: 大小 4 字节。Offset 必须是 4 的倍数。当前位置是 1。- 动作: 填充 3 个字节
[1-3]。 - 存放:
b存放在[4-7]。
- 动作: 填充 3 个字节
short c: 大小 2 字节。Offset 必须是 2 的倍数。当前位置是 8。- 存放:
c存放在[8-9]。
- 存放:
- 整体检查(规则三): 当前用了 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 | struct B { |
内存布局分析:
int b: Offset 0。占[0-3]。short c: 大小 2。Offset 需要是 2 的倍数。当前位置 4。符合。占[4-5]。char a: 大小 1。Offset 需要是 1 的倍数。当前位置 6。符合。占[6]。- 整体检查: 当前用了 7 个字节。
- 最大成员
int(4 字节)。 - 7 不是 4 的倍数,向上取整到 8。
- 动作: 末尾填充 1 个字节
[7]。
- 最大成员
最终大小:sizeof(B) = 8 字节 (比案例 A 节省了 33% 的内存!)
案例 C:嵌套结构体
1 | struct Inner { |
关键点: 当结构体包含子结构体时,子结构体的对齐基准不是它的大小,而是子结构体中最大成员的大小。
Inner最大成员是int(4 字节)。- 所以
Outer中的i必须放在 4 的倍数地址上。
分析:
c:[0]。i: 对齐要求 4。当前 1,填充[1-3]。i占用[4-11](8字节)。d: 对齐要求 8。当前 12,填充[12-15]。d占用[16-23]。- 整体大小:24 字节。最大成员是
double(8 字节),24 是 8 的倍数。
最终大小:sizeof(Outer) = 24 字节
4. 如何人为修改对齐规则?
有时候为了节省空间(如网络传输协议头),我们需要强制取消对齐(也叫“紧凑对齐”)。
方法 1:#pragma pack (最常用)
1 |
|
- 规则变动: 此时成员对齐的模数变为
min(成员大小, pack参数)。因为 pack 是 1,所以所有成员都按 1 对齐。
方法 2:C++11 alignas (指定更宽的对齐)
alignas 通常用于为了缓存行(Cache Line)优化而强制增大对齐,不能用来减小对齐。
1 | struct alignas(32) MyStruct { |
- 空间换时间: 内存对齐本质上是浪费少量内存空间来换取 CPU 的访问速度。
- 定义顺序很重要: 在定义结构体时,习惯将占用空间大的成员写在前面,小的写在后面(或者按照 大->小->大 的波浪形,但纯降序最稳),这样通常能获得最小的内存占用。
- 网络传输注意: 直接发送结构体二进制流时,务必使用
#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)
原则: 能用栈解决的,尽量用栈。因为快且安全。
- 局部小变量:函数内部的
int,double,char等基本类型。 - 小型结构体/对象:对象不大,且不需要在函数外部使用。
- 生命周期短:数据只需要在当前函数(或当前代码块
{ ... })内有效。
1 | void func() { |
场景 2:使用 堆 (Heap)
原则: 只有在栈搞不定的时候,才用堆。
- 大对象/大数组:需要存储大量数据(比如读取一张 4K 图片,或者处理几万个数据点),栈放不下。
- 动态大小:编译时不知道需要多少内存,运行时由用户输入决定(例如
cin >> n; int* arr = new int[n];)。注:C99 虽然支持变长数组 VLA,但 C++ 标准并不推荐,通常建议用std::vector(其底层也是堆)。 - 生命周期长:数据需要在函数结束后继续存在,供其他函数使用。
- 构建复杂数据结构:链表、树、图等,节点通常也是动态在堆上创建的。
1 | int* createArray(int size) { |