背景介绍
最近在使用Java语言写一个Java客户端,对接一个C/C++语言编写的Server时,采用TCP协议进行通信,在将C++结构体序列化的输出流转换为Java结构体时,需要按照结构体每个字段对应的字节长度截取字节流转换为Java类型,遇到了内存对齐的问题,在此总结一下。
内存对齐概念
什么是内存对齐
用一个例子带出这个问题,看下面的一段代码,理论上,32位系统下,int占4byte,char占一个byte,那么将它们放到一个结构体中应该占4+1=5byte;
但是实际上,通过运行程序得到的结果是8 byte,这就是内存对齐所导致的。
//32位系统
#include<stdio.h>
struct{
int x;
char y;
} s;
int main()
{
printf("%d\n",sizeof(s); // 输出8
return 0;
}
字节长度计算:sizeof函数
现代计算机中内存空间都是按照 byte 划分的,从理论上讲似乎对任何类型的变量的访问可以从任何地址开始,但是实际的计算机系统对基本类型数据在内存中存放的位置有限制,它们会要求这些数据的首地址的值是某个数k(通常它为4或8)的倍数,这就是所谓的内存对齐。
为什么要进行内存对齐
尽管内存是以字节为单位,但是大部分处理器并不是按字节块来存取内存的。它一般会以双字节,四字节,8字节,16字节甚至32字节为单位来存取内存,我们将上述这些存取单位称为内存存取粒度。
现在考虑4字节存取粒度的处理器取int类型变量(32位系统),该处理器只能从地址为4的倍数的内存开始读取数据。
假如没有内存对齐机制,数据可以任意存放,现在一个int变量存放在从地址1开始的连续四个字节地址中,该处理器去取数据时,要先从0地址开始读取第一个4字节块,剔除不想要的字节(0地址),然后从地址4开始读取下一个4字节块,同样剔除不要的数据(5,6,7地址),最后留下的两块数据合并放入寄存器。这需要做很多工作。
现在有了内存对齐的,int类型数据只能存放在按照对齐规则的内存中,比如说0地址开始的内存。那么现在该处理器在取数据时一次性就能将数据读出来了,而且不需要做额外的操作,提高了效率。
总结如下:
平台原因(移植原因):不是所有的硬件平台都能访问任意地址上的任意数据的;某些硬件平台只能在某些地址处取某些特定类型的数据,否则抛出硬件异常。
性能原因:数据结构(尤其是栈)应该尽可能地在自然边界上对齐。 原因在于:为了访问未对齐的内存,处理器需要作两次内存访问;而对齐的内存访问仅需要一次访问。所以内存对齐能够提高访问效率。
总体来说:结构体的内存对齐是拿空间来换取时间的做法。
内存对齐规则
每个特定平台上的编译器都有自己的默认“对齐系数”(也叫对齐模数)。gcc中默认#pragma pack(4)
,可以通过预编译命令#pragma pack(n)
,n = 1,2,4,8,16
来改变这一系数。
有效对齐值:是给定值#pragma pack(n)
和结构体中最长数据类型长度中较小的那个。有效对齐值也叫对齐单位。
了解了上面的概念后,我们现在可以来看看内存对齐需要遵循的规则:
(1) 结构体第一个成员的偏移量(offset)为0,以后每个成员相对于结构体首地址的 offset
都是该成员大小与有效对齐值中较小那个的整数倍,如有需要编译器会在成员之间加上填充字节。
(2) 结构体的总大小为 有效对齐值 的整数倍,如有需要编译器会在最末一个成员之后加上填充字节
。
下面给出几个例子以便于理解:
//32位系统
#include<stdio.h>
struct
{
int i;
char c1;
char c2;
}x1;
struct{
char c1;
int i;
char c2;
}x2;
struct{
char c1;
char c2;
int i;
}x3;
int main()
{
printf("%d\n", sizeof(x1)); // 输出8
printf("%d\n", sizeof(x2)); // 输出12
printf("%d\n", sizeof(x3)); // 输出8
return 0;
}
以上测试都是在Linux环境下进行的,Linux下默认#pragma pack(4)
,且结构体中最长的数据类型为4个字节,所以有效对齐单位为4字节,下面根据上面所说的规则以s2来分析其内存布局:
首先使用规则1,对成员变量进行对齐:
sizeof(c1) = 1 <= 4(有效对齐位)
,按照1字节对齐,占用第0单元;
sizeof(i) = 4 <= 4(有效对齐位)
,相对于结构体首地址的偏移要为4的倍数,占用第4,5,6,7单元;
sizeof(c2) = 1 <= 4(有效对齐位)
,相对于结构体首地址的偏移要为1的倍数,占用第8单元;
然后使用规则2,对结构体整体进行对齐:
s2中变量i占用内存最大占4字节,而有效对齐单位也为4字节,两者较小值就是4字节。因此整体也是按照4字节对齐。由规则1得到s2占9个字节,此处再按照规则2进行整体的4字节对齐,所以整个结构体占用12个字节。
根据上面的分析,不难得出上面例子三个结构体的内存布局如下:
#pragma pack(n)
不同平台上编译器的 pragma pack
默认值不同。而我们可以通过预编译命令#pragma pack(n), n= 1,2,4,8,16
来改变对齐系数。
例如,对于上个例子的三个结构体,如果前面加上#pragma pack(1)
,那么此时有效对齐值为1字节,此时根据对齐规则,不难看出成员是连续存放的,三个结构体的大小都是6字节。
如果前面加上#pragma pack(2)
,有效对齐值为2字节,此时根据对齐规则,三个结构体的大小应为6,8,6。内存分布图如下:
经过上面的实例分析,大家应该对内存对齐有了全面的认识和了解,在以后的编码中定义结构体时需要考虑成员变量定义的先后顺序了。
结构体偏移量
下面给大家介绍一个宏叫做offsetof,它可以用来计算结构体成员相对于起始位置的偏移量
其语法如下:
size_t offsetof(type, member)
其中,type
是结构体的类型,member
是结构体中的成员名。
offsetof
返回一个 size_t
类型的值,表示指定成员在结构体中的偏移量。偏移量是指该成员相对于结构体起始地址的字节偏移量。
使用示例:
#include<iostream>
#include<string>
#include<stddef.h>
using namespace std;
#pragma pack(8) //修改默认对齐数为8
struct A {
char a;
char b;
double c;
};
struct B {
char a;
double b;
char c;
};
int main() {
cout << "结构体A的大小:" << sizeof(A) << " a偏移-" <<offsetof(A, a) << " b偏移-" << offsetof(A, b) << " c偏移-" << offsetof(A, c) << endl;
cout << "结构体B的大小:" << sizeof(B) << " a偏移-" <<offsetof(B, a) << " b偏移-" << offsetof(B, b) << " c偏移-" << offsetof(B, c) << endl;
system("pause");
}
运行结果如下:
结构体A的大小:16 a偏移-0 b偏移-1 c偏移-8
结构体B的大小:24 a偏移-0 b偏移-8 c偏移-16
设计结构体的技巧
在了解了结构体的对齐规则之后,有没有一种方法能让我们在设计结构体的时候既满足对齐规则,又能尽量的节省空间呢?其实是有的,方法就是:让占用空间小的成员尽量集中在一起。就像的习题,我们把占用空间下的 a 和 b 放在一起,从而使得 struct A 比 struct B 小了8个字节。
填充字节规则
在C++中,当结构体(或类)的成员变量在内存中进行对齐时,为了满足特定的对齐要求,编译器可能会在成员之间或结构体/类的末尾插入填充字节(padding bytes)。这些填充字节的值并不是由C++标准指定的,也就是说,它们可以是任何值,这取决于具体的编译器实现和运行时环境。
在大多数情况下,这些填充字节的内容是未定义的,也就是说,你不能假设它们有任何特定的值。
在某些情况下,这些字节可能包含垃圾值(即它们可能包含任何随机数据),在其他情况下,它们可能已经被其他代码段写入了不同的值。
因此,你不应该依赖这些填充字节的值来编写你的代码。如果你需要特定的内存布局或值,你应该显式地初始化它们,或者使用特定的编译器选项或属性来控制对齐和填充行为(尽管这可能会降低代码的可移植性)。
例如,在GCC和Clang中,你可以使用__attribute__((packed))
来告诉编译器不要插入任何填充字节,但这可能会降低数据访问的效率,因为某些平台可能无法高效地访问未对齐的内存。同样,你也可以使用其他编译器特定的属性来控制对齐行为。但是,请注意,这些属性并不是C++标准的一部分,因此它们可能不适用于所有编译器或平台。
- 不要假设填充字节为任何特定的值,比如0;
- 从字节流转换为Java类型时,请使用有效字节数;
参考资料:
【底层原理】C/C++内存对齐详解