使用Enumerable.Range时内存消耗高?

7

我原本想知道ToList是否比使用带有IEnumerable<T>参数的List<T>构造函数(没有区别)分配了更多的内存。

为了测试目的,我使用Enumerable.Range创建了一个源数组,以便通过1.ToList和2.构造函数创建List<int>的实例。这两个都是创建副本。

这就是我注意到以下内容内存消耗差异极大的方式:

  1. Enumerable.Range(1, 10000000)或者
  2. Enumerable.Range(1, 10000000).ToArray()

当我使用第一个并调用ToList时,结果对象需要的内存约为数组的60%(38.26MB/64MB)。

问题:这是什么原因或我的推理出现了错误?

var memoryBefore = GC.GetTotalMemory(true);
var range = Enumerable.Range(1, 10000000);
var rangeMem = GC.GetTotalMemory(true) - memoryBefore; // negligible
var list = range.ToList();
var memoryList = GC.GetTotalMemory(true) - memoryBefore - rangeMem;

String memInfoEnumerable = String.Format("Memory before: {0:N2} MB List: {1:N2} MB"
    , (memoryBefore / 1024f) / 1024f
    , (memoryList   / 1024f) / 1024f);
// "Memory before: 0,11 MB List: 64,00 MB"

memoryBefore = GC.GetTotalMemory(true);
var array = Enumerable.Range(1, 10000000).ToArray();
var memoryArray = GC.GetTotalMemory(true) - memoryBefore;
list = array.ToList();
memoryList = GC.GetTotalMemory(true) - memoryArray;

String memInfoArray = String.Format("Memory before: {0:N2} MB Array: {1:N2} MB List: {2:N2} MB"
   , (memoryBefore / 1024f) / 1024f
   , (memoryArray  / 1024f) / 1024f
   , (memoryList   / 1024f) / 1024f);
// "Memory before: 64,11 MB Array: 38,15 MB List: 38,26 MB"

只是提醒一下,你也可以在第5行调用list.TrimExcess();而不是初始化列表到确切的大小。 - Marc
@Marc:是的,但你首先需要知道它在这里可能有用。正如Marc Gravell所指出的,另一种方法是使用range.Count()初始化列表,然后使用AddRange(range) - Tim Schmelter
4个回答

13

这可能与用于调整列表大小时使用的倍增算法有关。当您分配一个数组时,其长度是已知的,并且可以通过检查IList[<T>]和/或ICollection[<T>]来查询; 因此它可以分配一个单一的数组,在第一次正确调整大小后,然后只需块复制内容。

对于序列就不可能这样做(序列不以任何可访问的方式公开长度); 因此,它必须退回到“继续填充缓冲区;如果已满,则将其加倍并复制”的方法。

显然,这需要大约两倍的内存。

一个有趣的测试是:

var list = new List<int>(10000000);
list.AddRange(Enumerable.Range(1, 10000000));

这将最初分配正确的大小,同时仍然使用该序列。

tl;dr; 当构造函数传递一个序列时,它首先检查是否可以通过转换为一个众所周知的接口获得长度。


3
列表是作为一个数组实现的。当你超出了它已经分配的空间,它会再分配一个大小加倍的数组(本质上是加倍内存分配)。默认容量为4,然后从这里开始加倍。
如果你将项目数量降低到7500左右,你会看到数组下降到略低于32 MB,并且IList大小为32 MB。
你可以告诉>初始大小应该是多少,这就是为什么如果你在构造时给它< IEnumerable>,它不应该过度分配内存。
[编辑]在评论之后
在的情况下,它只返回一个>而不是一个>。对于>,为了不过度分配构造时传递的项也必须是一个>。

4
如果在构造函数中使用IEnumerable<T>,它就不会过度分配内存,这就是为什么。但这是错误的。只有当IEnumerable<T>也是ICollection<T>时,它才不会过度分配内存。 - Marc
@Marc 值得点赞,没错。由于 Enumerable.Range 返回的是 IEnumerable 而不是 ICollection,所以 Enumerater.Range(a, b).ToList() 总是会过度分配内存。 - M Afifi
一个List没有链接的数组。当一个填满了,它会创建一个新的、更大的数组,然后旧的数组就被留下来进行垃圾回收,而不是缓冲区的链表(这就是如果有人关心的话,StringBuider所做的)。 - Servy

3
这是因为List中使用了倍增算法来创建备用数组。IEnumerable没有Count属性,因此在调用ToList时无法预先分配备用数组以达到目标大小。实际上,在每次调用MoveNext时,都会在List上调用相应的Add方法。
然而,Array.ToList可以覆盖基本的ToList行为,将列表初始化为正确的容量。此外,可能是List在其构造函数中尝试将其对IEnumerable的引用向下转换为已知的集合类型,例如IList、ICollection、Array等等...
更新:
实际上,是在List的构造函数中确定参数是否实现了ICollection:
public List(IEnumerable<T> collection)
{
  if (collection == null)
    ThrowHelper.ThrowArgumentNullException(ExceptionArgument.collection);
  ICollection<T> collection1 = collection as ICollection<T>;
  if (collection1 != null)
  {
    int count = collection1.Count;
    if (count == 0)
    {
      this._items = List<T>._emptyArray;
    }
    else
    {
      this._items = new T[count];
      collection1.CopyTo(this._items, 0);
      this._size = count;
    }
  }
  else
  {
    this._size = 0;
    this._items = List<T>._emptyArray;
    foreach (T obj in collection)
      this.Add(obj);
  }
}

0

我猜测:

  • Enumerable.Range(1, 10000000)仅创建一个IEnumerable,尚未创建任何项。

  • Enumerable.Range(1, 10000000).ToArray()创建一个数组,使用内存来存储数字。

  • Enumerable.Range(1, 10000000).ToList()创建数字以及用于管理列表的其他数据(各部分之间的链接)。该列表可以改变其大小并需要在块中分配内存。


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