在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
所以:
Int32Wrapper
个字段打包在一起(TwoInt32Wrappers
的大小为8)int
个字段打包在一起(RefAndTwoInt32s
的大小为16)Int32Wrapper
字段似乎填充/对齐到8个字节。 (RefAndTwoInt32Wrappers
的大小为24。)其他一些实验产生了类似的结果:
object
而不是string
并不起作用(我希望它的#34;任何参考类型&#34;)int
字段仍然计为4个字节,Int32Wrapper
个字段计为8个字节[StructLayout(LayoutKind.Sequential, Pack = 4)]
添加到视线中的每个结构中都不会改变结果有没有人对此有任何解释(理想情况下是参考文档)或建议我如何得到CLR的提示我希望打包的字段没有指定一个恒定场偏移?
答案 0 :(得分: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 + Memory 1.切换到4字节整数并将&test
放在Address字段中:
0x000000E928B5DE98 0ed750e0 000000e9 11111111 00000000 22222222 00000000
0xe90ed750e0
是我机器上的字符串指针(不是你的)。您可以轻松地看到Int32Wrappers
,其中额外的4个字节的填充将大小变为24个字节。返回结构并将字符串放在最后。重复一遍,你会看到字符串指针仍然是。违反LayoutKind.Sequential
,您获得LayoutKind.Auto
。
很难说服微软解决这个问题,它已经用这种方式工作了太久,所以任何改变都会破坏某些东西。 CLR只尝试为结构的托管版本提供[StructLayout]
并使其变得明显,它通常很快就会放弃。众所周知,任何包含DateTime的结构。编组结构时,只能获得真正的LayoutKind保证。编组版本肯定是16个字节,Marshal.SizeOf()
会告诉你。
使用LayoutKind.Explicit
修复它,而不是您想要听到的内容。
答案 1 :(得分:19)
EDIT2
struct RefAndTwoInt32Wrappers
{
public int x;
public string s;
}
此代码将以8字节对齐,因此struct将具有16个字节。相比之下:
struct RefAndTwoInt32Wrappers
{
public int x,y;
public string s;
}
将4字节对齐,因此该结构也将具有16个字节。所以这里的基本原理是CLR中的结构对齐由大多数对齐字段的数量决定,clases显然不能这样做,因此它们将保持8字节对齐。
现在,如果我们将所有这些结合起来并创建struct:
struct RefAndTwoInt32Wrappers
{
public int x,y;
public Int32Wrapper z;
public string s;
}
它将有24个字节{x,y}每个将有4个字节,{z,s}将有8个字节。一旦我们在结构中引入了ref类型,CLR将始终对齐我们的自定义结构以匹配类对齐。
struct RefAndTwoInt32Wrappers
{
public Int32Wrapper z;
public long l;
public int x,y;
}
此代码将具有24个字节,因为Int32Wrapper将与long排列相同。因此,自定义结构包装器将始终与结构中最高/最佳对齐的字段或其自身内部最重要的字段对齐。因此,对于8字节对齐的ref字符串,struct wrapper将与之对齐。
结构中的结束自定义结构字段将始终与结构中最高对齐的实例字段对齐。现在,如果我不确定这是不是一个错误,但没有一些证据,我会坚持认为这可能是有意识的决定。
编辑
这些大小实际上只有在堆上分配时才是准确的,但结构本身的大小较小(它的字段的确切大小)。进一步分析接缝表明这可能是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();
}
此代码在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个字节。
如果你从ints添加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个字节并且这不是最佳的,所以mem分配器永远不会允许它发生。如果你再次进行数学计算,结果表明结构确实是40字节的大小。
因此,如果你想对内存更加保守,你应该永远不要将它打包在struct custom结构类型中,而是使用简单的数组。另一种方法是从堆中分配内存(例如VirtualAllocEx) 通过这种方式,您可以获得自己的内存块,并按照自己的方式进行管理。
这里的最后一个问题是为什么突然之间我们可能会得到这样的布局。好吧,如果你将带有struct []的int []增量的jited代码和性能与计数器字段增量进行比较,第二个将生成一个8字节对齐的地址作为并集,但是当jited时,这会转换为更优化的汇编代码(singe) LEA与多个MOV)。但是在这里描述的情况下,性能实际上会更差,所以我认为这与底层CLR实现是一致的,因为它是一个可以有多个字段的自定义类型,因此可能更容易/更好地放置起始地址而不是value(因为它是不可能的)并在那里进行struct padding,从而导致更大的字节大小。
答案 2 :(得分:8)
摘要见@Hans Passant的答案可能在上面。布局顺序不起作用
一些测试:
绝对只能在64位和对象参考&#34;毒药&#34;结构。 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 :(得分:4)
只是添加一些数据 - 我创建了一个类型:
struct RefAndTwoInt32Wrappers2
{
string text;
TwoInt32Wrappers z;
}
该程序写出:
RefAndTwoInt32Wrappers2: 16
所以看起来TwoInt32Wrappers
结构在新的RefAndTwoInt32Wrappers2
结构中正确对齐。