关于C#:为什么结构对齐取决于字段类型是基元类型还是用户定义的?

Why does struct alignment depend on whether a field type is primitive or user-defined?

在noda time v2中,我们将移动到纳秒分辨率。这意味着我们不能再使用一个8字节的整数来表示我们感兴趣的整个时间范围。这促使我研究了noda time的(许多)结构的内存使用情况,这反过来又导致我发现了clr的对齐决策中的一个稍微奇怪的地方。

首先,我认识到这是一个实现决策,并且默认行为可以随时更改。我知道我可以使用[StructLayout][FieldOffset]修改它,但我宁愿想出一个可能不需要的解决方案。

我的核心方案是,我有一个struct,其中包含一个引用类型字段和两个其他值类型字段,其中这些字段是int的简单包装。我希望在64位的clr上用16个字节来表示(8个用于引用,4个用于其他每个),但出于某种原因,它使用的是24个字节。我用数组来测量空间,顺便说一句,我知道在不同的情况下,布局可能会有所不同,但这似乎是一个合理的起点。

下面是一个演示这个问题的示例程序:

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字节的倍数地址对齐。即使显式应用[StructLayout(LayoutKind.Sequential)]属性,也会发生这种情况。这不应该发生。

您可以通过将结构成员设为公共的并像这样附加测试代码来看到它:

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字节整数,并将&test放入地址字段:

1
 0x000000E928B5DE98  0ed750e0 000000e9 11111111 00000000 22222222 00000000

0xe90ed750e0是我的机器(不是你的)上的字符串指针。您可以很容易地看到Int32Wrappers,加上额外的4个字节的填充,将大小转换为24个字节。返回结构并将字符串放在最后。重复,您将看到字符串指针仍然是第一个。违反了LayoutKind.Sequential,你得到了LayoutKind.Auto

很难说服微软来解决这个问题,它已经用了太长的时间,所以任何改变都会破坏某些东西。clr只试图满足[StructLayout]对于结构的托管版本的要求,并使其具有可攻击性,通常它很快就放弃了。众所周知,任何包含日期时间的结构。只有在封送结构时才能获得真正的布局类型保证。封送版本当然是16个字节,正如Marshal.SizeOf()所说。

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


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

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