【问题标题】:Stackoverflow doing boxing in C#Stackoverflow 在 C# 中做拳击
【发布时间】:2015-03-04 21:17:03
【问题描述】:

我在 C# 中有这两段代码:

首先

class Program
{
    static Stack<int> S = new Stack<int>();

    static int Foo(int n) {
        if (n == 0)
            return 0;
        S.Push(0);
        S.Push(1);
        ...
        S.Push(999);
        return Foo( n-1 );
    }
}

第二

class Program
{
    static Stack S = new Stack();

    static int Foo(int n) {
        if (n == 0)
            return 0;
        S.Push(0);
        S.Push(1);
        ...
        S.Push(999);
        return Foo( n-1 );
    }
}

他们都做同样的事情:

  1. 创建一个堆栈(第一个示例在&lt;int&gt; 中通用,第二个示例使用一个对象堆栈)。

  2. 声明一个递归调用自身 n 次 (n >= 0) 并在每一步中将 1000 个整数压入创建的堆栈的方法。

当我使用Foo(30000) 运行第一个示例时,没有发生异常,但是第二个示例使用Foo(1000) 崩溃,只有 n = 1000。

当我看到为这两种情况生成的CIL 时,唯一的区别是每次推送的拳击部分:

首先

IL_0030:  ldsfld     class [System]System.Collections.Generic.Stack`1<int32> Test.Program::S
IL_0035:  ldc.i4     0x3e7
IL_003a:  callvirt   instance void class [System]System.Collections.Generic.Stack`1<int32>::Push(!0)
IL_003f:  nop

第二

IL_003a:  ldsfld     class [mscorlib]System.Collections.Stack Test.Program::S
IL_003f:  ldc.i4     0x3e7
IL_0044:  box        [mscorlib]System.Int32
IL_0049:  callvirt   instance void [mscorlib]System.Collections.Stack::Push(object)
IL_004e:  nop

我的问题是:如果第二个示例的 CIL 堆栈没有显着过载,为什么它会比第一个示例“更快”崩溃?

【问题讨论】:

  • 我刚刚运行了代码,在调试模式下都在 30k 时崩溃,在发布模式下都在 30k 时运行良好。您可能只在调试模式下运行了非通用版本。
  • @JamesThorpe 这些调用不会影响堆栈帧的大小,因为它们在递归时不会在堆栈上。
  • 您在两个示例中创建的堆栈结构与线程堆栈无关,因为它们位于堆中。因此,调用 Stack.Push 方法一次或一百万次并不重要:它不会影响线程堆栈的使用
  • 我在编译到 x86 时复制了 OP 的问题。 Stack 在 n = 19261 处崩溃; Stack&lt;int&gt; 在 n = 3 时崩溃(即很久以后)。
  • @LeandroCastilloValdés 您是否遇到溢出异常、内存不足异常或其他问题?

标签: c# stack-overflow il


【解决方案1】:

如果第二个示例的 CIL 堆栈没有明显过载,为什么它会比第一个示例“更快”崩溃?

请注意,CIL 指令的数量 并不能准确地表示将使用的工作量或内存量。单个指令的影响可能非常低,也可能非常高,因此计算 CIL 指令并不是衡量“工作”的准确方法。

还要意识到 CIL 不是被执行的。 JIT 将 CIL 编译为实际的机器指令,并带有优化阶段,因此 CIL 可能与实际执行的指令有很大不同。

在第二种情况下,由于您使用的是非泛型集合,因此每个 Push 调用都需要将整数装箱,正如您在 CIL 中确定的那样。

装箱一个整数有效地创建了一个对象,它为您“包装”Int32。它现在必须将一个 32 位整数加载到堆栈上,然后将其装箱,这实际上也将一个对象引用加载到堆栈上,而不是仅仅将 32 位整数加载到堆栈中。

如果您在“反汇编”窗口中进行检查,您会发现通用版本和非通用版本之间的差异非常显着,并且比生成的 CIL 所暗示的要重要得多。

通用版本有效地编译为一系列调用,如下所示:

0000022c  nop 
            S.Push(25);
