为什么封闭类型更快?

38

为什么封闭类型更快?

我想了解更深层次的细节,为什么这是正确的。


2
请参见:https://dev59.com/onVC5IYBdhLWcg3whRcw - Shog9
它们是吗?我不知道... CLR 可能能够优化方法分派表,因为它知道它不能再增长了。 - harpo
1
@harpo:请参考此链接:http://msdn.microsoft.com/zh-cn/library/ms173150.aspx。我已将其添加到我的答案中,但事实上他们并没有详细说明为什么,所以我决定不加入... - Paul Sonier
https://dev59.com/onVC5IYBdhLWcg3whRcw - user195488
6个回答

44

在最低层次上,当你有密封类时,编译器可以进行微小的优化。

如果你在密封类上调用方法,并且在编译时声明的类型是该密封类,编译器可以使用call指令来实现方法调用(大多数情况下),而不是用callvirt指令。这是因为方法目标不能被覆盖。Call 消除了空检查,并且比callvirt执行更快的虚函数表查找,因为它不必检查虚函数表。

这可能只会略微提高性能。

话虽如此,在决定是否要将类标记为sealed时完全可以忽略此点。将类型标记为sealed应该是一个设计决策,而不是一个性能决策。你是否希望人们(包括你自己)可能从你的类中构建子类?如果是,请不要将其标记为sealed。如果不是,请标记为sealed。这确实应该是决定因素。


6
在设计时,倾向于封装那些没有必要显式扩展的公共类可能是一个好主意,因为在将来的版本中取消封装一个类是不会导致破坏性变化的,而反过来则不成立。 - Neil Williams
2
@Neil Williams:我同意。一般来说,由于解除类的密封是安全的,而密封不是,如果你正在制作公共库,密封可能是一个好选择。然而,这使得密封成为一个设计选择,而不是性能问题。 - Reed Copsey
我认为这是由于内联导致的。C#编译器总是使用callvirt,因为它喜欢该IL代码的空值检查副作用。 - Two Bit Gangster

10

实际上,这与它们不需要担心虚函数表的扩展有关;封闭类型无法扩展,因此,运行时不需要关心它们可能是多态的方式。


8

我决定发布小的代码示例,以说明C#编译器何时发出“call”和“callvirt”指令。

因此,这里是我使用的所有类型的源代码:

    public sealed class SealedClass
    {
        public void DoSmth()
        { }
    }

    public class ClassWithSealedMethod : ClassWithVirtualMethod
    {
        public sealed override void DoSmth()
        { }
    }

    public class ClassWithVirtualMethod
    {
        public virtual void DoSmth()
        { }
    }

我有一个方法可以调用所有的“DoSmth()”方法:

同时,我还有一种方法可以调用所有的“DoSmth()”方法:

    public void Call()
    {
        SealedClass sc = new SealedClass();
        sc.DoSmth();

        ClassWithVirtualMethod cwcm = new ClassWithVirtualMethod();
        cwcm.DoSmth();

        ClassWithSealedMethod cwsm = new ClassWithSealedMethod();
        cwsm.DoSmth();
    }

看一下 "Call()" 方法,我们可以说(理论上)C# 编译器应该发出 2 个 "callvirt" 和 1 个 "call" 指令,对吗?不幸的是,现实有些不同 - 会有 3 个 "callvirt":

.method public hidebysig instance void Call() cil managed
{
    .maxstack 1
    .locals init (
        [0] class TestApp.SealedClasses.SealedClass sc,
        [1] class TestApp.SealedClasses.ClassWithVirtualMethod cwcm,
        [2] class TestApp.SealedClasses.ClassWithSealedMethod cwsm)
    L_0000: newobj instance void TestApp.SealedClasses.SealedClass::.ctor()
    L_0005: stloc.0 
    L_0006: ldloc.0 
    L_0007: callvirt instance void TestApp.SealedClasses.SealedClass::DoSmth()
    L_000c: newobj instance void TestApp.SealedClasses.ClassWithVirtualMethod::.ctor()
    L_0011: stloc.1 
    L_0012: ldloc.1 
    L_0013: callvirt instance void TestApp.SealedClasses.ClassWithVirtualMethod::DoSmth()
    L_0018: newobj instance void TestApp.SealedClasses.ClassWithSealedMethod::.ctor()
    L_001d: stloc.2 
    L_001e: ldloc.2 
    L_001f: callvirt instance void TestApp.SealedClasses.ClassWithVirtualMethod::DoSmth()
    L_0024: ret 
}

