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

125
Noda Time v2中,我们将转向纳秒分辨率。这意味着我们不能再使用8字节整数来表示我们感兴趣的整个时间范围。这促使我调查Noda Time的(许多)结构体的内存使用情况,这反过来又让我发现了CLR对齐决策中的一些奇怪之处。
首先,我意识到这是一项实现决策,并且默认行为随时可能更改。我意识到我可以使用[StructLayout][FieldOffset]进行修改,但如果可能的话,我宁愿提出一个不需要这样做的解决方案。
我的核心场景是我有一个包含引用类型字段和两个其他值类型字段的结构体,其中这些字段是对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提示我想要字段被打包 而不需要 指定常量字段偏移量吗?


2
如果您创建一个包含两个 TwoInt32Wrappers 或者一个 Int64 和一个 TwoInt32Wrappers 的结构体,会发生什么?如果您创建一个泛型 Pair<T1,T2> {public T1 f1; public T2 f2;},然后创建 Pair<string,Pair<int,int>>Pair<string,Pair<Int32Wrapper,Int32Wrapper>>,会发生什么?哪些组合会强制 JITter 填充数据? - supercat
7
@supercat建议你最好复制代码并自己尝试一下。但是Pair<string, TwoInt32Wrappers>确实只需要16个字节,这可以解决问题。有趣。 - Jon Skeet
9
有时候当一个结构被传递给本地代码时,运行时会将所有数据复制到一个布局不同的结构中。Marshal.SizeOf会返回将要传递给本地代码的结构的大小,这个大小可能和.NET代码中的结构大小没有任何关系。 - supercat
1
DateTime导致自动布局的解释很简单:它本身具有自动布局。如果一个结构包含DateTime字段,为什么LayoutKind.Sequential会有不同的工作方式? - CodesInChaos
5
有趣的发现:Mono可以得到正确的结果。 环境:在Unix 3.13.0.24(64位)上运行CLR 4.0.30319.17020。 Int32Wrapper:4 TwoInt32s:8 TwoInt32Wrappers:8 RefAndTwoInt32s:16 RefAndTwoInt32Wrappers:16 - AndreyAkinshin
显示剩余17条评论
4个回答

88

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

您可以通过将结构成员设为public并追加以下测试代码来查看:

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

当断点停止时,使用调试(Debug) + 窗口(Window) + 内存(Memory) + 内存 1 (Memory 1)。切换到 4 字节整数,并将 &test 放在地址(Address)字段中:

 0x000000E928B5DE98  0ed750e0 000000e9 11111111 00000000 22222222 00000000 

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

很难说服Microsoft修复它,它已经工作了太长时间,所以任何更改都会破坏某些东西。CLR仅尝试遵守struct的托管版本并使其可暴露,一般很快就会放弃。尤其是任何包含DateTime的struct。只有在进行结构体marshaling时才能获得真正的LayoutKind保证。通过调用Marshal.SizeOf()可以确定marshaled版本确实为16字节。

使用LayoutKind.Explicit可以解决问题,这可能不是你想听到的。


8
"说服微软修复这个问题会很困难,因为它已经长期以来一直运作这样,所以任何改变都可能会破坏其他东西。据其他评论所述,这个问题似乎在32位或单声道中并没有表现出来,这可能有所帮助。" - NPSF3000
StructLayoutAttribute 的文档非常有趣。基本上,只有可按位复制的类型才会通过 StructLayout 在托管内存中进行控制。有趣,以前从未知道过。 - Michael Stum
@Soner 不,它不能解决问题。你是否将布局放在两个字段上以偏移8?如果是这样,那么x和y是相同的,更改一个会更改另一个。显然这不是Jon想要的。 - BartoszAdamczewski
string 替换为另一个新的引用类型(class),并应用 [StructLayout(LayoutKind.Sequential)],似乎并没有改变任何东西。相反地,将 [StructLayout(LayoutKind.Auto)] 应用于 struct Int32Wrapper,会改变 TwoInt32Wrappers 中的内存使用情况。 - Jeppe Stig Nielsen
1
很难说服微软修复这个问题,因为它已经运行了很长时间,所以任何改变都可能会破坏某些东西。http://xkcd.com/1172/ - iCodeSometime

