前言
我很久以前写过大量的博文都被我删除了,找了一些有价值的重传一下
主要讲一下C/C++在结构体和类在内存中的存储结构,注意空间和时间往往是反比关系,很多程序优化都符合这个原则,但也不绝对,有时要用好才可以,对于大多数程序员来说,其实都无视了这种细节上的优化。(很多语言的内存对齐都不会自动优化)
现象
struct Example {
char a; // 1 字节
int b; // 4 字节
short c; // 2 字节
};
从直观上看,这个结构体的大小应该是 1 + 4 + 2 = 7 字节。但实际上,很多编译器会对齐它,使其占用 12 字节(也可能使24字节,不同编译器默认结果不同),而不是 7 字节。这是因为编译器会按照一定的规则插入一些“填充字节”来对齐数据。
基本概念
内存对齐是一种让数据在内存中排列得更加高效的方式。它的目的是让 CPU 读取数据时更加快速,因为 CPU 并不是随意读取内存的,而是按照固定的“块”(比如 4 字节、8 字节等)来读取数据。如果数据的存储不对齐,CPU 可能需要多次访问内存才能完整读取一个变量,从而导致性能下降。
内存对齐规则
-
对齐原则:
- 每个成员变量的存储地址都必须是它“对齐边界”的整数倍。
- “对齐边界”通常是该变量类型的大小,比如
int
的对齐边界是 4 字节,short
是 2 字节,char
是 1 字节。
-
结构体的总大小:
- 结构体的总大小必须是其“最大对齐边界”的整数倍。
-
最大对齐边界:
- 通常来说是结构体内最大基础结构的大小(嵌套结构,也是看嵌套结构内的最大基础数据类型)
- 当最大基础结构大小小于编译器默认值时
- 可手动强制设定最大对其边界
按照规则分析最开始的的例子
我们来一步步看看 struct Example
是怎么对齐的(假设编译器默认最大边界是4字节):
-
char a
:占 1 字节,但因为int b
的对齐边界是 4,所以a
后面会补 3 个字节,使下一个变量b
的地址是 4 的倍数。 -
int b
:占 4 字节,地址是 4 的倍数,刚好对齐。 -
short c
:占 2 字节,c
的地址需要是 2 的倍数,剩下 2 个字节用来填充,使整个结构体大小是最大对齐边界(4 字节)的倍数。
最终,内存布局如下(每个字母代表 1 字节):
地址 | 数据 |
---|---|
0 | a |
1 | 填充 |
2 | 填充 |
3 | 填充 |
4 | b |
5 | b |
6 | b |
7 | b |
8 | c |
9 | c |
10 | 填充 |
11 | 填充 |
结构体总大小是 12 字节。
针对内存对齐如何优化
在设计结构体时,内存对齐会影响内存占用和性能,因此合理的结构体设计不仅可以节省空间,还能提升程序的运行效率。
1. 按照从大到小的顺序排列成员变量
不同类型的变量有不同的对齐要求(通常是它们的大小),因此将大对齐边界的变量放在前面,可以减少填充字节的浪费。
// 非优化的结构体
struct Example1 {
char a; // 1 字节,对齐 1 字节
int b; // 4 字节,对齐 4 字节
short c; // 2 字节,对齐 2 字节
};
// 内存布局(16 字节):
// | a | 填充 | 填充 | 填充 | b | b | b | b | c | c | 填充 | 填充 |
// 优化后的结构体
struct Example2 {
int b; // 4 字节,对齐 4 字节
short c; // 2 字节,对齐 2 字节
char a; // 1 字节,对齐 1 字节
};
// 内存布局(8 字节):
// | b | b | b | b | c | c | a | 填充 |
优化结果:
- 非优化结构体占用 16 字节。
- 优化后结构体占用 8 字节,节省了一半的内存。
2. 避免不必要的小类型交错
多个小类型(如 char
和 short
)交错排列时,会导致填充字节的增加。将相同类型的变量集中在一起,可以减少对齐浪费。
// 不推荐的设计
struct Example {
char a; // 1 字节,对齐 1 字节
short b; // 2 字节,对齐 2 字节
char c; // 1 字节,对齐 1 字节
int d; // 4 字节,对齐 4 字节
};
// 内存布局(12 字节):
// | a | 填充 | b | b | c | 填充 | d | d | d | d |
// 推荐的设计
struct OptimizedExample {
char a; // 1 字节,对齐 1 字节
char c; // 1 字节,对齐 1 字节
short b; // 2 字节,对齐 2 字节
int d; // 4 字节,对齐 4 字节
};
// 内存布局(8 字节):
// | a | c | b | b | d | d | d | d |
优化结果:
- 非优化结构体占用 12 字节。
- 优化后结构体占用 8 字节,节省了 4 字节。
3. 使用位域(Bit-field)优化小型数据
如果结构体中有多个需要表示小范围数据的成员(如布尔值、枚举等),可以使用位域(bit field)来压缩它们的存储空间。
// 不使用位域
struct Flags {
bool flag1; // 1 字节
bool flag2; // 1 字节
bool flag3; // 1 字节
bool flag4; // 1 字节
};
// 使用位域
struct BitFlags {
unsigned int flag1 : 1; // 1 位
unsigned int flag2 : 1; // 1 位
unsigned int flag3 : 1; // 1 位
unsigned int flag4 : 1; // 1 位
};
优化结果:
- 不使用位域的结构体占用 4 字节(每个
bool
单独存储,填充到 4 字节对齐)。 - 使用位域的结构体仅占用 4 位(被打包在一个
unsigned int
中)。
注意:位域的使用可能导致编译器生成额外的位操作指令,因此需要在性能和空间之间权衡。
4. 避免过小的对齐边界
虽然可以通过 #pragma pack
或 __attribute__((packed))
来修改对齐规则,使数据不进行对齐(比如按 1 字节对齐),但这可能导致性能下降。
强制取消对齐
#pragma pack(1) // 强制 1 字节对齐
struct Unaligned {
char a;
int b;
short c;
};
#pragma pack() // 恢复默认对齐
上述结构体占用 7 字节,节省了填充字节,但由于变量 b
无法对齐到 4 字节边界,读取时可能导致性能下降。
建议:不要随意使用
#pragma pack
或__attribute__((packed))
,除非在嵌入式系统等对内存占用有极高要求的场景。
5. 使用数据对齐辅助工具
有些编译器提供了调整对齐的工具,可以明确指定结构体或者字段的对齐边界,方便在不同平台上优化。
对齐指令
struct Example {
char a;
int b;
short c;
} __attribute__((aligned(8))); // 强制对齐到 8 字节边界
使用这种方式,可以确保结构体按 8 字节对齐,在特定架构下可能提高访问效率。
6. 避免过多的指针成员
指针的对齐边界通常是 4 字节(32 位系统) 或 8 字节(64 位系统),如果结构体中有大量指针成员,可能会增加对齐填充的浪费。因此,可以考虑减少指针的使用,或者将指针成员单独存储。
7. 合理使用联合体(union)以节省空间
如果多个成员不需要同时存在,可以将它们放入联合体中,共用一块内存。
// 普通结构体
struct Normal {
int x; // 4 字节
double y; // 8 字节
};
// 使用联合体
union Optimized {
int x; // 4 字节
double y; // 8 字节
};
- 普通结构体的大小为 12 字节(对齐到 8 字节)。
- 联合体的大小为 8 字节(两个成员共用一块内存)。
注意:联合体适用于互斥数据,不能同时访问多个成员。
8. 缓存友好性优化
为了提高性能,可以根据实际使用场景设计结构体,使得经常访问的成员尽量存放在一起,减少缓存失效(Cache Miss)。
假设一个结构体被频繁访问:
struct Example {
int frequently_used;
char rarely_used[64];
};
在访问 frequently_used
时,rarely_used
可能占据了缓存行,导致浪费。可以优化为:
struct Optimized {
int frequently_used;
char padding[60]; // 手动填充,避免缓存浪费
char rarely_used[64];
};
总结
在设计结构体时,可以从以下几点进行优化:
- 大类型优先排列:从大到小排列成员,减少填充字节。
- 相同类型集中:将相同对齐边界的成员放在一起,避免交错浪费。
- 使用位域压缩小型成员:尤其是布尔值或小范围数据。
- 慎用强制取消对齐:避免性能损失。
- 减少指针浪费:指针占用对齐空间较大,尽量避免大量使用。
- 使用联合体节省空间:当多个成员互斥时,采用联合体共享内存。
- 考虑缓存友好性:经常访问的字段放在一起,减少缓存失效。