.NET中通用和非通用集合的内存使用差异

5
我最近了解到在.NET中使用的集合。众所周知,使用通用集合比使用非通用集合有一些优势:它们是类型安全的,不需要强制转换,不需要装箱/拆箱。这就是为什么通用集合具有性能优势的原因。
如果考虑到非通用集合将每个成员都存储为“object”,那么可以认为通用集合也具有内存优势。然而,我没有找到关于内存使用差异的任何信息。
有人能澄清一下这一点吗?

2
通常情况下,编写最清晰的代码来完成所需工作。如果你有内存方面的问题,使用profiler来查找内存使用情况、类型等信息,并尝试寻找替代方法。不要试图学习成千上万的“总是使用X / 从不使用Y”的规则。 - Damien_The_Unbeliever
这可能对你很有趣。 - L. Guthardt
一个 object 总是比一个 int 大。相关 - Jeroen Mostert
2
一个 List<byte> 占用每个元素 1 字节,而 ArrayList 在 32 位模式下占用 12 字节,在 64 位模式下占用 24 字节来存储一个字节。将 2400% 的存储开销视为有用是不明智的,最好忘记 .NET 1.0 曾经存在过。 - Hans Passant
这些差异在MSDN: List(T) Class的性能注意事项(备注下)部分有官方文档记录。 - Brian
3个回答

20
如果我们考虑非泛型集合将每个成员存储为对象,那么我们可以认为泛型也具有内存优势。但是,我没有找到有关内存使用差异的任何信息。能否有人阐明一下这一点?
当然。让我们考虑包含int的ArrayList与List<int>。假设每个列表中都有1000个int。
在两种情况下,集合类型都是数组的薄包装器-因此名为ArrayList。在ArrayList的情况下,底层object[]包含至少1000个装箱的int。在List<int>的情况下,底层的int[]包含至少1000个int。
为什么我说“至少”?因为两者都使用双倍容量策略。如果您在创建集合时设置了容量,则它会为该数量分配足够的空间。如果没有,则集合必须猜测,如果猜错并且需要更多容量,则它会将其容量加倍。因此,最好的情况是我们的集合数组恰好是正确的大小。最坏的情况是它们可能比需要的大小大一倍;数组中可能有2000个对象或2000个int的空间。
但是假设为简单起见,我们很幸运,在每个列表中都有大约1000个元素。
首先,数组的内存负担是多少?object[1000]在32位系统上占用4000字节,在64位系统上占用8000字节,仅供引用使用,它们的大小与指针相同。int[1000]无论如何都需要4000字节。(数组管理还需要一些额外的字节,但这些成本与边际成本相比很小。)

所以我们可以看到,非泛型解决方案可能就为数组消耗了两倍的内存。那么数组的内容呢?

关于值类型的问题在于,它们直接存储在自己的变量中。除了用于存储1000个整数的那4000字节之外,没有额外的空间;它们被打包进数组里面。因此,对于泛型情况来说,额外的成本是零。

对于object[]的情况,数组的每个成员都是一个引用,而该引用指向一个对象;在这种情况下,是装箱整型。一个装箱整型的大小是多少?

未装箱的值类型不需要存储其类型的任何信息,因为其类型由其存储的类型确定,并且运行时已知。装箱值类型需要在某个地方存储盒子中物品的类型,这需要空间。事实证明,在32位.NET中,对象的簿记开销为8字节,在64位系统上为16字节。这只是开销;我们当然需要4字节来存储整数。但是等等,情况变得更糟:在64位系统上,盒子必须对齐到8字节边界,因此在64位系统上,我们需要另外4字节的填充。

总结一下:在64位和32位系统上,我们的int[]大约占用4KB。包含1000个整数的object[]在32位系统上大约需要16KB,在64位系统上需要32K。因此,非泛型情况下,int[]object[]的内存效率要差4到8倍。

但等等,情况变得更糟了。这仅仅是大小。那么访问时间呢?

要从整数数组中访问一个整数,运行时必须:

  • 验证数组是否有效
  • 验证索引是否有效
  • 从给定索引处的变量中提取值
要从装箱整型数组中访问一个整型数,运行时必须执行以下操作:
  • 验证数组的有效性
  • 验证索引的有效性
  • 从给定索引处的变量中获取引用
  • 验证引用不为空
  • 验证引用是否为装箱整型
  • 从盒子中提取整数

这需要更多的步骤,因此需要更长的时间。

但是等等,情况会变得更糟。

现代处理器在芯片本身上使用缓存来避免返回主内存。一个包含 1000 个普通整数的数组很可能会出现在高速缓存中,以便对数组的第一、第二、第三等成员进行快速连续访问,所有这些成员都是从同一高速缓存线路中提取的;这是非常快的。但是装箱整数可能分布在整个堆中,这增加了缓存失效的次数,进而大大降低了访问速度。

希望这足以澄清您对装箱惩罚的理解。

那么非装箱类型呢?一个字符串数组列表和一个 List<string> 之间有显著差异吗?

在这种情况下,惩罚要小得多,因为 object[]string[] 具有类似的性能特征和内存布局。唯一的额外惩罚是:(1) 直到运行时才捕获错误,(2) 使代码更难以阅读和编辑,以及 (3) 运行时类型检查的轻微惩罚。


非常好的解释。谢谢! - Shyju

2
如果我们认为泛型也具有内存优势,那么这个假设是错误的,它只适用于值类型。因此请考虑以下内容:
new ArrayList { 1, 2, 3 };

这将会把每个整数隐式转换为object(称为装箱),以便将其存储到您的ArrayList中。这将导致您在此处的内存开销,因为一个object肯定比一个简单的int大。
对于引用类型,两者之间没有区别,因为不需要装箱。
使用其中之一不应受性能或内存问题的影响。但是,您应该问自己想要用结果做什么。特别是如果您在编译时知道存储在集合中的类型,则使用正确的泛型类型参数将此信息放入编译过程中是有意义的。
无论如何,由于提到的类型安全性,您应该始终使用通用集合而不是非通用集合。
编辑:实际问题是使用非通用集合还是通用版本,这是相当无意义的:始终使用通用版本。但不是因为它的内存使用情况。请参见此内容:
ArrayList a = new ArrayList { 1, 2, 3};

对比。

List<object> a = new List<object> { 1, 2, 3 };

两个列表将消耗相同的内存,尽管第二个是通用的。这是因为它们都将您的整数装箱到object中。因此,问题的答案与内存无关。

对于引用类型,完全没有内存差异:

ArrayList a = new ArrayList { myInstance, anotherInstance }

对比。

List<MyClass> a = new List<MyClass> { myInstance, anotherInstance }

这两个操作会产生相同的内存结果。但是第二个操作更容易维护,因为您可以直接使用实例而不需要转换它们。


真的吗?我本以为 List<byte> 会在内部存储一个 byte[] 来存储其值,而 List 则有一个 object[],其中必然包含4或8个字节的引用,加上装箱开销。 - Damien_The_Unbeliever

0
假设我们有这个语句:
int valueType = 1;

现在我们在堆栈上有一个值,如下所示:

堆栈

i = 1

现在考虑我们立即执行以下操作:

object boxingObject = valueType;

现在我们在内存中有两个值,一个是堆栈中 valueType 的引用,另一个是堆中的 value 1

堆栈

boxingObject

1

因此,根据 Microsoft 文档的说明,在对值类型进行装箱时会有额外的内存使用:

将值类型装箱会在堆上分配一个对象实例,并将值复制到新对象中。

请参阅 link 以获取完整信息。


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