为什么结构对齐取决于字段类型是原始的还是用户定义的?

野田时光 v2中,我们正在向纳秒级分辨率移动。这意味着我们不能再使用一个8字节的整数来表示我们感兴趣的整个时间范围。这促使我调查 Noda Time (许多)结构的内存使用情况,这反过来又导致我发现 CLR 对齐决策中的一个小小的异常。

首先,我意识到这个 是一个实现决策,默认行为可以随时改变。我意识到我 可以修改它使用 [StructLayout][FieldOffset],但我宁愿拿出一个解决方案,不要求,如果可能的话。

My core scenario is that I have a struct which contains a reference-type field and two other value-type fields, where those fields are simple wrappers for int. I had 希望 that that would be represented as 16 bytes on the 64-bit CLR (8 for the reference and 4 for each of the others), but for some reason it's using 24 bytes. I'm measuring the space using arrays, by the way - I understand that the layout may be different in different situations, but this felt like a reasonable starting point.

Here's a sample program demonstrating the issue:

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

所以:

  • 如果没有引用类型字段,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,我希望字段打包为 没有,指定一个常量字段偏移量?

8466 次浏览

我觉得这是个窃听器。您正在看到自动布局的副作用,它喜欢将非平凡的字段对齐到一个在64位模式下是8字节的倍数的地址。即使在显式应用 [StructLayout(LayoutKind.Sequential)]属性时也会发生这种情况。这不应该发生的。

您可以通过将 struct 成员设置为 public 并附加如下测试代码来查看:

    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字节整数并将 &test放在 Address 字段中:

 0x000000E928B5DE98  0ed750e0 000000e9 11111111 00000000 22222222 00000000

0xe90ed750e0是我机器上的字符串指针(不是你的)。您可以很容易地看到 Int32Wrappers,额外的4个字节的填充将大小变成了24个字节。返回到 struct 并将字符串放在最后。重复以上操作,您将首先看到字符串指针是 还是。违反 LayoutKind.Sequential,你得到 LayoutKind.Auto

要说服微软解决这个问题是很困难的,因为它已经这样做了太长时间,所以任何改变都会破坏 什么的。CLR 只是尝试使结构的托管版本符合 [StructLayout]并使其可闪烁,通常很快就放弃了。对于任何包含 DateTime 的结构来说都是臭名昭著的。只有在封送结构时才能获得真正的 LayoutKind 保证。封送的版本当然是16字节,正如 Marshal.SizeOf()将告诉您的那样。

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

总结见上面汉斯 · 帕桑特的回答,布局顺序不起作用


Some 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个字节大小。扩大测试范围:

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 分配,这是不同的对齐方式。

EDIT2

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

这段代码将被8字节对齐,所以结构将有16字节:

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

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

Now if we combine all that and create struct:

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

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

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

这段代码将有24个字节,因为 Int32Wrapper 的对齐方式与 long 相同。因此,自定义结构包装器将始终与结构中最高/最佳对齐的字段或其自身内部最重要的字段对齐。因此,对于8字节对齐的 ref 字符串,结构包装器将与之对齐。

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


编辑

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

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


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

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 中可以执行以下操作:

让我们先在堆上找到类型:

    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,它是我们创建的。因为这是一个数组,所以我们需要获取数组中单个元素的 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

The structure is actually 32 bytes since it's 16 bytes is reserved for padding so in actuality every structure is at least 16 bytes in size from the get go.

如果你将 int 和 string ref 中的16个字节添加到: 000000003e72d18 + 8字节 EE/pding,那么你最终得到的结果是000000003e72d30,这是字符串引用的起点,因为所有的引用都是从它们的第一个实际数据字段中填充的8字节,这就弥补了这个结构的32个字节。

让我们看看字符串是否真的是这样填充的:

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

Now lets analyse the above program the same way:

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字节。

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

Here the situation is the same, if we add to 0000000003c22d18 + 8 bytes of string ref we will end up at the start of the first Int wrapper where the value actually point to the address we are at.

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

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

所以实际上,这更像是一个 Union 类型,这次将对齐8字节(所有的填充都将与父结构对齐)。如果不是这样,那么我们最终会得到20个字节,这不是最优的,因此 mem 分配器将永远不会允许这种情况发生。如果你再算一遍,结果会发现这个结构实际上是40个字节的大小。

因此,如果您希望在内存方面更加保守,就不应该将其打包为 struct 自定义结构类型,而应使用简单的数组。另一种方法是从堆中分配内存(例如,VirtualAllocEx) 通过这种方式,你可以得到自己的内存块,并按照你想要的方式进行管理。

The final question here is why all of a sudden we might get layout like that. Well if you compare the jited code and performance of a int[] incrementation with struct[] with a counter field incrementation the second one will generate a 8 byte aligned address being an union, but when jited this translates to more optimized assembly code (singe LEA vs multiple MOV). However in the case described here the performance will be actually worse so my take is that this is consistent with the underlying CLR implementation since it's a custom type that can have multiple fields so it may be easier/better to put the starting address instead of a value (since it would be impossible) and do struct padding there, thus resulting in bigger byte size.

只是添加一些数据到混合-我创建了一个新的类型从您有:

struct RefAndTwoInt32Wrappers2
{
string text;
TwoInt32Wrappers z;
}

程序写出:

RefAndTwoInt32Wrappers2: 16

因此,看起来 TwoInt32Wrappers结构在新的 RefAndTwoInt32Wrappers2结构中正确对齐。