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

Posted

技术标签:

【中文标题】为啥结构对齐取决于字段类型是原始类型还是用户定义?【英文标题】:Why does struct alignment depend on whether a field type is primitive or user-defined?为什么结构对齐取决于字段类型是原始类型还是用户定义? 【发布时间】:2014-09-04 17:04:25 【问题描述】:

在Noda Time v2 中,我们正在转向纳秒级分辨率。这意味着我们不能再使用 8 字节整数来表示我们感兴趣的整个时间范围。这促使我研究了 Noda Time 的(许多)结构的内存使用情况,这反过来又引导了我以发现 CLR 对齐决策中的一点点奇怪之处。

首先,我意识到这一个实现决策,并且默认行为可能随时改变。我意识到我可以使用[StructLayout][FieldOffset] 对其进行修改,但如果可能的话,我宁愿想出一个不需要这样做的解决方案。

我的核心场景是我有一个struct,其中包含一个引用类型字段和两个其他值类型字段,其中这些字段是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

所以:

如果您没有引用类型字段,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 提示我希望在不指定常量字段的情况下打包字段偏移量?

【问题讨论】:

您实际上并没有使用Ref&lt;T&gt;,而是使用string,并不是说它应该有所作为。 如果你把两个创建一个带有两个TwoInt32Wrappers,或者一个Int64和一个TwoInt32Wrappers的结构会发生什么?如果你创建一个通用的Pair&lt;T1,T2&gt; public T1 f1; public T2 f2; 然后创建Pair&lt;string,Pair&lt;int,int&gt;&gt;Pair&lt;string,Pair&lt;Int32Wrapper,Int32Wrapper&gt;&gt; 怎么样?哪些组合会强制 JITter 填充内容? @supercat:您最好自己复制代码并进行试验 - 但Pair&lt;string, TwoInt32Wrappers&gt; 确实 只提供 16 个字节,这样就可以解决问题。令人着迷。 @SLaks:有时当结构传递给本机代码时,运行时会将所有数据复制到具有不同布局的结构中。 Marshal.SizeOf 将返回将传递给本机代码的结构的大小,这与 .NET 代码中结构的大小没有任何关系。 有趣的观察:Mono 给出了正确的结果。环境: Unix 3.13.0.24(64 位)上的 CLR 4.0.30319.17020 Int32Wrapper:4 TwoInt32s:8 TwoInt32Wrappers:8 RefAndTwoInt32s:16 RefAndTwoInt32Wrappers:16 【参考方案1】:

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

您可以通过公开结构成员并附加测试代码来查看它,如下所示:

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

 0x000000E928B5DE98  0ed750e0 000000e9 11111111 00000000 22222222 00000000 

0xe90ed750e0 是我机器上的字符串指针(不是你的)。您可以轻松看到Int32Wrappers,额外的 4 字节填充将大小变为 24 字节。返回结构并将字符串放在最后。重复一遍,你会首先看到字符串指针是 still。违反LayoutKind.Sequential,你得到LayoutKind.Auto

说服 Microsoft 解决此问题将很困难,它已经以这种方式工作了太久,因此任何更改都会破坏某些东西。 CLR 仅尝试为结构的托管版本兑现 [StructLayout] 并使其可blittable,它通常很快就放弃了。众所周知,对于任何包含 DateTime 的结构。只有在编组结构时才能获得真正的 LayoutKind 保证。正如Marshal.SizeOf() 会告诉你的那样,封送版本当然是 16 字节。

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

【讨论】:

“要说服微软解决这个问题是很困难的,它已经这样工作太久了,所以任何改变都会破坏一些东西。”这显然不会在 32 位或单声道中体现这一事实可能会有所帮助(根据其他 cmets)。 StructLayoutAttribute 的文档非常有趣。基本上,只有 blittable 类型通过托管内存中的 StructLayout 进行控制。有趣,从来不知道。 @Soner 不,它不能修复它。您是否将两个字段上的布局都设置为偏移 8 ?如果是这样,那么 x 和 y 是相同的,并且改变一个会改变另一个。显然不是乔恩所追求的。 string 替换为已应用[StructLayout(LayoutKind.Sequential)] 的另一种新引用类型(class) 似乎没有任何改变。在相反的方向上,将[StructLayout(LayoutKind.Auto)] 应用于struct Int32Wrapper 会改变TwoInt32Wrappers 中的内存使用情况。 “要说服微软解决这个问题是很困难的,它已经这样工作太久了,所以任何改变都会破坏一些东西。” xkcd.com/1172【参考方案2】:

摘要请参阅上面的@Hans Passant 的回答。布局顺序不起作用


一些测试:

它肯定只在 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 分配。

【讨论】:

【参考方案3】:

EDIT2

