Why does struct alignment depend on whether a field type is primitive or user-defined?
在noda time v2中,我们将移动到纳秒分辨率。这意味着我们不能再使用一个8字节的整数来表示我们感兴趣的整个时间范围。这促使我研究了noda time的(许多)结构的内存使用情况,这反过来又导致我发现了clr的对齐决策中的一个稍微奇怪的地方。
首先,我认识到这是一个实现决策,并且默认行为可以随时更改。我知道我可以使用
我的核心方案是,我有一个
下面是一个演示这个问题的示例程序:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 | using System; using System.Runtime.InteropServices; #pragma warning disable 0169 struct Int32Wrapper { int x; } struct TwoInt32s { int x, y; } struct TwoInt32Wrappers { Int32Wrapper x, y; } struct RefAndTwoInt32s { string text; int x, y; } struct RefAndTwoInt32Wrappers { string text; Int32Wrapper x, y; } class Test { static void Main() { Console.WriteLine("Environment: CLR {0} on {1} ({2})", Environment.Version, Environment.OSVersion, Environment.Is64BitProcess ?"64 bit" :"32 bit"); ShowSize<Int32Wrapper>(); ShowSize<TwoInt32s>(); ShowSize<TwoInt32Wrappers>(); ShowSize<RefAndTwoInt32s>(); ShowSize<RefAndTwoInt32Wrappers>(); } static void ShowSize<T>() { long before = GC.GetTotalMemory(true); T[] array = new T[100000]; long after = GC.GetTotalMemory(true); Console.WriteLine("{0}: {1}", typeof(T), (after - before) / array.Length); } } |
以及我笔记本电脑上的编译和输出:
1 2 3 4 5 6 7 8 9 10 11 12 13 | c:\Users\Jon\Test>csc /debug- /o+ ShowMemory.cs Microsoft (R) Visual C# Compiler version 12.0.30501.0 for C# 5 Copyright (C) Microsoft Corporation. All rights reserved. c:\Users\Jon\Test>ShowMemory.exe Environment: CLR 4.0.30319.34014 on Microsoft Windows NT 6.2.9200.0 (64 bit) Int32Wrapper: 4 TwoInt32s: 8 TwoInt32Wrappers: 8 RefAndTwoInt32s: 16 RefAndTwoInt32Wrappers: 24 |
所以:
- 如果没有引用类型字段,clr很乐意将
Int32Wrapper 字段打包在一起(TwoInt32Wrappers 的大小为8)。 - 即使使用引用类型字段,clr仍然乐于将
int 字段打包在一起(RefAndTwoInt32s 的大小为16)。 - 结合这两个字段,每个
Int32Wrapper 字段似乎被填充/对齐到8个字节。(RefAndTwoInt32Wrappers 的尺寸为24。) - 在调试器中运行相同的代码(但仍然是一个发布版本)显示大小为12。
其他一些实验也产生了类似的结果:
- 将引用类型字段放在值类型字段之后没有帮助
- 使用
object 而不是string 没有帮助(我希望它是"任何引用类型")。 - 在引用周围使用另一个结构作为"包装器"没有帮助
- 将泛型结构用作围绕引用的包装没有帮助
- 如果我不断添加字段(为了简单起见,成对添加),那么
int 字段仍计为4个字节,Int32Wrapper 字段计为8个字节。 - 将
[StructLayout(LayoutKind.Sequential, Pack = 4)] 添加到每个可见结构中不会改变结果。
是否有人对此有任何解释(最好是参考文档),或者有人建议我如何在不指定常量字段偏移量的情况下,向clr提示希望打包字段?
我觉得这是个虫子。您看到了自动布局的副作用,它喜欢将非普通字段与64位模式下8字节的倍数地址对齐。即使显式应用
您可以通过将结构成员设为公共的并像这样附加测试代码来看到它:
1 2 3 4 5 | var test = new RefAndTwoInt32Wrappers(); test.text ="adsf"; test.x.x = 0x11111111; test.y.x = 0x22222222; Console.ReadLine(); // <=== Breakpoint here |
当断点命中时,使用debug+windows+memory+memory 1。切换到4字节整数,并将
1 | 0x000000E928B5DE98 0ed750e0 000000e9 11111111 00000000 22222222 00000000 |
很难说服微软来解决这个问题,它已经用了太长的时间,所以任何改变都会破坏某些东西。clr只试图满足
使用
编辑2好的。
1 2 3 4 5 | struct RefAndTwoInt32Wrappers { public int x; public string s; } |
此代码将8字节对齐,因此结构将有16个字节。相比之下:好的。
1 2 3 4 5 | struct RefAndTwoInt32Wrappers { public int x,y; public string s; } |
将被4个字节对齐,因此该结构也将有16个字节。所以这里的基本原理是,clr中的结构对齐是由大多数对齐字段的数量决定的,clase显然不能这样做,所以它们将保持8字节对齐。好的。
现在,如果我们结合所有这些并创建结构:好的。
1 2 3 4 5 6 | struct RefAndTwoInt32Wrappers { public int x,y; public Int32Wrapper z; public string s; } |
它将有24个字节X,Y将有4个字节,而Z,S将有8个字节。一旦我们在结构中引入了一个引用类型,clr将始终对齐我们的自定义结构以匹配类对齐。好的。
1 2 3 4 5 6 | struct RefAndTwoInt32Wrappers { public Int32Wrapper z; public long l; public int x,y; } |
这段代码将有24个字节,因为int32wrapper的长度将相同。因此,自定义结构包装器将始终与结构中最高/最佳对齐的字段或其内部最重要的字段对齐。因此,在引用字符串是8字节对齐的情况下,结构包装器将与之对齐。好的。
结构内的结束自定义结构字段将始终与结构中最高对齐的实例字段对齐。现在,如果我不确定这是否是一个错误,但没有证据,我会坚持我的观点,这可能是有意识的决定。好的。
编辑好的。
实际上,只有在堆上分配时,大小才是准确的,但结构本身的大小较小(字段的确切大小)。进一步分析Seam,认为这可能是clr代码中的一个bug,但需要有证据支持。好的。
我将检查cli代码并发布进一步的更新,如果找到有用的东西。好的。
这是.NET MEM分配器使用的对齐策略。好的。
1 2 3 4 5 6 7 8 9 10 | public static RefAndTwoInt32s[] test = new RefAndTwoInt32s[1]; static void Main() { test[0].text ="a"; test[0].x = 1; test[0].x = 1; Console.ReadKey(); } |
在windbg中,使用.net40在x64下编译的代码可以执行以下操作:好的。
让我们先在堆上查找类型:好的。
1 2 3 4 5 6 7 8 9 10 | 0:004> !dumpheap -type Ref Address MT Size 0000000003e72c78 000007fe61e8fb58 56 0000000003e72d08 000007fe039d3b78 40 Statistics: MT Count TotalSize Class Name 000007fe039d3b78 1 40 RefAndTwoInt32s[] 000007fe61e8fb58 1 56 System.Reflection.RuntimeAssembly Total 2 objects |
一旦我们有了它,让我们看看地址下面是什么:好的。
1 2 3 4 5 6 7 8 | 0:004> !do 0000000003e72d08 Name: RefAndTwoInt32s[] MethodTable: 000007fe039d3b78 EEClass: 000007fe039d3ad0 Size: 40(0x28) bytes Array: Rank 1, Number of elements 1, Type VALUETYPE Fields: None |
我们看到这是一个值类型,也是我们创建的值类型。由于这是一个数组,我们需要获取数组中单个元素的valuetype def:好的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | 0:004> !dumparray -details 0000000003e72d08 Name: RefAndTwoInt32s[] MethodTable: 000007fe039d3b78 EEClass: 000007fe039d3ad0 Size: 40(0x28) bytes Array: Rank 1, Number of elements 1, Type VALUETYPE Element Methodtable: 000007fe039d3a58 [0] 0000000003e72d18 Name: RefAndTwoInt32s MethodTable: 000007fe039d3a58 EEClass: 000007fe03ae2338 Size: 32(0x20) bytes File: C:\ConsoleApplication8\bin elease\ConsoleApplication8.exe Fields: MT Field Offset Type VT Attr Value Name 000007fe61e8c358 4000006 0 System.String 0 instance 0000000003e72d30 text 000007fe61e8f108 4000007 8 System.Int32 1 instance 1 x 000007fe61e8f108 4000008 c System.Int32 1 instance 0 y |
这个结构实际上是32个字节,因为它的16个字节是为填充而保留的,所以实际上每个结构从一开始就至少有16个字节的大小。好的。
如果从int中添加16个字节,并将字符串引用添加到:000000000 3E72d18+8 bytes ee/padding,则最终将达到000000000 3E72d30,这是字符串引用的起始点,并且由于所有引用都是从其第一个实际数据字段填充的8个字节,因此这就构成了此结构的32个字节。好的。
让我们看看字符串是否是这样填充的:好的。
1 2 3 4 5 6 7 8 9 10 11 12 13 | 0:004> !do 0000000003e72d30 Name: System.String MethodTable: 000007fe61e8c358 EEClass: 000007fe617f3720 Size: 28(0x1c) bytes File: C:\WINDOWS\Microsoft.Net\assembly\GAC_64\mscorlib\v4.0_4.0.0.0__b77a5c561934e089\mscorlib.dll String: a Fields: MT Field Offset Type VT Attr Value Name 000007fe61e8f108 40000aa 8 System.Int32 1 instance 1 m_stringLength 000007fe61e8d640 40000ab c System.Char 1 instance 61 m_firstChar 000007fe61e8c358 40000ac 18 System.String 0 shared static Empty >> Domain:Value 0000000001577e90:NotInit << |
现在让我们用同样的方法分析上述程序:好的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | public static RefAndTwoInt32Wrappers[] test = new RefAndTwoInt32Wrappers[1]; static void Main() { test[0].text ="a"; test[0].x.x = 1; test[0].y.x = 1; Console.ReadKey(); } 0:004> !dumpheap -type Ref Address MT Size 0000000003c22c78 000007fe61e8fb58 56 0000000003c22d08 000007fe039d3c00 48 Statistics: MT Count TotalSize Class Name 000007fe039d3c00 1 48 RefAndTwoInt32Wrappers[] 000007fe61e8fb58 1 56 System.Reflection.RuntimeAssembly Total 2 objects |
我们的结构现在是48字节。好的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | 0:004> !dumparray -details 0000000003c22d08 Name: RefAndTwoInt32Wrappers[] MethodTable: 000007fe039d3c00 EEClass: 000007fe039d3b58 Size: 48(0x30) bytes Array: Rank 1, Number of elements 1, Type VALUETYPE Element Methodtable: 000007fe039d3ae0 [0] 0000000003c22d18 Name: RefAndTwoInt32Wrappers MethodTable: 000007fe039d3ae0 EEClass: 000007fe03ae2338 Size: 40(0x28) bytes File: C:\ConsoleApplication8\bin elease\ConsoleApplication8.exe Fields: MT Field Offset Type VT Attr Value Name 000007fe61e8c358 4000009 0 System.String 0 instance 0000000003c22d38 text 000007fe039d3a20 400000a 8 Int32Wrapper 1 instance 0000000003c22d20 x 000007fe039d3a20 400000b 10 Int32Wrapper 1 instance 0000000003c22d28 y |
这里的情况是相同的,如果我们添加到000000000 3c22d18+8字节的字符串引用,我们将在第一个int包装器的开始处结束,该包装器的值实际指向我们所在的地址。好的。
现在我们可以看到,每个值都是一个对象引用,再次让我们通过查看00000000003C22D20来确认这一点。好的。
1 2 3 | 0:004> !do 0000000003c22d20 <Note: this object has an invalid CLASS field> Invalid object |
实际上,这是正确的,因为它是一个结构,如果它是obj或vt,那么地址就不会告诉我们任何信息。好的。
1 2 3 4 5 6 7 8 9 10 | 0:004> !dumpvc 000007fe039d3a20 0000000003c22d20 Name: Int32Wrapper MethodTable: 000007fe039d3a20 EEClass: 000007fe03ae23c8 Size: 24(0x18) bytes File: C:\ConsoleApplication8\bin elease\ConsoleApplication8.exe Fields: MT Field Offset Type VT Attr Value Name 000007fe61e8f108 4000001 0 System.Int32 1 instance 1 x |
所以实际上,这更像是一个联合类型,这次将得到8个字节的对齐(所有的填充都将与父结构对齐)。如果不是这样,我们将得到20个字节,这不是最佳的,因此mem分配器将永远不会允许它发生。如果你再做一次数学运算,就会发现这个结构确实有40个字节的大小。好的。
因此,如果您希望在内存方面更加保守,则不应将其打包为结构自定义结构类型,而应使用简单数组。另一种方法是从堆中分配内存(例如,virtualallocex)这样你就有了自己的内存块,你就可以按照自己的方式来管理它了。好的。
最后一个问题是,为什么突然间我们会得到这样的布局。如果将int[]递增与struct[]递增与counter字段递增进行比较,第二个将生成一个8字节对齐的联合地址,但是当jitted时,这将转换为更优化的汇编代码(单个lea与多个mov)。但是,在这里描述的情况下,性能实际上会更差,所以我的看法是,这与底层的clr实现是一致的,因为它是一个自定义类型,可以有多个字段,因此可以更容易/更好地将起始地址而不是一个值(因为这是不可能的),并在那里进行结构填充,从而恢复以更大的字节大小进行传输。好的。好啊。
总结见上文@hans passant的答案。布局顺序不起作用
一些测试:
它肯定只在64位上,对象引用"毒物"结构。32位执行您期望的操作:
1 2 3 4 5 6 7 8 9 10 | Environment: CLR 4.0.30319.34209 on Microsoft Windows NT 6.2.9200.0 (32 bit) ConsoleApplication1.Int32Wrapper: 4 ConsoleApplication1.TwoInt32s: 8 ConsoleApplication1.TwoInt32Wrappers: 8 ConsoleApplication1.ThreeInt32Wrappers: 12 ConsoleApplication1.Ref: 4 ConsoleApplication1.RefAndTwoInt32s: 12 ConsoleApplication1.RefAndTwoInt32Wrappers: 12 ConsoleApplication1.RefAndThreeInt32s: 16 ConsoleApplication1.RefAndThreeInt32Wrappers: 16 |
一旦添加了对象引用,所有结构就扩展为8个字节,而不是4个字节的大小。扩展测试:
1 2 3 4 5 6 7 8 9 10 11 12 13 | Environment: CLR 4.0.30319.34209 on Microsoft Windows NT 6.2.9200.0 (64 bit) ConsoleApplication1.Int32Wrapper: 4 ConsoleApplication1.TwoInt32s: 8 ConsoleApplication1.TwoInt32Wrappers: 8 ConsoleApplication1.ThreeInt32Wrappers: 12 ConsoleApplication1.Ref: 8 ConsoleApplication1.RefAndTwoInt32s: 16 ConsoleApplication1.RefAndTwoInt32sSequential: 16 ConsoleApplication1.RefAndTwoInt32Wrappers: 24 ConsoleApplication1.RefAndThreeInt32s: 24 ConsoleApplication1.RefAndThreeInt32Wrappers: 32 ConsoleApplication1.RefAndFourInt32s: 24 ConsoleApplication1.RefAndFourInt32Wrappers: 40 |
正如您所看到的,只要添加引用,每个int32wrapper就变成8个字节,所以这不是简单的对齐方式。我缩小了数组分配,因为它是不同对齐的loh分配。
只是为了向组合中添加一些数据-我从您拥有的类型中创建了另一个类型:
1 2 3 4 5 | struct RefAndTwoInt32Wrappers2 { string text; TwoInt32Wrappers z; } |
程序写出:
1 | RefAndTwoInt32Wrappers2: 16 |
因此,看起来