.NET数组的开销是什么?

41

我试图通过这段代码来确定在32位进程中的.NET数组头部开销:

long bytes1 = GC.GetTotalMemory(false);
object[] array = new object[10000];
    for (int i = 0; i < 10000; i++)
        array[i] = new int[1];
long bytes2 = GC.GetTotalMemory(false);
array[0] = null; // ensure no garbage collection before this point

Console.WriteLine(bytes2 - bytes1);
// Calculate array overhead in bytes by subtracting the size of 
// the array elements (40000 for object[10000] and 4 for each 
// array), and dividing by the number of arrays (10001)
Console.WriteLine("Array overhead: {0:0.000}", 
                  ((double)(bytes2 - bytes1) - 40000) / 10001 - 4);
Console.Write("Press any key to continue...");
Console.ReadKey();

结果是

    204800
    Array overhead: 12.478

在32位进程中,object[1]应该与int[1]大小相同,但实际上开销会增加3.28字节,变成

    237568
    Array overhead: 15.755

有人知道为什么吗?

(顺便说一句,如果有人好奇的话,上面循环中非数组对象的开销,比如(object)i,大约为8字节(8.384)。我听说在64位进程中是16字节。)


2
这是在调试版还是发布版中? - Tamas Czinege
嗯,我其实不知道,我一直在使用SnippetCompiler。当我切换到Visual Studio时,结果略有变化:int [1]为11.92,object [1]为15.94,无论是Debug还是Release构建。 - Qwertie
哦,我还将其更改为100000,以减少内存管理器中分配粒度的影响。 - Qwertie
5个回答

50

这里是一个稍微整洁一些(我认为)的简短完整程序,用于演示同样的事情:

using System;

class Test
{
    const int Size = 100000;

    static void Main()
    {
        object[] array = new object[Size];
        long initialMemory = GC.GetTotalMemory(true);
        for (int i = 0; i < Size; i++)
        {
            array[i] = new string[0];
        }
        long finalMemory = GC.GetTotalMemory(true);
        GC.KeepAlive(array);

        long total = finalMemory - initialMemory;

        Console.WriteLine("Size of each element: {0:0.000} bytes",
                          ((double)total) / Size);
    }
}

但我得到了相同的结果 - 任何引用类型的数组开销为16字节,而任何值类型的数组开销为12字节。我仍在努力弄清楚这是为什么,正在查看CLI规范进行研究。别忘了,引用类型数组是协变的,这可能是相关的...

编辑:借助cordbg的帮助,我可以确认Brian的答案 - 引用类型数组的类型指针与实际元素类型无关。可能在 object.GetType() 中存在某些特殊性(请记住,它是非虚拟的)来解决这个问题。

因此,代码如下:

object[] x = new object[1];
string[] y = new string[1];
int[] z = new int[1];
z[0] = 0x12345678;
lock(z) {}
我们最终得到类似以下的东西:
Variables:
x=(0x1f228c8) <System.Object[]>
y=(0x1f228dc) <System.String[]>
z=(0x1f228f0) <System.Int32[]>

