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

内容来源于 Stack Overflow,并遵循CC BY-SA 3.0许可协议进行翻译与使用

  • 回答 (2)
  • 关注 (0)
  • 查看 (39)

我意识到使用[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提示我希望将字段打包指定常量字段偏移量?

提问于
用户回答回答于

EDIT 2

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

这段代码将是8字节对齐,所以结构将有16字节。相比之下:

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

将是4字节对齐,因此这个结构也将有16个字节。因此,这里的基本原理是CLR中的结构匹配是由大多数对齐字段的数量决定的,而clases显然不能这样做,因此它们将保持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一样对齐。因此,定制的struct包装将始终与结构中最高/对齐的字段对齐,或与其内部最重要的字段对齐。因此,对于一个8字节对齐的ref字符串,struct包装器将对齐。

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

编辑

只有在堆上分配时,才能准确地确定大小,但结构本身的大小(它的字段的确切大小)较小。进一步分析Seam,表明这可能是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();
}

在WinDbg中,在x64下用.net 40编译的这段代码让我们执行以下操作:

让我们首先在堆中找到类型:

    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,它是我们创建的。由于这是一个数组,我们需要获得数组中单个元素的ValueTypedef:

    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个字节是为填充保留的,所以实际上,从GET开始,每个结构都至少有16个字节大小。

如果从INT中添加16个字节和字符串ref到:00000003e72d18+8字节EE/填充,则最终将出现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字节的字符串引用,我们将在第一个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字节对齐(所有的PAADD都将与父结构对齐)。如果不是这样的话,那么我们将得到20个字节,这不是最优的,所以mem分配器永远不会允许它发生。如果你再做一次数学运算,结果会发现这个结构实际上是40个字节的大小。

因此,如果想在内存方面更加保守,就不应该将其打包为struct自定义结构类型,而应该使用简单的数组。另一种方法是在堆上分配内存(例如,VirtualAllocEx),这样就可以给内存块,并用方式管理它。

这里的最后一个问题是,为什么突然之间我们会得到这样的布局。那么,如果比较了jitedcode和struct[]的执行情况,以及一个计数器字段的增量,那么第二个地址就会产生一个8字节对齐的地址,但是当JIT被转换成更优化的程序集代码(singeLEA与多个MOV)。但是,在这里描述的情况下,性能实际上会更差,所以我认为这与底层CLR实现是一致的,因为它是一个可以有多个字段的自定义类型,所以将起始地址放置在其中而不是值(因为不可能)并在那里填充结构可能会更容易/更好,从而导致字节大小更大。

用户回答回答于

看到了自动布局的副作用,它喜欢将非平凡的字段对齐到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+内存+内存1。切换到4字节整数并将&test在地址字段中:

 0x000000E928B5DE98  0ed750e0 000000e9 11111111 00000000 22222222 00000000 

0xe90ed750e0是我的机器上的字符串指针。可以很容易地看到Int32Wrappers,使用额外的4字节填充,将大小转换为24字节。返回到结构,并将字符串放在最后。重复,将看到字符串指针是仍然首先。

要说服微软解决这个问题是很困难的,它已经用了很长时间,所以任何改变都将被打破。CLR只是试图尊重[StructLayout]对于结构的托管版本,并使其可闪现,通常会很快放弃。对于任何包含日期时间的结构来说都是出了名的。只有在封送结构时才能得到真正的LayoutKind保证。封送版本当然是16个字节,如下所示Marshal.SizeOf()会告诉你的。

扫码关注云+社区