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


121

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提示我希望在指定常量字段偏移的情况下打包字段?


1
您实际上似乎并没有在使用,Ref<T>而是在使用,string而不是应该有所作为。
tvanfosson

2
如果您放置两个用两个创建结构TwoInt32Wrappers,或者一个Int64和一个会发生TwoInt32Wrappers什么?如果先创建泛型Pair<T1,T2> {public T1 f1; public T2 f2;},然后再创建Pair<string,Pair<int,int>>and Pair<string,Pair<Int32Wrapper,Int32Wrapper>>呢?哪些组合迫使JITter垫东西?
超级猫

7
@supercat:最好复制代码并自己进行实验-但Pair<string, TwoInt32Wrappers> 确实只提供16个字节,因此可以解决此问题。迷人。
乔恩·斯基特

9
@SLaks:有时当结构传递给本机代码时,运行时会将所有数据复制到具有不同布局的结构中。 Marshal.SizeOf将返回将传递给本机代码的结构的大小,而本机代码与.NET代码中的结构的大小没有任何关系。
超级猫

5
有趣的发现:Mono提供了正确的结果。环境:Unix 3.13.0.24(64位)上的CLR 4.0.30319.17020 Int32Wrapper:4个TwoInt32s:8个TwoInt32Wrappers:8个RefAndTwoInt32s:16个RefAndTwoInt32Wrappers:16
AndreyAkinshin

Answers:


85

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

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

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

遇到断点时,请使用Debug + Windows + Memory + Memory1。切换到4个字节的整数,然后&test在Address字段中输入:

 0x000000E928B5DE98  0ed750e0 000000e9 11111111 00000000 22222222 00000000 

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

要说服微软解决这个问题将很困难,它已经以这种方式工作了太久了,因此任何更改都将破坏某些东西。CLR仅尝试尊重[StructLayout]结构的托管版本并使它可蓝变,因此通常会很快放弃。众所周知,任何包含DateTime的结构。封送结构时,您只能获得真正的LayoutKind保证。Marshal.SizeOf()告诉您,封送处理的版本肯定是16个字节。

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


7
“要说服微软来解决这个问题将很困难,它已经以这种方式工作了太长时间,因此任何更改都将破坏某些东西。” 这显然不会以32位或单声道显示的事实可能会有所帮助(根据其他评论)。
NPSF3000

StructLayoutAttribute的文档非常有趣。基本上,只能通过可管理内存中的StructLayout控制blittable类型。有趣,从来不知道。
Michael Stum

@Soner不,它不能修复它。您是否在两个字段上都将Layout偏移了8?如果是这样,则x和y相同,改变一个会改变另一个。显然不是乔恩追求的。
BartoszAdamczewski 2014年

替换string为另一个class已应用的新引用类型()[StructLayout(LayoutKind.Sequential)]似乎并没有改变任何内容。相反,将更改应用于[StructLayout(LayoutKind.Auto)]struct Int32Wrapper的内存使用情况TwoInt32Wrappers
Jeppe Stig Nielsen

1
“要说服微软来解决这个问题将很困难,它已经以这种方式工作了太长时间,因此任何更改都将破坏某些东西。” xkcd.com/1172
iCodeSometime

19

编辑2

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的对齐长度将相同。因此,自定义结构包装器将始终与结构中的最高/最佳对齐字段或其自身内部的最高有效字段对齐。因此,如果ref字符串是8字节对齐的,则struct wrapper会与此对齐。

在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

该结构实际​​上是32个字节,因为它的16个字节是为填充而保留的,因此实际上每个结构从一开始就至少有16个字节的大小。

如果您将16个字节的整数和一个字符串引用添加到:0000000003e72d18 + 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字节的字符串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个字节,这不是最佳选择,因此mem分配器将永远不允许它发生。如果再次进行数学运算,结果会发现该结构的大小确实为40个字节。

因此,如果您希望对内存更加保守,则永远不要将其打包为struct自定义struct类型,而应使用简单的数组。另一种方法是通过堆外分配内存(例如VirtualAllocEx),这样您便拥有了自己的内存块,并且可以按自己的方式进行管理。

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


1
我自己看,它的大小RefAndTwoInt32Wrappers 不是 32个字节-它是24个字节,与我的代码报告的相同。如果您在内存视图中查看而不是使用dumparray,并查看包含(例如)3个具有可区分值的元素的数组的内存,则可以清楚地看到每个元素由一个8字节的字符串引用和两个8字节的整数组成。我怀疑将dumparray这些值显示为引用只是因为它不知道如何显示Int32Wrapper值。这些“指称”指向自己。它们不是单独的值。
乔恩·斯基特

1
我不太确定从哪里获得“ 16字节填充”,但我怀疑这可能是因为您正在查看数组对象的大小,即“ 16字节+计数*元素大小”。因此,显示为2的数组的大小为72(16 + 2 * 24)dumparray
乔恩·斯基特

@jon您是否将结构体转储并检查了它在堆中占用了多少空间?通常,数组大小会保留在数组的开头,这也可以进行验证。
BartoszAdamczewski 2014年

@jon报告的大小还包含从8开始的字符串的偏移量。我不认为提到的那些额外的8个字节来自数组,因为大多数数组内容都位于第一个元素地址之前,但是我会仔细检查并对此发表评论。
BartoszAdamczewski 2014年

1
不,ThreeInt32Wrappers最终为12个字节,FourInt32Wrappers为16个字节,FiveInt32Wrappers为20个字节。我看不到添加引用类型字段如此剧烈地改变布局的任何逻辑。请注意,当字段为类型时,忽略8字节对齐非常高兴Int32。老实说,我不太担心它在堆栈上的作用-但我没有检查过。
乔恩·斯基特

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

一旦添加了对象引用,所有结构都将扩展为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分配的对齐方式不同。


4

只是为了添加一些数据-我从您已有的类型中又创建了一种类型:

struct RefAndTwoInt32Wrappers2
{
    string text;
    TwoInt32Wrappers z;
}

程序写出:

RefAndTwoInt32Wrappers2: 16

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


您正在运行64位吗?对准是在32位的细
奔亚当斯

对于各种环境,我的发现与其他所有人相同。
Jesse C. Slicer 2014年
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.