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

Jon*_*eet 121 .net c# clr struct memory-alignment

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

首先,我意识到这一个实现决策,并且默认行为可能随时发生变化.我意识到我可以使用[StructLayout]和修改它[FieldOffset],但我想提出一个解决方案,如果可能的话不需要它.

我的核心方案是我有一个struct包含引用类型字段和另外两个值类型字段,其中这些字段是简单的包装器int.我希望,这将被表示为对64位的CLR(8用于参考和4每个其他)16个字节,但由于某些原因它使用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);
    }
}
Run Code Online (Sandbox Code Playgroud)

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

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
Run Code Online (Sandbox Code Playgroud)

所以:

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

Han*_*ant 85

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

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

    var test = new RefAndTwoInt32Wrappers();
    test.text = "adsf";
    test.x.x = 0x11111111;
    test.y.x = 0x22222222;
    Console.ReadLine();      // <=== Breakpoint here
Run Code Online (Sandbox Code Playgroud)

当断点命中时,使用Debug + Windows + Memory + Memory 1.切换到4字节整数并&test输入Address字段:

 0x000000E928B5DE98  0ed750e0 000000e9 11111111 00000000 22222222 00000000 
Run Code Online (Sandbox Code Playgroud)

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

很难说服微软解决这个问题,它已经用这种方式工作了太久,所以任何改变都会破坏某些东西.CLR只尝试为[StructLayout]结构的托管版本提供荣誉并使其变得模糊,它通常很快就会放弃.众所周知,任何包含DateTime的结构.编组结构时,只能获得真正的LayoutKind保证.正如Marshal.SizeOf()告诉你的那样,封送版本肯定是16个字节.

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

  • "很难说服微软解决这个问题,它已经用这种方式工作了太久,所以任何改变都会破坏某些东西." 事实上,这显然不会显示32位或单声道可能会有所帮助(根据其他评论). (7认同)

Bar*_*ski 19

EDIT2

struct RefAndTwoInt32Wrappers
{
    public int x;
    public string s;
}
Run Code Online (Sandbox Code Playgroud)

此代码将以8字节对齐,因此struct将具有16个字节.相比之下:

struct RefAndTwoInt32Wrappers
{
    public int x,y;
    public string s;
}
Run Code Online (Sandbox Code Playgroud)

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

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

struct RefAndTwoInt32Wrappers
{
    public int x,y;
    public Int32Wrapper z;
    public string s;
}
Run Code Online (Sandbox Code Playgroud)

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

struct RefAndTwoInt32Wrappers
{
    public Int32Wrapper z;
    public long l;
    public int x,y;  
}
Run Code Online (Sandbox Code Playgroud)

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

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


编辑

这些大小实际上只有在堆上分配时才是准确的,但结构本身的大小较小(它的字段的确切大小).进一步分析接缝表明这可能是CLR代码中的错误,但需要通过证据进行备份.

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


这是.NET mem allocator使用的对齐策略.

public static RefAndTwoInt32s[] test = new RefAndTwoInt32s[1];

static void Main()
{
    test[0].text = "a";
    test[0].x = 1;
    test[0].x = 1;

    Console.ReadKey();
}
Run Code Online (Sandbox Code Playgroud)

此代码在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
Run Code Online (Sandbox Code Playgroud)

一旦我们拥有它,让我们看看该地址下的内容:

    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
Run Code Online (Sandbox Code Playgroud)

我们看到这是一个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
Run Code Online (Sandbox Code Playgroud)

该结构实际​​上是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  <<
Run Code Online (Sandbox Code Playgroud)

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

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
Run Code Online (Sandbox Code Playgroud)

我们的结构现在是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
Run Code Online (Sandbox Code Playgroud)

这里的情况是一样的,如果我们添加到0000000003c22d18 + 8字节的字符串ref,我们将在第一个Int包装器的开头结束,其中值实际指向我们所在的地址.

现在我们可以看到每个值都是一个对象引用再次让我们通过查看0000000003c22d20进行确认.

0:004> !do 0000000003c22d20
<Note: this object has an invalid CLASS field>
Invalid object
Run Code Online (Sandbox Code Playgroud)

实际上这是正确的,因为它是一个结构,地址告诉我们什么,如果这是一个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
Run Code Online (Sandbox Code Playgroud)

所以实际上这更像是一种Union类型,这次将对齐8字节(所有填充都将与父结构对齐).如果不是那么我们最终将得到20个字节并且这不是最佳的,因此mem分配器永远不会允许它发生.如果你再次进行数学计算,结果表明结构确实是40字节的大小.

因此,如果你想对内存更加保守,你应该永远不要将它打包在struct custom结构类型中,而是使用简单的数组.另一种方法是从堆中分配内存(例如VirtualAllocEx),这样您就可以获得自己的内存块并按照自己的方式进行管理.

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

  • 不,ThreeInt32Wrappers 最终为 12 个字节,FourInt32Wrappers 最终为 16 个字节,FiveInt32Wrappers 最终为 20 个字节。我认为添加引用类型字段会如此彻底地改变布局,这没有任何逻辑。请注意,当字段的类型为“Int32”时,它很乐意忽略 8 字节对齐。老实说,我不太关心它在堆栈上的作用 - 但我还没有检查过它。 (2认同)

Ben*_*ams 9

总结请参阅@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
Run Code Online (Sandbox Code Playgroud)

添加对象引用后,所有结构都会扩展为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
Run Code Online (Sandbox Code Playgroud)

正如您所看到的,只要添加引用,每个Int32Wrapper都会变为8个字节,因此不是简单的对齐.我缩小了数组分配,因为LoH分配是不同的.