原因很简单:运行时必须在调用“DoSmth()”方法之前检查类型实例是否不等于null。 但是我们仍然可以以这样的方式编写代码,使得C#编译器能够发出优化的IL代码:

    public void Call()
    {
        new SealedClass().DoSmth();

        new ClassWithVirtualMethod().DoSmth();

        new ClassWithSealedMethod().DoSmth();
    }

结果如下:

.method public hidebysig instance void Call() cil managed
{
    .maxstack 8
    L_0000: newobj instance void TestApp.SealedClasses.SealedClass::.ctor()
    L_0005: call instance void TestApp.SealedClasses.SealedClass::DoSmth()
    L_000a: newobj instance void TestApp.SealedClasses.ClassWithVirtualMethod::.ctor()
    L_000f: callvirt instance void TestApp.SealedClasses.ClassWithVirtualMethod::DoSmth()
    L_0014: newobj instance void TestApp.SealedClasses.ClassWithSealedMethod::.ctor()
    L_0019: callvirt instance void TestApp.SealedClasses.ClassWithVirtualMethod::DoSmth()
    L_001e: ret 
}

如果您尝试以同样的方式调用未标记为虚拟的非密封类的非虚方法,则也会得到“call”指令而不是“callvirt”。

谢谢,为什么在你的第二个例子中避免了空值检查?你能解释一下吗? - Joan Venge
1
因为我在第一个示例中没有使用“SealedClass”类型的局部变量,所以编译器不需要检查它是否为“null”。 如果将“SealedClass.DoSmth()”方法声明为静态方法,则将生成相同的IL代码。 - Volodymyr Usarskyy

5
如果JIT编译器看到使用sealed类型调用虚方法的调用,它可以通过非虚拟方式调用方法来生成更有效的代码。现在,调用非虚拟方法更快,因为不需要执行vtable查找。在我看来,这是微观优化,应该作为改善应用程序性能的最后手段使用。如果您的方法包含任何代码,则与执行代码本身的成本相比,虚拟版本将几乎与非虚拟版本一样快。

2
为什么这应该是最后的选择?为什么不默认封装你的类呢?通常只有在与之相关的成本(通常是代码可读性降低或开发时间增加)存在时,才会被认为是微小优化。如果没有任何副作用,为什么不做它,无论是否存在性能问题? - jalf
1
当你封闭一个类时,你会阻止继承的使用。这可能会使开发变得更加困难,也可能会阻止解决某些错误。理想情况下,人们应该考虑并设计继承,并明确哪些是设计为可扩展的,然后封闭其他所有内容。盲目地封闭一切太过严格。 - Eddie

4
为了补充其他答案,一个密封类(Java中的final类的等效类)不能被继承。这意味着每次编译器看到该类的方法被使用时,编译器都会确切地知道不需要运行时分派。它不必检查类以动态地确定需要调用层次结构中哪个类的哪个方法。这意味着分支可以被编译而不是动态执行。
例如,如果我有一个非密封类Animal,它有一个方法makeNoise(),则编译器不一定知道任何Animal实例是否覆盖该方法。因此,每次任何Animal实例调用makeNoise()时,都需要检查实例的类层次结构,以查看实例是否在扩展类中覆盖了此方法。
但是,如果我有一个密封类AnimalFeeder,它有一个方法feedAnimal(),那么编译器确切地知道这个方法无法被覆盖。它可以编译分支到子例程或等效指令而不是使用虚拟分派表。
注意:您可以在类上使用sealed来防止从该类继承,也可以在基类中声明为virtual的方法上使用sealed来防止进一步覆盖该方法。

1
要真正看到它们,您需要分析JIT编译的代码(最后一个)。
C#代码
public sealed class Sealed
{
    public string Message { get; set; }
    public void DoStuff() { }
}
public class Derived : Base
{
    public sealed override void DoStuff() { }
}
public class Base
{
    public string Message { get; set; }
    public virtual void DoStuff() { }
}
static void Main()
{
    Sealed sealedClass = new Sealed();
    sealedClass.DoStuff();
    Derived derivedClass = new Derived();
    derivedClass.DoStuff();
    Base BaseClass = new Base();
    BaseClass.DoStuff();
}

MIL 代码

.method private hidebysig static void  Main() cil managed
{
  .entrypoint
  // Code size       41 (0x29)
  .maxstack  8
  IL_0000:  newobj     instance void ConsoleApp1.Program/Sealed::.ctor()
  IL_0005:  callvirt   instance void ConsoleApp1.Program/Sealed::DoStuff()
  IL_000a:  newobj     instance void ConsoleApp1.Program/Derived::.ctor()
  IL_000f:  callvirt   instance void ConsoleApp1.Program/Base::DoStuff()
  IL_0014:  newobj     instance void ConsoleApp1.Program/Base::.ctor()
  IL_0019:  callvirt   instance void ConsoleApp1.Program/Base::DoStuff()
  IL_0028:  ret
} // end of method Program::Main

