背景
在C/C++中,struct类型中的成员的一旦声明,则实例中成员在内存中的布局 (Layout) 顺序就定下来了,即与成员声明的顺序相同,并且在默认情况下总是按照结构中占用空间最大的成员进行对齐(Align);当然我们也可以通过设置或编码来设置内存对齐的方式。
然而在 .net 托管环境中,CLR 提供了更自由的方式来控制 struct 中 Layout:我们可以在定义 struct时,在 struct 上运用 StructLayoutAttribute
特性来控制成员的内存布局。默认情况下,struct 实例中的字段在栈上的布局 (Layout) 顺序与声明中的顺序相同,即在 struct 上运用 [StructLayoutAttribute(LayoutKind.Sequential)]
特性,这样做的原因是结构常用于和非托管代码交互的情形。但是对于引用类型,默认的则是 LayoutKind.Auto
。
如果我们的值类型不会与非托管代码互操作,就应该覆盖 C# 编译器的默认设定。 LayoutKind
除了 Sequential
成员之外,还有两个成员 Auto
和Explicit
,给 StructLayoutAttribute
传入 LayoutKind.Auto
可以让 CLR 按照自己选择的最优方式来排列实例中的字段;传入 LayoutKind.Explicit
可以使字段按照我们的在字段上设定的 FieldOffset
来更灵活的设置字段排序方式,但这种方式也挺危险的,如果设置错误后果将会比较严重。
Struct 的内存布局
struct 的最小大小为 1 Byte:
struct EmptyStruct {};
// sizeof (EmptyStruct) = 1
内存布局流程
一个 struct 的内存布局流程可以简化为下面几步:
- struct 放到地址0上
- struct 的所有成员顺序地依次放置到自己的偏移位置上
- 所有成员放置完毕,对struct的内容大小(最后一个成员的终了位置)进行对齐,计算出新大小
流程中涉及到两点:
- 如何确定一个成员的放置起始位置
- 如何最后对struct的内容大小进行对齐
成员的起始位置
当放置完上一个成员之后,对接下来的成员放置位置是有要求的,要求这个位置偏移(对于struct的起始位置)必须是这个成员的内部最大类型的大小的倍数。
在上个放置成员尾部和当前成员起始位置之间如果有间隙,那么编译器会填充无效字节。
然后就可以把当前成员放置进去,放入的大小为当前成员的总大小。
struct 的大小对齐
成员都放置完毕之后,struct的内容大小有个规则,必须是内部最大类型的大小的倍数。没错,就是上面那个 maxInnerUnitSize。
然后在最后一个成员末尾到struct的新大小末尾会填充字节(如果有空隙的话)。
案例
下面就看几个示例,算下四个struct各占多少Byte ?
1. [StructLayout(LayoutKind.Sequential)]
struct StructDeft//C#编译器会自动在上面运用[StructLayout(LayoutKind.Sequential)]
{
bool i; //1Byte
double c;//8Byte
bool b; //1Byte
}
sizeof(StructDeft)
得到的结果是 24 Byte!啊哈,本身只有 10 Byte 的数据却占有了 24 Byte 的内存,这是因为默认(LayoutKind.Sequential
)情况下,CLR 对 struct 的 Layout 的处理方法与 C/C++ 中默认的处理方式相同(8+8+8=24),即按照结构中占用空间最大的成员进行对齐(Align)。10 Byte 的数据却占有了 24 Byte,严重地浪费了内存,所以如果我们正在创建一个与非托管代码没有任何互操作的 struct类型,最好还是不要使用默认的 StructLayoutAttribute(LayoutKind.Sequential)
特性。
2. [StructLayout(LayoutKind.Explicit)]
[StructLayout(LayoutKind.Explicit)]
struct BadStruct
{
[FieldOffset(0)]
public bool i; //1Byte
[FieldOffset(0)]
public double c;//8byte
[FieldOffset(0)]
public bool b; //1Byte
}
sizeof(BadStruct)
得到的结果是 8 Byte,得出的基数 8 显示 CLR 并没对结构体进行任何内存对齐(Align);本身要占有 10Byte 的数据却只占了 8Byte,显然有些数据被丢失了,这也正是我给 struct 取BadStruct
作为名字的原因。如果在 struct 上运用了 [StructLayout(LayoutKind.Explicit)]
,计算 FieldOffset
一定要小心,例如我们使用上面 BadStruct
来进行下面的测试:
StructExpt e = new StructExpt();
e.c = 0;
e.i = true;
Console.WriteLine(e.c);
输出的结果不再是 0 了,而是 4.94065645841247E-324 ,这是因为 e.c
和e.i
共享同一个 byte,执行e.i = true;
时也改变了 e.c
,CPU在按照浮点数的格式解析 e.c
时就得到了这个结果。所以在运用 LayoutKind.Explicit
时千万别吧 FieldOffset
算错了:)
3. [StructLayout(LayoutKind.Auto)]
sizeof(StructAuto)
得到的结果是12 Byte。下面来测试下这 StructAuto
的三个字段是如何摆放的:
unsafe
{
StructAuto s = new StructAuto();
Console.WriteLine(string.Format("i:{0}", (int)&(s.i)));
Console.WriteLine(string.Format("c:{0}", (int)&(s.c)));
Console.WriteLine(string.Format("b:{0}", (int)&(s.b)));
}
// 测试结果:
i:1242180
c:1242172
b:1242181
即CLR会对结构体中的字段顺序进行调整,将 i 调到 c 之后,使得 StructAuto
的实例 s 占有尽可能少的内存,并进行 4byte
的内存对齐(Align),字段顺序调整结果如下图所示:
结论
- 默认
(LayoutKind.Sequential)
情况下,CLR 对 struct 的 Layout 的处理方法与 C/C++ 中默认的处理方式相同,即按照结构中占用空间最大的成员进行对齐(Align); - 使用
LayoutKind.Explicit
的情况下,CLR 不对结构体进行任何内存对齐(Align),而且我们要小心就是FieldOffset
; - 使用
LayoutKind.Auto
的情况下,CLR 会对结构体中的字段顺序进行调整,使实例占有尽可能少的内存,并进行 4 Byte 的内存对齐(Align)。