Memory:
0x1f228c4: 00000000 003284dc 00000001 00326d54 00000000 // Data for x
0x1f228d8: 00000000 003284dc 00000001 00329134 00000000 // Data for y
0x1f228ec: 00000000 00d443fc 00000001 12345678 // Data for z
请注意,我已经将变量本身的值之前的1个字长的内存倒出。 对于x和y,它们的值是: - 同步块,用于锁定哈希码(或薄锁-请参见Brian的评论) - 类型指针 - 数组的大小 - 元素类型指针 - 空引用(第一个元素)
对于z,其值为: - 同步块 - 类型指针 - 数组的大小 - 0x12345678(第一个元素)
不同类型的值类型数组(byte [],int []等)最终会得到不同的类型指针,而所有引用类型数组都使用相同的类型指针,但具有不同的元素类型指针。元素类型指针的值与您在该类型的对象的类型指针中找到的值相同。因此,如果我们查看上述运行中的字符串对象的内存,它将具有0x00329134的类型指针。
类型指针之前的单词肯定与监视器或哈希代码有关:调用GetHashCode()会填充该内存位,并且我相信默认的object.GetHashCode()会获取同步块,以确保对象的生命周期内哈希码的唯一性。然而,仅仅执行lock(x){}并没有做任何事情,这让我感到惊讶...
顺便说一句,所有这些仅适用于“向量”类型-在CLR中,“向量”类型是具有下限为0的单维数组。其他数组将具有不同的布局-首先,它们需要存储下限...
到目前为止,这只是实验,但以下是猜测-系统被实现的原因。从这里开始,我真的只是猜测。
-object[]数组可以共享相同的JIT代码。它们在内存分配、数组访问、Length属性以及GC引用的布局方面都将表现出相同的行为。与值类型数组相比,其中不同的值类型可能具有不同的GC“足迹”(例如,一个可能具有一个字节和一个引用,而其他数组根本没有引用等)。 -每次在object[]中分配值时,运行时都需要检查它是否有效。它需要检查您用于新元素值的对象引用的类型是否与数组的元素类型兼容。例如:
object[] x = new object[1];
object[] y = new string[1];
x[0] = new object(); // Valid
y[0] = new object(); // Invalid - will throw an exception
这就是我之前提到的协方差。考虑到这将在每个分配中发生,因此减少间接性很有意义。特别是,我怀疑您不希望通过每次分配都去获取元素类型来消耗缓存。我猜测(我的x86汇编水平还不够好),该测试是这样的:
  • 要复制的值是否为null引用?如果是,则没问题。(完成)
  • 获取引用指向的对象的类型指针。
  • 该类型指针与元素类型指针相同(简单的二进制相等检查)吗?如果是,则没问题。(完成)
  • 该类型指针是否可以赋值给元素类型指针?(涉及继承和接口的更复杂检查) 如果是,则没问题-否则,抛出异常。
如果我们能在前三个步骤中终止搜索,那么就没有太多间接性——对于像数组分配这样经常发生的操作而言,这非常有利。对于值类型分配,无需进行任何操作,因为这是静态可验证的。
因此,这就是我认为引用类型数组稍微比值类型数组大的原因。
很棒的问题-真的很有趣 :)