即时编译代码
--- C:\Users\Ivan Porta\source\repos\ConsoleApp1\Program.cs --------------------
        {
0066084A  in          al,dx  
0066084B  push        edi  
0066084C  push        esi  
0066084D  push        ebx  
0066084E  sub         esp,4Ch  
00660851  lea         edi,[ebp-58h]  
00660854  mov         ecx,13h  
00660859  xor         eax,eax  
0066085B  rep stos    dword ptr es:[edi]  
0066085D  cmp         dword ptr ds:[5842F0h],0  
00660864  je          0066086B  
00660866  call        744CFAD0  
0066086B  xor         edx,edx  
0066086D  mov         dword ptr [ebp-3Ch],edx  
00660870  xor         edx,edx  
00660872  mov         dword ptr [ebp-48h],edx  
00660875  xor         edx,edx  
00660877  mov         dword ptr [ebp-44h],edx  
0066087A  xor         edx,edx  
0066087C  mov         dword ptr [ebp-40h],edx  
0066087F  nop  
            Sealed sealedClass = new Sealed();
00660880  mov         ecx,584E1Ch  
00660885  call        005730F4  
0066088A  mov         dword ptr [ebp-4Ch],eax  
0066088D  mov         ecx,dword ptr [ebp-4Ch]  
00660890  call        00660468  
00660895  mov         eax,dword ptr [ebp-4Ch]  
00660898  mov         dword ptr [ebp-3Ch],eax  
            sealedClass.DoStuff();
0066089B  mov         ecx,dword ptr [ebp-3Ch]  
0066089E  cmp         dword ptr [ecx],ecx  
006608A0  call        00660460  
006608A5  nop  
            Derived derivedClass = new Derived();
006608A6  mov         ecx,584F3Ch  
006608AB  call        005730F4  
006608B0  mov         dword ptr [ebp-50h],eax  
006608B3  mov         ecx,dword ptr [ebp-50h]  
006608B6  call        006604A8  
006608BB  mov         eax,dword ptr [ebp-50h]  
006608BE  mov         dword ptr [ebp-40h],eax  
            derivedClass.DoStuff();
006608C1  mov         ecx,dword ptr [ebp-40h]  
006608C4  mov         eax,dword ptr [ecx]  
006608C6  mov         eax,dword ptr [eax+28h]  
006608C9  call        dword ptr [eax+10h]  
006608CC  nop  
            Base BaseClass = new Base();
006608CD  mov         ecx,584EC0h  
006608D2  call        005730F4  
006608D7  mov         dword ptr [ebp-54h],eax  
006608DA  mov         ecx,dword ptr [ebp-54h]  
006608DD  call        00660490  
006608E2  mov         eax,dword ptr [ebp-54h]  
006608E5  mov         dword ptr [ebp-44h],eax  
            BaseClass.DoStuff();
006608E8  mov         ecx,dword ptr [ebp-44h]  
006608EB  mov         eax,dword ptr [ecx]  
006608ED  mov         eax,dword ptr [eax+28h]  
006608F0  call        dword ptr [eax+10h]  
006608F3  nop  
        }
0066091A  nop  
0066091B  lea         esp,[ebp-0Ch]  
0066091E  pop         ebx  
0066091F  pop         esi  
00660920  pop         edi  
00660921  pop         ebp  

00660922  ret  

虽然对象的创建方式相同,但调用密封类和派生/基类方法的指令略有不同。在将数据移入寄存器或RAM(mov指令)后,调用密封方法会执行 dword ptr [ecx],ecx(cmp指令)之间的比较,然后调用该方法,而派生/基类则直接执行该方法。
根据Torbj¨orn Granlund撰写的报告《AMD和Intel x86处理器的指令延迟和吞吐量》,Intel Pentium 4上以下指令的速度为:
- mov:延迟1个周期,处理器每个周期可以维持2.5个此类型的指令 - cmp:延迟1个周期,处理器每个周期可以维持2个此类型的指令
链接:https://gmplib.org/~tege/x86-timing.pdf 这意味着理论上调用密封方法需要2个周期的时间,而调用派生或基类方法需要3个周期的时间。
编译器的优化使得封闭和非封闭类之间的性能差异如此之低,以至于我们正在谈论处理器圈,并且因此对大多数应用程序来说是不相关的。

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