为什么结构alignment取决于字段types是基本的还是用户定义的?

在Noda Time v2中,我们正在转向纳秒级的分辨率。 这意味着我们不能再使用一个8字节的整数来表示我们感兴趣的整个时间范围。这促使我调查了Noda Time(许多)结构的内存使用情况,这又导致了我在CLR的协调决定中发现一个轻微的怪异。

首先,我意识到这一个实现的决定,而且默认行为随时可能改变。 我意识到我可以使用[StructLayout][FieldOffset] 修改它,但我宁愿想出一个解决scheme,如果可能的话,不要求这样做。

我的核心场景是,我有一个struct ,其中包含一个引用types的字段和其他两个值types的字段,其中这些字段是简单的包装int 。 我曾希望在64位CLR上表示为16个字节(参考8个,其他4个),但由于某种原因,它使用了24个字节。 我使用数组测量空间,顺便说一下 – 我了解,在不同的情况下,布局可能会有所不同,但这种感觉像是一个合理的起点。

以下是一个演示此问题的示例程序:

 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); } } 

并在我的笔记本电脑上编译和输出:

 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 

所以:

  • 如果您没有引用types字段,则CLR很乐意将Int32Wrapper字段放在一起( TwoInt32Wrappers的大小为8)
  • 即使使用引用types字段,CLR仍然乐意将int字段打包在一起( RefAndTwoInt32s的大小为16)
  • 结合这两者,每个Int32Wrapper字段显示为填充/alignment到8个字节。 ( RefAndTwoInt32Wrappers的大小为24)
  • 在debugging器中运行相同的代码(但仍然是发布版本)显示大小为12。

其他一些实验也产生了类似的结果:

  • 将值types字段之后的引用types字段不起作用
  • 使用object而不是string没有帮助(我期望它是“任何引用types”)
  • 使用另一个结构作为引用的“包装器”并没有帮助
  • 使用generics结构作为引用的包装并没有帮助
  • 如果我不断添加字段(为了简单起见,成对), int字段仍然计数为4个字节,并且Int32Wrapper字段计数为8个字节
  • 在每个结构中添加[StructLayout(LayoutKind.Sequential, Pack = 4)]不会改变结果

有没有人有任何解释(理想情况下与参考文件)或如何我可以暗示到CLR,我希望字段被打包, 而不指定一个常量字段偏移的build议?

我认为这是一个错误。 您将看到自动布局的副作用,它喜欢将非重要字段与64位模式下的8字节倍数的地址alignment。 即使您显式应用[StructLayout(LayoutKind.Sequential)]属性,也会出现这种情况。 这不应该发生。

你可以通过公开struct成员并附加testing代码来看到它:

  var test = new RefAndTwoInt32Wrappers(); test.text = "adsf"; test.xx = 0x11111111; test.yx = 0x22222222; Console.ReadLine(); // <=== Breakpoint here 

当出现断点时,使用Debug + Windows + Memory + Memory 1.切换到4字节整数,并在“地址”字段中进行&test

  0x000000E928B5DE98 0ed750e0 000000e9 11111111 00000000 22222222 00000000 

0xe90ed750e0是我的机器上的string指针(不是你的)。 你可以很容易地看到Int32Wrappers ,额外的4个填充字节将大小变成24个字节。 回到结构并把string放在最后。 重复,你会看到string指针仍然是第一个。 违反LayoutKind.Sequential ,你有LayoutKind.Auto

说服微软去解决这个问题是很困难的,它已经这样做了太久了,所以任何改变都会被打破。 CLR只是试图使[StructLayout]为一个结构的pipe理版本,并使其变为blittable,它通常很快放弃。 臭名昭着的任何包含DateTime的结构。 封送一个结构体时,您只能得到真正的LayoutKind保证。 编组版本肯定是16字节,因为Marshal.SizeOf()会告诉你。

使用LayoutKind.Explicit修复它,而不是你想听到的。

EDIT2

 struct RefAndTwoInt32Wrappers { public int x; public string s; } 

这段代码将被8字节alignment,所以结构将有16个字节。 相比之下:

 struct RefAndTwoInt32Wrappers { public int x,y; public string s; } 

将4个字节alignment,所以这个结构也将有16个字节。 所以这里的基本原理是CLR中的结构alignment是由大多数alignment字段的数量决定的,clains显然不能这样做,所以它们将保持8字节alignment。

现在,如果我们将所有这些结合起来并创build结构

 struct RefAndTwoInt32Wrappers { public int x,y; public Int32Wrapper z; public string s; } 

它将有24个字节{x,y}将有4个字节,{z,s}将有8个字节。 一旦我们在结构中引入一个reftypes,CLR将始终alignment我们的自定义结构以匹配类alignment。

 struct RefAndTwoInt32Wrappers { public Int32Wrapper z; public long l; public int x,y; } 

