40

我试图使用以下代码确定 .NET 数组(在 32 位进程中)上的标头开销:

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

4

5 回答 5

51

这是一个稍微简洁(IMO)的简短但完整的程序来演示同一件事:

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

对于xy,值为:

  • 同步块,用于锁定哈希码(或薄锁- 请参阅 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
    

这就是我前面提到的协方差。现在鉴于这将发生在每个单独的 assignment中,减少间接的数量是有意义的。特别是,我怀疑您真的不想通过必须转到每个分配的类型对象来获取元素类型来破坏缓存。我怀疑(而且我的 x86 程序集不足以验证这一点)测试类似于:

  • 要复制的值是空引用吗?如果是这样,那很好。(完毕。)
  • 获取引用指向的对象的类型指针。
  • 该类型指针是否与元素类型指针相同(简单的二进制相等检查)?如果是这样,那很好。(完毕。)
  • 该类型指针赋值与元素类型指针兼容吗?(更复杂的检查,涉及继承和接口。)如果是这样,那很好 - 否则,抛出异常。

如果我们可以在前三个步骤中终止搜索,那么就没有太多的间接性——这对于像数组赋值一样经常发生的事情是有好处的。对于值类型分配,这些都不需要发生,因为这是静态可验证的。

所以,这就是为什么我认为引用类型数组比值类型数组略大。

好问题 - 深入研究它真的很有趣:)

于 2009-10-19T16:46:42.227 回答
24

数组是引用类型。所有引用类型都带有两个额外的单词字段。类型引用和 SyncBlock 索引字段,除其他外用于在 CLR 中实现锁。所以引用类型的类型开销是 32 位上的 8 个字节。最重要的是,数组本身还存储另外 4 个字节的长度。这使总开销达到 12 个字节。

我刚刚从 Jon Skeet 的回答中了解到,引用类型的数组有额外的 4 字节开销。这可以使用 WinDbg 来确认。事实证明,附加字是存储在数组中的类型的另一个类型引用。所有引用类型的数组都在内部存储为object[],并附加了对实际类型的类型对象的引用。所以 astring[]实际上只是一个object[]对 type 的附加类型引用string。详情请见下文。

存储在数组中的值:引用类型的数组保存对对象的引用,因此数组中的每个条目都是引用的大小(即 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 字节。这是int32值本身的 4 个字节,常规引用类型开销的 8 个字节和数组长度的另外 4 个字节。

原始转储显示 SyncBlock、方法表int[]、长度和 42 的值(十六进制中的 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字节同步块
  • 4 个字节用于数组本身的方法表(类型引用)
  • 数组长度为 4 个字节
  • 引用类型数组再增加 4 个字节来保存实际元素类型的方法表(引用类型数组object[]在底层)

,值类型数组的开销为 12 个字节,引用类型数组的开销为 16 个字节

于 2009-10-19T16:36:57.830 回答
2

我认为您在测量时做出了一些错误的假设,因为循环期间的内存分配(通过 GetTotalMemory)可能与数组实际所需的内存不同 - 内存可能分配在更大的块中,可能还有其他对象循环期间回收的内存等。

以下是有关阵列开销的一些信息:

于 2009-10-19T16:27:27.133 回答
1

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

于 2009-10-19T16:24:07.140 回答
1

我很抱歉跑题了,但我今天早上发现了有关内存开销的有趣信息。

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

为什么?

于 2009-10-19T16:43:27.927 回答