关于接口多态(Framework 2.0之后的版本),CLR运行时的实现比继承多态要复杂得多,而网上大部分的文章和资料描述的还是Framework 1.1的实现方式,2.0之后变化非常大,这也是写此文的目的,说明下2.0版本开始的实现细节。
先看个有趣的Demo:
源码内有2种方案:
- Case 1:根据条件实例化后 一次调用 实现的 Foo() 方法
- Case 2:根据条件每次实例化后都立即调用实现的 Foo() 方法
2种方案的功能上没有区别,只是写法上,第一种是我们日常多态的写法,第二种没有体现多态的优势.
请用Framework 2.0(或者2.0以后的版本)分别编译 Case1功能和Case2功能,看2个方案的执行时间:
截图是我本地电脑的截图,不具有普遍性,但具有代表性:也就是 Case 2 的速度要比 Case 1 的速度快,这个性能的差异就与CLR运行时的实现有关系了.
- 我们先来简单看下 Framework 1.1 在接口多态的运行时实现方式,下面截取Main方法中关于接口调用部分:12345678...012200b3 8bf7 mov esi,edi012200b5 8bce mov ecx,esi012200b7 8b01 mov eax,dword ptr [ecx]012200b9 8b400c mov eax,dword ptr [eax+0Ch]012200bc 8b80d0000000 mov eax,dword ptr [eax+0D0h]012200c2 ff10 call dword ptr [eax]...
可以看到Framework1.1对于接口多态的运行时实现和4.0的继承多态实现类似,先把对象地址赋给ecx寄存器,在把对象第1个4字节(64位8字节)的方法表(MethodTable)赋给eax寄存器,再通过方法表地址偏移0Ch的位置 取出接口偏移表地址 IOT (Interface Offset Table) 赋给eax寄存器,再通过IOT偏移0D0h的位置 取出对应实现接口的方法入口地址 赋给eax寄存器,最后调用.
也就是每个类型的方法表内包含接口偏移表的入口地址(偏移0Ch的位置),再通过接口偏移表内相同的方法实现有着相同的偏移量来完成多态,但接口是可以多实现的,和继承不同,继承是单一继承(先父类再子类的排列顺序可以保证同一个虚方法的实现有着相同的偏移量),而接口的这种做法明显是以空间换时间的方案, Framework2.0 开始对于接口的调用实现有了非常大的调整.
- 通过下面的Demo看下2.0之后的实现
|
|
用2.0方式编译:
12%windir%\Microsoft.NET\Framework\v2.0.50727\csc.exe /debug /target:exe /out:e:\temp\Polymorphism02_02_2.0.exe e:\temp\Polymorphism02_02.cspause运行程序并通过windbg附加到程序进程,查找Main方法的编译结果:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384850:004> !u 01010070Normal JIT generated codeProgram.Main(System.String[])Begin 01010070, size f2>>> 01010070 55 push ebp01010071 8bec mov ebp,esp01010073 83ec20 sub esp,20h01010076 894dfc mov dword ptr [ebp-4],ecx01010079 833d142eaf0000 cmp dword ptr ds:[0AF2E14h],001010080 7405 je 0101008701010082 e832ff0d79 call mscorwks!JIT_DbgIsJustMyCode (7a0effb9)01010087 33d2 xor edx,edx01010089 8955ec mov dword ptr [ebp-14h],edx0101008c c745f400000000 mov dword ptr [ebp-0Ch],001010093 33d2 xor edx,edx01010095 8955f8 mov dword ptr [ebp-8],edx01010098 33d2 xor edx,edx0101009a 8955f0 mov dword ptr [ebp-10h],edx0101009d 90 nop0101009e 8b0d30204202 mov ecx,dword ptr ds:[2422030h] ("Polymorphism02_02 demo")*** WARNING: Unable to verify checksum for C:\WINDOWS\assembly\NativeImages_v2.0.50727_32\mscorlib\b14359470744c840c59fbe4e58034fd6\mscorlib.ni.dll010100a4 e8b3457878 call mscorlib_ni+0x6d465c (7979465c) (System.Console.WriteLine(System.String), mdToken: 060007c8)010100a9 90 nop010100aa e8c1487878 call mscorlib_ni+0x6d4970 (79794970) (System.Console.ReadLine(), mdToken: 060007ba)010100af 90 nop010100b0 b99431af00 mov ecx,0AF3194h (MT: TestA)010100b5 e8621fadff call 00ae201c (JitHelp: CORINFO_HELP_NEWSFAST)010100ba 8945e8 mov dword ptr [ebp-18h],eax010100bd 8b4de8 mov ecx,dword ptr [ebp-18h]010100c0 ff15d831af00 call dword ptr ds:[0AF31D8h] (TestA..ctor(), mdToken: 06000009)010100c6 8b45e8 mov eax,dword ptr [ebp-18h]010100c9 8945f0 mov dword ptr [ebp-10h],eax010100cc 8b4df0 mov ecx,dword ptr [ebp-10h]010100cf ff151000b000 call dword ptr ds:[0B00010h]010100d5 90 nop010100d6 33d2 xor edx,edx010100d8 8955ec mov dword ptr [ebp-14h],edx010100db 33d2 xor edx,edx010100dd 8955f8 mov dword ptr [ebp-8],edx010100e0 90 nop010100e1 eb61 jmp 01010144010100e3 90 nop010100e4 837df801 cmp dword ptr [ebp-8],1010100e8 0f9fc0 setg al010100eb 0fb6c0 movzx eax,al010100ee 8945f4 mov dword ptr [ebp-0Ch],eax010100f1 837df400 cmp dword ptr [ebp-0Ch],0010100f5 7521 jne 01010118010100f7 90 nop010100f8 b99431af00 mov ecx,0AF3194h (MT: TestA)010100fd e81a1fadff call 00ae201c (JitHelp: CORINFO_HELP_NEWSFAST)01010102 8945e0 mov dword ptr [ebp-20h],eax01010105 8b4de0 mov ecx,dword ptr [ebp-20h]01010108 ff15d831af00 call dword ptr ds:[0AF31D8h] (TestA..ctor(), mdToken: 06000009)0101010e 8b45e0 mov eax,dword ptr [ebp-20h]01010111 8945ec mov dword ptr [ebp-14h],eax01010114 90 nop01010115 90 nop01010116 eb1e jmp 0101013601010118 90 nop01010119 b92832af00 mov ecx,0AF3228h (MT: TestB)0101011e e8f91eadff call 00ae201c (JitHelp: CORINFO_HELP_NEWSFAST)01010123 8945e4 mov dword ptr [ebp-1Ch],eax01010126 8b4de4 mov ecx,dword ptr [ebp-1Ch]01010129 ff156c32af00 call dword ptr ds:[0AF326Ch] (TestB..ctor(), mdToken: 0600000d)0101012f 8b45e4 mov eax,dword ptr [ebp-1Ch]01010132 8945ec mov dword ptr [ebp-14h],eax01010135 90 nop01010136 8b4dec mov ecx,dword ptr [ebp-14h]01010139 ff159000b000 call dword ptr ds:[0B00090h]0101013f 90 nop01010140 90 nop01010141 ff45f8 inc dword ptr [ebp-8]01010144 837df866 cmp dword ptr [ebp-8],66h01010148 0f9ec0 setle al0101014b 0fb6c0 movzx eax,al0101014e 8945f4 mov dword ptr [ebp-0Ch],eax01010151 837df400 cmp dword ptr [ebp-0Ch],001010155 758c jne 010100e301010157 e814487878 call mscorlib_ni+0x6d4970 (79794970) (System.Console.ReadLine(), mdToken: 060007ba)0101015c 90 nop0101015d 90 nop0101015e 8be5 mov esp,ebp01010160 5d pop ebp01010161 c3 ret这里最重要的几行:
010100cf ff151000b000 call dword ptr ds:[0B00010h]
对应源码:ifo2.Foo2();
01010139 ff159000b000 call dword ptr ds:[0B00090h]
对应源码:ifo.Foo11();
也就是说运行时不管调用这个接口方法的实例是什么类型的都通过这个内存地址的内容去到相应的实现
前来看看目前这2块内存的内容:
分别指向 00b06012
和 00b06022
这4个内容地址所属的位置:
也就是说运行时为每个调用点(call site)分配一个 Indirect cell (在应用程序域的IndcellHeap堆内)默认情况下其内容指针指向 Lookup Cell(在应用程序域的LookupHeap堆内),再来看看这2块lookupHeap的内容:
这里2个 DispatchToken 30000h,50001h分别代表2个接口方法的调用,DispatchToken的构成比较有意思,分为2段各16位(目前是32位进程),高16位是TypeID,应用程序域在运行时为每个接口类型动态分配的ID,也就是接口类型在运行时唯一的标识,TypeID的起始值是3步长是2,应用程序域内持有2个HashMap分别用于对于TypeID和方法表的对应(MethodTable),以及方法表对应TypeID
- 这2个HashMap的Hash算法是一样的:每个桶存储4对 key/value 算法是Hash key 右移2位为种子(seed),种子模桶的长度找到对应的桶,然后循环桶内的4个slot匹配key,如果桶已满,则种子增加incr 继续查找。
- 图里方法表00af312c是IFoo2的方法表,00af30c0是IFoo1的方法表,可以看出在运行时先编译到IFoo2的方法调用,所以先为IFoo2分配TypeID=3,再编译到IFoo1的方法调用 TypeID=5
- 在第一个HashMap(TypeID->MT)内,IFoo2的key:3右移2位为0,所以在0号桶内,value:MT 右移1位存如桶内对应的位置。IFoo1的key:5 右移2位为1,所以在1号桶内,value:MT 右移1位存入桶内对应的位置。
- 在第二个HashMap(MT->TypeID)内,IFoo2的key:00af312c右移2位为2BCC4B 再模11为7,所以IFoo2的MT和TypeID在7号桶,同样IFoo1的key:00af30c0右移2位为2BCC30 再模11为2,所以IFoo1的MT和TypeID在2号桶里。
DispatchToken的低16位是由方法在接口内的方法索引(slot index前面的文章介绍过如果查看方法描述及方法槽索引),也就是在运行时DispatchToken可以唯一代表某个接口的某个方法调用,这里和实现类还没有关系。
在循环内i=2的时候再中断观察接口调用(call site)的内存变化(这里前2次都用TestA来实例化是因为IndcellHeap的内容并不是立刻被改的,官方的说法:appropriate do it)
目前的调用点IndcellHeap的内容指向了DispatchHeap,内容也有了变化:
可以看到DispatchHeap的内容就3行,第一行比较传进来的对象是不是TestA类型的,不是的话跳到 00b0a011,是TestA类型的话跳到 010101e8,这个010101e8就是TestA类型实现接口方法 Foo11 的入口,也就是运行时先假设我们传进来的对象都是TestA,这样经过短短的几条指令就能到达TestA的接口实现,那如果不是TestA呢?TestB也实现了,并且在运行时调用了。看下条件失败的调整地址情况:
第1句是对某个内存地址减1的操作,看下这块内存目前内容是64h(十进制的100),也就是在多态接口调用的时候不是TestA对象的话会进入另外一个流程,并在其内部做1个失败计数器,从100开始递减,递减的过程:
最终减完会发生什么,等程序循环执行完再观察调用点的内存。
接口调用点(call site)IndcellHeap的内容指向了ResolveHeap,ResolveHeap的内容:
现在调用点的IndcellHeap内容就不再变化了,不管再用那个对象在循环内再调用Foo11方法,都是通过ResolveHeap内存的方法进行中转的。
总结下:.NET 2.0 开始对接口的多态实现比1.1的时候要复杂的多,也更灵活,它假设我们在接口调用的时候是单态的 或者说多态的次数不多的情况,用某个实现类的方法表(MT)加以判断,如果是则直接跳到这个实现类的接口实现入口,如果不是 则对失败进行计数,并在失败达到一定数量后(100次)认为确实是多态调用,并将call site的入口指向ResolveHeap,此时不再单独判断某个类型的方法表(MT),这也是为何在文章开头的Demo里为何2种写法的性能是有差别的原因了。
参考文档:
https://github.com/dotnet/coreclr/blob/master/Documentation/botr/virtual-stub-dispatch.md
http://blogs.microsoft.co.il/sasha/2012/03/15/virtual-method-dispatch-and-object-layout-changes-in-clr-40/
https://github.com/dotnet/coreclr/blob/master/src/vm/methodtable.h
https://www.microsoft.com/china/MSDN/library/netFramework/netframework/JITCompiler.mspx?mfr=true