这个代码将有24个字节,因为Int32Wrapper的长度是一样的。 所以自定义的结构包装将始终与结构中最高/最好alignment的字段alignment,或者与其内部最重要的字段alignment。 所以在8字节alignment的refstring的情况下,结构包装将与此alignment。

在结构中结束自定义结构字段将始终与结构中最高alignment的实例字段alignment。 现在,如果我不确定这是一个错误,但没有证据,我会坚持我的意见,这可能是有意识的决定。


编辑

大小实际上只有在堆中分配时才是准确的,但是结构本身具有较小的大小(它的字段的确切大小)。 进一步的分析表明,这可能是CLR代码中的一个错误,但需要证据支持。

我会检查克莱代码,并发布进一步的更新,如果有用的东西会被发现。


这是.NET mem分配器使用的alignment策略。

 public static RefAndTwoInt32s[] test = new RefAndTwoInt32s[1]; static void Main() { test[0].text = "a"; test[0].x = 1; test[0].x = 1; Console.ReadKey(); } 

这个代码在x64下用.net40编译,在WinDbg中可以执行以下操作:

让我们先在堆上findtypes:

  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 

一旦我们有它,让我们看看下面的地址是什么:

  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,也是我们创build的。 由于这是一个数组,我们需要获取数组中单个元素的ValueType def:

  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\Release\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个字节是为填充保留的,所以实际上每个结构至less有16个字节的大小。

如果你添加来自ints的16个字节和一个stringref:0000000003e72d18 + 8字节的EE / padding,你将会在0000000003e72d30结束,这是string引用的起始点,因为所有引用都是从它们的第一个实际数据字段这就弥补了这个结构的32字节。

我们来看看这个string是否真的被填充了:

 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 << 

现在让我们以同样的方式分析上面的程序:

 public static RefAndTwoInt32Wrappers[] test = new RefAndTwoInt32Wrappers[1]; static void Main() { test[0].text = "a"; test[0].xx = 1; test[0].yx = 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个字节。

 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\Release\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 

这里的情况是一样的,如果我们加上0000000003c22d18 + 8个字节的stringref,我们将在第一个Int包装器的开始处实际指向我们所在的地址。

现在我们可以看到每个值都是一个对象引用,再次通过偷看0000000003c22d20来确认。

 0:004> !do 0000000003c22d20 <Note: this object has an invalid CLASS field> Invalid object 

其实这是正确的,因为它的结构地址告诉我们,如果这是一个obj或vt。

 0:004> !dumpvc 000007fe039d3a20 0000000003c22d20 Name: Int32Wrapper MethodTable: 000007fe039d3a20 EEClass: 000007fe03ae23c8 Size: 24(0x18) bytes File: C:\ConsoleApplication8\bin\Release\ConsoleApplication8.exe Fields: MT Field Offset Type VT Attr Value Name 000007fe61e8f108 4000001 0 System.Int32 1 instance 1 x 

所以实际上,这更像是一个联合types,这次将得到8个字节alignment(所有的填充将与父结构alignment)。 如果不是那么我们最终会得到20个字节,这不是最佳的,所以mem分配器将永远不会允许它发生。 如果再次进行math计算,结果确实是40字节的大小。

所以,如果你想要更加保守的内存,你不应该把它打包在一个结构自定义结构types,而是使用简单的数组。 另一种方法是从堆中分配内存(例如,VirtualAllocEx),这样您可以获得自己的内存块,并以您想要的方式进行pipe理。

这里最后的问题是为什么突然之间我们可能会得到这样的布局。 那么,如果你用一个计数器字段增量来比较int []递增的jit代码和性能,那么第二个将生成一个8字节alignment的地址作为一个联合,但是当jit被转换时,这会转化为更优化的汇编代码LEA vs多个MOV)。 然而,在这里描述的情况下,性能会更糟,所以我认为这是与基础的CLR实现一致,因为它是一个自定义types,可以有多个字段,所以可以更容易/更好地把起始地址,而不是值(因为这是不可能的),并在那里做结构填充,从而导致更大的字节大小。

总结见@Hans Passant的答案可能在上面。 布局顺序不起作用


一些testing:

它绝对只在64位上,而对象引用“毒害”了结构。 32位做你所期望的:

 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个字节的大小。 扩大testing:

 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个字节,所以不是简单的alignment。 我缩小了数组的分配情况,因为它是不同的alignment的LoH分配。

只是为了添加一些数据 – 我创build了一个更多的types:

 struct RefAndTwoInt32Wrappers2 { string text; TwoInt32Wrappers z; } 

该程序写道:

 RefAndTwoInt32Wrappers2: 16 

所以它看起来像TwoInt32Wrappers结构在新的RefAndTwoInt32Wrappers2结构中正确alignment。