【问题标题】:Different performance in creating class with reference参考创建类的不同表现
【发布时间】:2015-05-20 17:19:20
【问题描述】:

我在 C# 中发现了一个奇怪的东西。我有一个类 A,只包含一个对 A 的引用。然后我在 for 循环内的每次迭代中创建新对象 A,并引用在前一次迭代中创建的对象。但是如果我更改对 for 循环之前创建的对象的引用,它会快得多。为什么会这样?

class A
{
    private A next;

    public A(A next)
    {
        this.next = next;
    }
}

var a = new A(null);

for (int i = 0; i < 10*1000*1000; i++)
    a = new A(a);

// Takes 1.5s

var b = new A(null);
for (int i = 0; i < 10*1000*1000; i++)
    a = new A(b);

// Takes only 0.17s

我在以通常方式实现不可变堆栈的过程中发现了这一点,并通过VList 发现了这一点,这是因为速度要快得多。

【问题讨论】:

  • 猜测是因为在第二种情况下,每次调用a = new A(b) 时,对旧A 实例的引用都会丢失,因此CLI 可以重用其内存。在第一种情况下,必须分配额外的内存来保存您创建的 1000 万个实例,同时保留对所有实例的引用。
  • 小提示,但在进行此类计时测试时,总是对数据进行某种基本的完整性/正确性检查,尤其是当您看到如此天文数字的计时结果时。您不想对快速但不正确的解决方案感到太兴奋。正如他们所说,以两倍速度给出错误结果的东西会无限慢。

标签: c# .net performance jit


【解决方案1】:

这段代码(你的第二个 sn-p):

var b = new A(null);
for (int i = 0; i < 10*1000*1000; i++)
    a = new A(b);

在功能上等同于这段代码:

 var b = new A(null);
 a = new A(b);

你的第一个 sn-p 不一样:

var a = new A(null);

for (int i = 0; i < 10*1000*1000; i++)
    a = new A(a);

虽然看起来你正在扔掉所有东西,但最后一个参考不是你。 A 的最后一个实例引用了前一个实例,该实例引用了前一个实例,该实例引用了前一个实例……一直追溯到 10,000,000 个对象。难怪它更慢。

所以比较两段实际上没有达到相同结果的代码是没有意义的。使用实际有效的(您的第一个 sn-p)而不是第二个。运行速度较慢的代码肯定优于运行速度较快的代码。

最后,C# 有一个非常好的集合类选择(如List&lt;T&gt;),它们工作得很好。不知道为什么要在这里重新发明轮子。

【讨论】:

  • 这似乎是显而易见的答案,但我想知道 OP 如何测量所用时间。 var b = new A(null); a = new A(b); 不可能需要 170 毫秒才能执行。
  • @JasonWatkins:再考虑一下并尝试一下,我认为它更有可能像其他人建议的那样重用堆内存。我认为它实际上不能完全优化循环。
  • 我没有重新发明轮子,我实现了 immutable 集合来保存所有版本(参见函数式编程中的数据结构)。我想这将是某种 JIT 优化,但不是你提到的。在 VList 中,我创建了所有值,因此 JIT 不能跳过 for 循环。
【解决方案2】:

您正在衡量使用内存的成本。慢版本分配 120 兆字节,所有对象都通过 next 对象引用来引用。从分配地址空间的成本来看,我看到了 21 个 gen #0 集合、17 个 gen #1 集合和 2 个昂贵的 gen #2 集合。由于分配率高,后台 GC 无能为力。

错误的快速版本为垃圾收集器提供了非常轻松的时间。分配的对象不会在任何地方引用,因此快速 gen #0 集合会收集所有这些对象。它使用的内存非常少,只有 gen #0 段,并且没有 gen #1 和 gen #2 集合。

否则,您可能已经发现了关于不可变对象的基本事实。类型保证很好,但这个概念并不能很好地转化为机器的工作方式。处理器经过高度优化,使可变对象非常高效,不可变对象对缓存不友好,占用大量内存并为 GC 提供大量工作。最简单的例子是 String 与 StringBuilder,大多数程序员都知道何时切换到可变版本。我认为也是基本原因之一,为什么 Roslyn 这么晚了,要达到旧 C# 编译器设定的性能目标肯定是一场巨大的战斗。

【讨论】:

    【解决方案3】:

    当您在 .NET Framework 下运行应用程序时,内存使用高级编程语言提供的 托管代码 功能在后台分配到 HeapC#/VB.NetJava

    当您使用关键字new 创建类的实例时,编程语言会告诉编译器它要在heap 上分配内存(动态分配)。这种分配需要时间,因为它必须通过 OS 请求限制和进程。当您通过高级编程语言请求内存分配时,它会在后台分配一个更大的块(缓冲区)。因此,未来的“实例化”需要更少的时间,因为heap 上已有应用程序可用的内存。

    【讨论】:

      【解决方案4】:

      大卫和马特是正确的。如果您注意分析应用程序,您会发现第一个示例

      GC Generation GC Count
           0        3 
           1        17 
           2        2 
      

      还有你的第二个样本

      GC Generation  GC Count
         0           28 
         1           0 
         2           0 
      

      使用您的第一个代码,您创建了一个大型链表,其中仍然包含所有对象,这些对象在应用程序终止之前永远不会释放。

      • Max GC Heap Size: 119,546 MB
      

      而你得到的第二个样本

      • Max GC Heap Size: 4,217 MB
      

      做一个

      var a = new Container();
      loop
      {
       a = new Container(a) 
      }
      

      将保留数据,因为 a 将包含对“旧”a 的引用。 而

      b = new Container();
      loop
      {
         a = new Container(b) 
      }
      

      将只分配一个容器,该容器包含另一个实例,但不包含先前分配对象的完整历史记录。 这个故事的寓意是仔细观察你的对象在哪里扎根。如果您创建一个大型的节点链接列表,这就是您所得到的。

      【讨论】:

        【解决方案5】:

        看起来像 CLR 优化,因为在第二种情况下变量 a 未被使用。

        【讨论】:

          【解决方案6】:

          在第一种情况下,您正在创建一个由 10,000,000 个连接对象组成的链,而在第二种情况下,您正在创建连接到 A 的单个实例的 10,000,000 个单独的对象。我的猜测是速度变慢是由于框架在分配 10,000,000 个连接对象时必须执行堆管理,而不是随机分配单个对象。

          【讨论】:

            猜你喜欢
            • 2013-11-21
            • 1970-01-01
            • 1970-01-01
            • 1970-01-01
            • 2015-11-29
            • 1970-01-01
            • 1970-01-01
            • 1970-01-01
            • 1970-01-01
            相关资源
            最近更新 更多