Philip的第一个链接(http://www.codeproject.com/KB/dotnet/arrays.aspx)有一个答案:“引用类型也有一个元素类型字段,它在数据之前; 它似乎是冗余的,因为数组的方法表可以提取必要的类型信息。元素类型作为字段的存在使得元素类型信息能够快速提取,而不需要虚拟调用间接和函数调用,这对于数组协变等功能非常重要。” - Qwertie
Jon,我相信我找到了缺失的四个字节。请看我的答案。如果你想要,我可以添加必要的WinDbg转储以进行说明。 - Brian Rasmussen
顺便说一句,加1点赞为这个事实做出了突出的贡献。 - Brian Rasmussen
你在实例前面看到的字节是SyncBlock条目。然而,它比这更复杂,因为正如你所提到的,该字段既用于锁定又用于哈希码。CLR 2.0引入了一种优化称为薄锁。据我了解,只要SyncBlock字段未用于哈希码并且锁未被争用,薄锁就可以直接存储其中。如果发生其中任何一种情况,锁将被“升级”为真正的SyncBlock锁。即将推出的《高级.NET调试》书籍(http://my.safaribooksonline.com/0321589637)中有更多详细信息。 - Brian Rasmussen
啊...锁退出后,薄锁被清除。这很有道理 - 我现在已经验证了,如果您在锁语句中转储内存,那么该单词将获得一个值。 - Jon Skeet
使用.NET 5时,当我对intbytestringdouble空数组运行GetTotalMemory测试时,所有类型在32位中的空数组大小均为12字节,包括string。但是,4字节对齐,例如new byte[1]的大小为16字节而不是13字节。此外,在64位中,所有类型的空数组大小均为24字节,8字节对齐,例如new int[1]的大小为32字节而不是28字节。在Windows 10上测试,运行时版本为5.0.203。 - zahir

23

数组是引用类型。所有引用类型都带有两个额外的字字段:类型引用和 SyncBlock 索引字段,其中 SyncBlock 索引字段等其他内容用于在 CLR 中实现锁。因此,在 32 位上,引用类型的类型开销为 8 字节。除此之外,数组本身还存储长度,这是另外 4 字节。这将使总开销为 12 字节。

我刚刚从 Jon Skeet 的回答中了解到,引用类型的数组还有另外 4 字节的开销。这可以通过 WinDbg 进行确认。事实证明,这个额外的字是数组中存储类型的类型引用。所有引用类型的数组都以 object[] 内部存储,其中包含对实际类型的类型对象的额外引用。因此,string[] 实际上只是带有指向类型为 string 的类型引用的 object[]。有关详细信息,请参见下文。

数组中存储的值:引用类型的数组保存对象的引用,因此数组中的每个条目都是一个引用的大小(即在 32 位上为 4 字节)。值类型的数组将值存储在内联中,因此每个元素将占用所涉及的类型的大小。

这个问题也可能会引起兴趣:C# List<double> size vs double[] size

详细信息

考虑以下代码:

var strings = new string[1];
var ints = new int[1];

strings[0] = "hello world";
ints[0] = 42;

附加WinDbg后显示如下:

首先让我们来看看值类型数组。

0:000> !dumparray -details 017e2acc 
Name: System.Int32[]
MethodTable: 63b9aa40
EEClass: 6395b4d4
Size: 16(0x10) bytes
Array: Rank 1, Number of elements 1, Type Int32
Element Methodtable: 63b9aaf0
[0] 017e2ad4
    Name: System.Int32
    MethodTable 63b9aaf0
    EEClass: 6395b548
    Size: 12(0xc) bytes
     (C:\Windows\assembly\GAC_32\mscorlib\2.0.0.0__b77a5c561934e089\mscorlib.dll)
    Fields:
          MT    Field   Offset                 Type VT     Attr    Value Name
    63b9aaf0  40003f0        0         System.Int32  1 instance       42 m_value <=== Our value

0:000> !objsize 017e2acc 
sizeof(017e2acc) =           16 (        0x10) bytes (System.Int32[])

0:000> dd 017e2acc -0x4
017e2ac8  00000000 63b9aa40 00000001 0000002a <=== That's the value

首先,我们将数组和值为42的一个元素转储出来。可以看到其大小为16个字节。其中4个字节用于int32值本身,8个字节用于常规引用类型开销,另外4个字节用于数组长度。

原始转储显示了SyncBlock、int[]的方法表、长度和值42(16进制中的2a)。注意,SyncBlock位于对象引用的前面。

接下来,让我们查看string[]以找出额外的单词是用来做什么的。

0:000> !dumparray -details 017e2ab8 
Name: System.String[]
MethodTable: 63b74ed0
EEClass: 6395a8a0
Size: 20(0x14) bytes
Array: Rank 1, Number of elements 1, Type CLASS
Element Methodtable: 63b988a4
[0] 017e2a90
    Name: System.String
    MethodTable: 63b988a4
    EEClass: 6395a498
    Size: 40(0x28) bytes <=== Size of the string
     (C:\Windows\assembly\GAC_32\mscorlib\2.0.0.0__b77a5c561934e089\mscorlib.dll)
    String:     hello world    
    Fields:
          MT    Field   Offset                 Type VT     Attr    Value Name
    63b9aaf0  4000096        4         System.Int32  1 instance       12 m_arrayLength
    63b9aaf0  4000097        8         System.Int32  1 instance       11 m_stringLength
    63b99584  4000098        c          System.Char  1 instance       68 m_firstChar
    63b988a4  4000099       10        System.String  0   shared   static Empty
    >> Domain:Value  00226438:017e1198 <<
    63b994d4  400009a       14        System.Char[]  0   shared   static WhitespaceChars
    >> Domain:Value  00226438:017e1760 <<

0:000> !objsize 017e2ab8 
sizeof(017e2ab8) =           60 (        0x3c) bytes (System.Object[]) <=== Notice the underlying type of the string[]

0:000> dd 017e2ab8 -0x4
017e2ab4  00000000 63b74ed0 00000001 63b988a4 <=== Method table for string
017e2ac4  017e2a90 <=== Address of the string in memory

0:000> !dumpmt 63b988a4
EEClass: 6395a498
Module: 63931000
Name: System.String
mdToken: 02000024  (C:\Windows\assembly\GAC_32\mscorlib\2.0.0.0__b77a5c561934e089\mscorlib.dll)
BaseSize: 0x10
ComponentSize: 0x2
Number of IFaces in IFaceMap: 7
Slots in VTable: 196

首先,我们将数组和字符串倒出来。然后我们输出 string[] 的大小。注意 WinDbg 在这里将类型列为 System.Object[]。在这种情况下,对象大小包括字符串本身,因此总大小是数组中的 20 加上字符串的 40。

通过转储实例的原始字节,我们可以看到以下内容:首先是 SyncBlock,然后是 object[] 的方法表,接着是数组的长度。之后,我们发现额外的 4 个字节引用了字符串的方法表。这可以通过上面显示的 dumpmt 命令进行验证。最后,我们找到对实际字符串实例的单个引用。

总之

数组的开销可以分解如下(在 32 位系统上):

  • 4 个字节的 SyncBlock
  • 4 个字节的方法表(类型引用)用于数组本身
  • 4 个字节的长度
  • 引用类型数组需要另外的 4 个字节来保存实际元素类型(引用类型数组在底层上是 object[])的方法表

也就是说,值类型数组的开销为12 个字节,而引用类型数组的开销为16 个字节


object[1]和int[1]都是引用类型。 - Qwertie
我认为你的意思是“两个额外的字段”。 - H H
@Qwertie:是的,数组是引用类型。我只是指出一个有点令人惊讶的事实,即对象占用额外的4个字节。我已经删除了这条评论,因为它与数组无关。 - Brian Rasmussen
我在http://codeproject.com/KB/dotnet/arrays.aspx上读到,额外的4个字节是为了一个“元素类型字段”。我认为它用于保存单个间接引用,因为CLR设计人员本可以将该字段包含在每个数组类型的虚函数表中。换句话说,我认为他们本可以每个引用数组类型只存储一次该字段,但他们选择在每个数组中存储该字段的副本。 - Qwertie
@Brian:看起来我们都在同时扩展我们的答案。你能检查一下我的答案是否正确吗?我还猜测了一些原因 :) - Jon Skeet
显示剩余2条评论

2
我认为在测量时你做出了一些错误的假设,因为通过GetTotalMemory进行的内存分配(循环期间)可能与仅针对数组所需的实际内存不同 - 内存可能会被分配在更大的块中,循环期间可能会回收其他内存中的对象等。
以下是有关数组开销的一些信息:

错误的假设,例如...? - Qwertie
1
错误的假设,例如 - GetTotalMemory仅通过循环中的数组分配增加,并且仅增加所需的确切数量。 - Philip Rieck
我并没有假设那个。我确实假设随着Size趋近于无穷大,GetTotalMemory会与数组的内存消耗收敛,但我意识到我一开始没有趋近于无穷大。 - Qwertie

1

很抱歉离题了,但我今天早上刚发现有关内存超载的有趣信息。

我们有一个处理大量数据(高达2GB)的项目。作为主要存储,我们使用Dictionary<T,T>。实际上创建了成千上万个字典。将其更改为键的List<T>和值的List<T>(我们自己实现了IDictionary<T,T>),内存使用量减少了约30-40%。

为什么呢?


1
如果您查看Reflector中的Dictionary类,它使用Entry<TKey, TValue>结构的内部数组。每个结构体以及键/值对象都有“hashcode”和“next”整数,由Dictionary实现使用。如果您自己的IDictionary类没有每个条目的附加数据,则这两个整数可能是额外内存使用的来源。 - thecoop
1
字典内部使用一个叫做Entry的结构体,它使用额外的存储空间来保存每个条目的“hashCode”和“next”字段。我猜想这些可能用于解决哈希冲突。 - Qwertie
1
还有一个整数“桶”数组,长度与条目数组相同,增加了4个字节的开销。因此,我预计每个条目的KeyValuePair<A,B>列表比Dictionary<A,B>要小12个字节(通常情况下),因为它只包含三个整数hashCode、next和“bucket”值。 - Qwertie
经过进一步改进我们的 LightDictionary,我们使其与标准字典一样快,但使用的内存只有三分之一。因此,从现在开始,我们的数据只需要占用700MB的RAM,而不是2GB了。 :)我只是想感谢你们。我很高兴。 - Vasyl Boroviak
你刚刚发了一条“死灵评论”。自从上次我们交谈已经过去三年半了。 :) - Vasyl Boroviak
显示剩余3条评论

1

由于堆管理(因为您处理GetTotalMemory)只能分配相当大的块,因此CLR会通过较小的块来为程序员目的分配后者。


内存不是以4KB或8KB的页面分配吗?在这种情况下,最大误差在此示例中为2-4%。我尝试将测试大小增加到100,000个数组,但这几乎没有什么区别:int [1]开销变为11.924,而object [1]开销则为15.938:仍然有3个字节的差异。 - Qwertie
@Qwertie:实际上,CLR从操作系统中分配相当大的块。在32位系统上,段通常为16 MB左右。然后将这些段用作托管堆的存储空间。 - Brian Rasmussen
在我看来,GetTotalMemory 的粒度大约为 8 KB。 - Qwertie

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