0000022d  mov         ecx,dword ptr ds:[03834978h] 
00000233  mov         edx,19h 
00000238  cmp         dword ptr [ecx],ecx 
0000023a  call        71618DD0 
0000023f  nop 
            S.Push(26);
00000240  mov         ecx,dword ptr ds:[03834978h] 
00000246  mov         edx,1Ah 
0000024b  cmp         dword ptr [ecx],ecx 
0000024d  call        71618DD0 
00000252  nop 
            S.Push(27);

另一方面,非泛型必须创建装箱对象,而是编译为:

00000645  nop 
            S.Push(25);
00000646  mov         ecx,7326560Ch 
0000064b  call        FAAC20B0 
00000650  mov         dword ptr [ebp-48h],eax 
00000653  mov         eax,dword ptr ds:[03AF4978h] 
00000658  mov         dword ptr [ebp+FFFFFEE8h],eax 
0000065e  mov         eax,dword ptr [ebp-48h] 
00000661  mov         dword ptr [eax+4],19h 
00000668  mov         eax,dword ptr [ebp-48h] 
0000066b  mov         dword ptr [ebp+FFFFFEE4h],eax 
00000671  mov         ecx,dword ptr [ebp+FFFFFEE8h] 
00000677  mov         edx,dword ptr [ebp+FFFFFEE4h] 
0000067d  mov         eax,dword ptr [ecx] 
0000067f  mov         eax,dword ptr [eax+2Ch] 
00000682  call        dword ptr [eax+18h] 
00000685  nop 
            S.Push(26);
00000686  mov         ecx,7326560Ch 
0000068b  call        FAAC20B0 
00000690  mov         dword ptr [ebp-48h],eax 
00000693  mov         eax,dword ptr ds:[03AF4978h] 
00000698  mov         dword ptr [ebp+FFFFFEE0h],eax 
0000069e  mov         eax,dword ptr [ebp-48h] 
000006a1  mov         dword ptr [eax+4],1Ah 
000006a8  mov         eax,dword ptr [ebp-48h] 
000006ab  mov         dword ptr [ebp+FFFFFEDCh],eax 
000006b1  mov         ecx,dword ptr [ebp+FFFFFEE0h] 
000006b7  mov         edx,dword ptr [ebp+FFFFFEDCh] 
000006bd  mov         eax,dword ptr [ecx] 
000006bf  mov         eax,dword ptr [eax+2Ch] 
000006c2  call        dword ptr [eax+18h] 
000006c5  nop 

这里可以看出拳击的意义。

在您的情况下,装箱整数会导致装箱的对象引用加载到堆栈中。在我的系统上,这会导致任何大于Foo(127)(32 位)的调用出现堆栈溢出,这表明整数和装箱对象引用(每个 4 字节)都保存在堆栈中,如 127*1000* 8==1016000,这非常接近 .NET 应用程序的默认 1 MB 线程堆栈大小。

使用通用版本时,由于没有装箱对象,整数不必全部存储在堆栈中,并且可以重复使用相同的寄存器。这使您可以在用完堆栈之前进行更多的递归(在我的系统上 > 40000)。

请注意,这将取决于 CLR 版本和平台,因为在 x86/x64 上也有不同的 JIT。

【讨论】:

  • 当我测试这个问题时,我使用了一个 for 循环 - for (int i = 0; i &lt;= 999; i++) S.Push(i); - 并且得到了类似于 OP 的行为。如果编译器正在执行循环展开,这只会符合您的解释。你认为是这种情况吗?
  • JIT 是否有充分的理由将引用留在堆栈中?
  • @immibis 在这种情况下,不,但无论出于何种原因,它都不会优化它。 JIT 中还有很多潜在的优化机会尚未被利用……这似乎是一个不错的机会。
  • @Sinix 正如我所说,对于我来说,for 循环不会发生这种情况,只有在内联调用时...
  • @Sinix:ideone.com/5tec26StackOverflowException 仍然出现在“不调试就开始”(尽管稍后)。我正在编译为 x86,在调试模式下以 .NET Framework 4.5.1 为目标。 ideone 上不会出现,因为它运行 Mono。
猜你喜欢
  • 2010-09-15
  • 1970-01-01
  • 2012-11-17
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多