从 List<T> 和 IList<T> 创建 Enumerator 时的 GC 收集

GC collections when creating Enumerator from List<T> and IList<T>

我正在 https://softwareengineering.stackexchange.com/a/411324/109967 阅读 casablanca 的评论:

There's another caveat here: value types are boxed onto the heap if accessed via an interface, so you'd still incur a heap allocation if enumerating via IList or IEnumerable. You'd have to be holding onto a concrete List instance to avoid the allocation.

我想使用这个 .NET 5 控制台应用程序来测试这个理论(我尝试在 List<T>IList<T> 之间切换):

using System;
using System.Collections.Generic;

namespace ConsoleApp10
{    
    class Program
    {
        static void Main(string[] args)
        {
            IList<int> numbers = new List<int> { 0, 1, 2, 3, 4, 5 };
            //List<int> numbers = new List<int> { 0, 1, 2, 3, 4, 5 };

            for (int i = 0; i < 2000; i++)
            {
                foreach (var number in numbers)
                {

                }
            }
            GC.Collect();
            Console.WriteLine("GC gen 0 collection count = " + GC.CollectionCount(0)); //Always 1
            Console.WriteLine("GC gen 1 collection count = " + GC.CollectionCount(1)); //Always 1
            Console.WriteLine("GC gen 2 collection count = " + GC.CollectionCount(2)); //Always 1
                
        }
    }
}

似乎所有代都已满并进行了 GC。

如果 List<T> 使用结构 Enumerator,那么在调用 GC.Collect() 之前不应该发生任何堆分配(之后在对 [= 的调用中创建字符串对象) 18=] 但这发生在 GC 运行之后。

问题 1: 为什么在使用 List<T> 时会发生堆分配?

问题二: 我知道由于通过 IList<T> 接口迭代 List<T> 时使用的引用类型 Enumerator 会进行堆分配(假设链接问题中的注释是正确的),但为什么所有 3 代都有一个集合?

2000 枚举器对象是很多对象,但是一旦 foreach 循环完成,它就可以进行 GC,因为在 foreach 完成后没有任何对象引用该对象。为什么对象会进入第 1 代和第 2 代?

我想你对 GC.CollectionCount() 向你展示的内容感到困惑。

每次调用GC.Collect(),都会强制所有代的全集。然后你调用 GC.CollectionCount(),它告诉你所有的世代都被收集了。您只是在观察调用 GC.Collect()!

的效果

确实 IList 版本正在分配枚举器,但这些枚举器在 gen0 中很快就会消亡。

检查此类内容的正确工具是 BenchmarkDotNet with the MemoryDiagnoser

我整理了一个简单的基准:

public static class Program
{
    public static void Main()
    {
        BenchmarkRunner.Run<Benchmarks>();
    }
}

[MemoryDiagnoser]
public class Benchmarks
{
    [Benchmark]
    public int IList()
    {
        int sum = 0;
        IList<int> numbers = new List<int> { 0, 1, 2, 3, 4, 5 };

        for (int i = 0; i < 2000; i++)
        {
            foreach (var number in numbers)
            {
                sum += number;
            }
        }
        return sum;
    }

    [Benchmark]
    public int List()
    {
        int sum = 0;
        List<int> numbers = new List<int> { 0, 1, 2, 3, 4, 5 };

        for (int i = 0; i < 2000; i++)
        {
            foreach (var number in numbers)
            {
                sum += number;
            }
        }
        return sum;
    }
}

这产生了结果:

BenchmarkDotNet=v0.13.0, OS=Windows 10.0.19042.985 (20H2/October2020Update)
Intel Core i5-6300U CPU 2.40GHz (Skylake), 1 CPU, 4 logical and 2 physical cores
.NET SDK=5.0.300-preview.21180.15
  [Host]     : .NET 5.0.5 (5.0.521.16609), X64 RyuJIT
  DefaultJob : .NET 5.0.5 (5.0.521.16609), X64 RyuJIT
Method Mean Error StdDev Gen 0 Gen 1 Gen 2 Allocated
IList 149.42 μs 2.902 μs 3.563 μs 51.0254 - - 80,128 B
List 48.98 μs 0.961 μs 1.524 μs - - - 128 B

两者共有的 128B 分配将是 List<T> 实例本身。除此之外,看看 IList 版本如何多分配大约 80KB,导致每 1000 次操作产生 51 个新的 gen0 集合?