20

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字节。一旦我们在结构体中引入引用类型,CLR将始终对齐我们的自定义结构以匹配类对齐。

struct RefAndTwoInt32Wrappers
{
    public Int32Wrapper z;
    public long l;
    public int x,y;  
}

这段代码的大小为24字节,因为Int32Wrapper将与long对齐。因此,自定义结构包装器将始终与结构中最高/最佳对齐的字段对齐,或者对齐到其自身内部最重要的字段。因此,在ref string对齐为8字节的情况下,结构包装器将对齐到它上面。

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

编辑

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

这段代码使用 .net40 编译,在 x64 下运行。在 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
我们可以看到这是一个值类型,并且这是我们创建的类型。由于这是一个数组,我们需要获取数组中单个元素的值类型定义:

我们发现这是一个值类型,而且是我们自己创建的。由于这是一个数组,我们需要获取数组中单个元素的值类型定义:

    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字节。

如果你从int和一个字符串引用中添加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个字节的字符串引用添加到中,我们将最终到达第一个Int包装器的开头,该值实际上指向我们所在的地址。

现在我们可以看到每个值都是一个对象引用,让我们通过窥视0000000003c22d20来确认。

0:004> !do 0000000003c22d20
<Note: this object has an invalid CLASS field>
Invalid object

因为它是一个结构体,所以地址并不能告诉我们这是一个对象还是虚函数表,实际上这是正确的。

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字节大小。

如果您想更节约内存,您不应该打包在自定义结构体类型中,而是使用简单的数组。另一种方法是从堆外分配内存(例如VirtualAllocEx),以这种方式,您将获得自己的内存块,并且可以按照您想要的方式管理它。

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


1
仔细看了一下,RefAndTwoInt32Wrappers 的大小不是 32 字节 - 它是 24 字节,这与我的代码报告的大小相同。如果您查看内存视图而不是使用 dumparray,并查看具有(例如)3 个具有可区分值的元素的数组的内存,则可以清楚地看到每个元素由一个 8 字节的字符串引用和两个 8 字节的整数组成。我怀疑 dumparray 之所以将这些值显示为引用,只是因为它不知道如何显示 Int32Wrapper 值。那些“引用”指向它们自己;它们不是单独的值。 - Jon Skeet
1
我不太确定你从哪里得到了“16字节填充”,但我怀疑这可能是因为你正在查看数组对象的大小,它将是“16字节+计数*元素大小”。因此,一个具有计数2的数组的大小为72(16 + 2 * 24),这就是dumparray所显示的。 - Jon Skeet
是的,WinDbg报告的确是整个数组对象的正确大小,但这并不是结构体大小。那就是我的观点。我认为我们在误解对方的意思......但我确信RefAndTwoInt32Wrappers的大小确实是24个字节。 - Jon Skeet
@jon 这是逻辑上的,就像我写的那样,CLR这样做是为了保留8字节对齐,否则将不可能实现。如果你使用3个IntWrappers而没有字符串来做到这一点,你仍然会最终占用16或24字节的空间。 - BartoszAdamczewski
2
不,ThreeInt32Wrappers最终为12个字节,FourInt32Wrappers为16个字节,FiveInt32Wrappers为20个字节。我没有看到在添加引用类型字段时改变布局的任何逻辑。请注意,当字段为“Int32”类型时,它很高兴忽略8字节对齐。说实话,我对它在堆栈上的操作并不太关心 - 但我还没有检查过。 - Jon Skeet
显示剩余10条评论

9

总结见 @Hans Passant的答案,可能在上方。布局Sequential不起作用。


一些测试:

这绝对只出现在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位系统中对齐是正常的。 - Ben Adams
我的发现与其他人对于不同的环境是一样的。 - Jesse C. Slicer

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接