struct RefAndTwoInt32Wrappers

    public int x;
    public string s;

此代码将是 8 字节对齐的,因此结构将有 16 个字节。相比之下:

struct RefAndTwoInt32Wrappers

    public int x,y;
    public string s;

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

现在如果我们结合所有这些并创建结构:

struct RefAndTwoInt32Wrappers

    public int x,y;
    public Int32Wrapper z;
    public string s;

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

struct RefAndTwoInt32Wrappers

    public Int32Wrapper z;
    public long l;
    public int x,y;  

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

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


编辑

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

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


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

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

这个结构实际上是 32 个字节,因为它有 16 个字节是为填充而保留的,所以实际上每个结构从一开始就至少有 16 个字节。

如果你将 16 个字节从整数和一个字符串 ref 添加到:0000000003e72d18 + 8 个字节 EE/padding,你最终会得到 0000000003e72d30,这是字符串引用的起点,因为所有引用都是从第一个开始填充 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  <<

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

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

这里的情况是一样的,如果我们添加 0000000003c22d18 + 8 个字节的字符串 ref,我们将在第一个 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

所以实际上这更像是一个 Union 类型,这次将对齐 8 个字节(所有填充都将与父结构对齐)。如果不是这样,我们最终会得到 20 个字节,这不是最佳的,所以内存分配器永远不会允许它发生。如果您再次进行数学运算,结果会发现该结构确实是 40 字节大小。

因此,如果您想在内存方面更加保守,则永远不应将其打包为 struct 自定义结构类型,而应使用简单的数组。另一种方法是在堆外分配内存(例如,VirtualAllocEx) 这样你就得到了你自己的内存块,你可以按照你想要的方式管理它。

这里的最后一个问题是为什么我们会突然得到这样的布局。好吧,如果您将 int[] 增量的 jited 代码和性能与 struct[] 与计数器字段增量进行比较,第二个将生成一个 8 字节对齐的地址作为联合,但是当 jited 时,这将转换为更优化的汇编代码(单LEA 与多个 MOV)。但是,在此处描述的情况下,性能实际上会更差,所以我认为这与底层 CLR 实现是一致的,因为它是可以具有多个字段的自定义类型,因此放置起始地址而不是值(因为这是不可能的)并在那里进行结构填充,从而导致更大的字节大小。

【讨论】:

我自己看这个,RefAndTwoInt32Wrappers 的大小 不是 32 字节 - 是 24,与我的代码报告的相同。如果您查看内存视图而不是使用dumparray,并查看具有(例如)3 个具有可区分值的元素的数组的内存,您可以清楚地看到每个元素由一个 8 字节字符串引用和两个 8 -字节整数。我怀疑dumparray 将值显示为引用仅仅是因为它不知道如何显示Int32Wrapper 值。这些“参考”指向自己;它们不是单独的值。 我不太确定您从哪里获得“16 字节填充”,但我怀疑这可能是因为您正在查看数组对象的大小,即“16 字节” + 计数 * 元素大小”。所以计数为 2 的数组的大小为 72 (16 + 2 * 24),这就是 dumparray 显示的内容。 @jon 你转储了你的结构并检查了它在堆上占用了多少空间?通常数组大小保持在数组的开头,这也可以验证。 @jon 报告的大小还包含从 8 开始的字符串的偏移量。我不认为提到的那些额外的 8 个字节来自数组,因为大多数数组内容都位于第一个元素之前地址,但我会仔细检查并对此发表评论。 不,ThreeInt32Wrappers 最终为 12 个字节,FourInt32Wrappers 为 16,FiveInt32Wrappers 为 20。我认为添加引用类型字段如此剧烈地改变布局没有任何逻辑。请注意,当字段类型为 Int32 时,忽略 8 字节对齐是很高兴的。老实说,我并不太关心它在堆栈上的作用——但我还没有检查过。【参考方案4】:

只是为了添加一些数据 - 我从你拥有的那些中创建了另一种类型:

struct RefAndTwoInt32Wrappers2

    string text;
    TwoInt32Wrappers z;

程序写出:

RefAndTwoInt32Wrappers2: 16

所以看起来TwoInt32Wrappers 结构在新的RefAndTwoInt32Wrappers2 结构中正确对齐。

【讨论】:

您运行的是 64 位吗?对齐在 32 位中很好 对于各种环境,我的发现与其他人一样。

以上是关于为啥结构对齐取决于字段类型是原始类型还是用户定义?的主要内容,如果未能解决你的问题,请参考以下文章

为啥在使用嵌套查询时不能引用用户定义的类型字段?

为啥固定大小的缓冲区只能是原始类型?

自定义类型

数组是原始类型还是对象(或完全是其他东西)?

自定义类型之结构体

C语言进阶自定义类型