经过了《泛型真的会降低性能吗?》一文中的性能测试,已经从实际入手,从测试数据上证明了泛型不会降低程序效率。只是还是有几位朋友谈到,“普遍认为”泛型的代码性能会略差一些,也有朋友正在进一步寻找泛型性能略差的证据。老赵认为这种探究问题的方式非常值得提倡。不过,老赵忽然想到,如果从能从汇编入手,证明非泛型和泛型的代码之间没有性能差距——好吧,或者说,存在性能差距,那么事情不就到此为止了吗?任何理论说明,都抵不过观察计算机是如何处理这个问题来的“直接”。因此,老赵最终决定通过这种极端的方式来一探究竟,把这个问题彻底解决。
需要一提的是,老赵并不希望这篇文章会引起一些不必要的争论,因此一些话就先说在前面。老赵并不喜欢用这种方式来解决问题。事实上,如果可以通过数据比较,理论分析,或者高级代码来说明问题,我连IL都不愿意接触,更别说深入汇编。如果是平时的工作,就算使用WinDbg也最多是查看查看内存中有哪些数据,系统到底出了哪些问题。如果您要老赵表态的话,我会说:我强烈反对接触汇编。我们有太多太多的东西需要学习,如果您并没有明确您的目标,老赵建议您就放过IL和汇编这种东西吧。我们知道这些是什么就行了,不必对它们有什么“深入”的了解。
下面就要开始真正的探索之旅了。这不是一个顺利的旅程,其中有些步骤是连蒙带猜,最后加以验证才得到的结果。原本老赵打算按照自己的思路一步一步进行下去,但是发现这样太过冗余,反而会让大家的思路难以集中。因此老赵最后决定重新设计一个流程,和大家一起步步为营,朝着目标前进。此外,为了方便某些朋友按照这文章亲手进行操作,老赵也制作了一个dump文件,如果您是安装了.NET 3.5 SP1的32位x86系统,可以直接下载进行试验。试验过程中出现的地址也会和文章中完全一致。
废话就说到这里,我们开始吧。
测试代码测试代码便是我们的目标。和上一篇文章一样,我们准备了一份最简单的代码进行测试,这样可以尽可能摆脱其他因素的影响,得到最正确的结果:
namespace TestConsole { public class MyArrayList { public MyArrayList(int length) { this.m_items = new object[length]; } private object[] m_items; public object this[int index] { [MethodImpl(MethodImplOptions.NoInlining)] get { return this.m_items[index]; } [MethodImpl(MethodImplOptions.NoInlining)] set { this.m_items[index] = value; } } } public class MyList<T> { public MyList(int length) { this.m_items = new T[length]; } private T[] m_items; public T this[int index] { [MethodImpl(MethodImplOptions.NoInlining)] get { return this.m_items[index]; } [MethodImpl(MethodImplOptions.NoInlining)] set { this.m_items[index] = value; } } } class Program { static void Main(string[] args) { MyArrayList arrayList = new MyArrayList(1); arrayList[0] = arrayList[0] ?? new object(); MyList<object> list = new MyList<object>(1); list[0] = list[0] ?? new object(); Console.WriteLine("Here comes the testing code."); var a = arrayList[0]; var b = list[0]; Console.ReadLine(); } } }
我们在这里构建了两个“容器”,一个是MyArrayList,另一个是MyList<T>,前者直接使用Object类型,而后者则是一个泛型类。我们对两个类的索引属性的get和set方法都加上了NoInlining标记,这样便可以避免这种简单的方法被JIT内联。而在Main方法中,前几行代码的作用都是构造两个类的对象,并确保索引的get和set方法都已经得到JIT。在打印出“Here comes the testing code.”之后,我们便对两个类的实例进行“下标访问”,并使控制台暂停。
当Release编译并运行之后,控制台会打印出“Here comes the testing code.”字样并停止。这时候我们便可以使用WinDbg来Attach to Process进行调试。老赵也是在这个时候制作了一个dump文件,您也可以Open Crash Dump命令打开这个文件。更多操作您可以参考互联网上的各篇文章,亦或是老赵之前写过的一篇《使用WinDbg获得托管方法的汇编代码》。
分析MyArrayList对象结构假设您现在已经打开了WinDbg,并Attach to Process(或Open Crash Dump),而且加载了正确的sos.dll(可参考老赵之前给出的文章)。那么第一件事情,我们就要来分析一个MyArrayList对象的结构。
首先,我们还是在项目中查找MyArrayList类型的MT(Method Table,方法表)地址:
0:000> !name2ee *!TestConsole.MyArrayList Module: 5bf71000 (mscorlib.dll) -------------------------------------- Module: 00362354 (sortkey.nlp) -------------------------------------- Module: 00362010 (sorttbls.nlp) -------------------------------------- Module: 00362698 (prcp.nlp) -------------------------------------- Module: 003629dc (mscorlib.resources.dll) -------------------------------------- Module: 00342ff8 (TestConsole.exe) Token: 0x02000002 MethodTable: 00343440 EEClass: 0034141c Name: TestConsole.MyArrayList
我们得到了MyArrayList类型的MT地址之后,便可以在系统中寻找MyArrayList对象了:
0:000> !dumpheap -mt 00343440 Address MT Size 0205be3c 00343440 12 total 1 objects Statistics: MT Count TotalSize Class Name 00343440 1 12 TestConsole.MyArrayList Total 1 objects
不出所料,当前程序中只有一个MyArrayList对象。我们继续追踪它的地址:
0:000> !do 0205be3c Name: TestConsole.MyArrayList MethodTable: 00343440 EEClass: 0034141c Size: 12(0xc) bytes (E:\Users\Jeffrey Zhao\...\bin\Release\TestConsole.exe) Fields: MT Field Offset Type VT Attr Value Name 5c1b41d0 4000001 4 System.Object[] 0 instance 0205be48 m_items
OK,到这里为止,我们得到一个结论。如果我们获得了一个MyArrayList对象的地址,那么偏移4个字节,便可以得到m_items字段,也就是存放元素的Object数组的地址。这点很关键,否则可能对于理解后面的汇编代码形成障碍。
如果您使用同样的方法来观察MyList<object>类型的话,您会发现其结果也完全相同:从对象地址开始偏移4个字节便是m_items字段,类型为Object数组。
分析数组对象的结构接着我们来观察一下,一个数组对象在内存中的存放方式是什么样的。首先,我们打印出托管堆上的各种类型:
0:000> !dumpheap -stat total 6922 objects Statistics: MT Count TotalSize Class Name 5c1e3ed4 1 12 System.Text.DecoderExceptionFallback 5c1e3e90 1 12 System.Text.EncoderExceptionFallback 5c1e1ea4 1 12 System.RuntimeTypeHandle 5c1dfb28 1 12 System.__Filters 5c1dfad8 1 12 System.Reflection.Missing 5c1df9e0 1 12 System.RuntimeType+TypeCacheQueue ... 5c1e3150 48 8640 System.Collections.Hashtable+bucket[] 5c1e2d28 347 9716 System.Collections.ArrayList+ArrayListEnumeratorSimple 5c1b5ca4 46 11024 System.Reflection.CustomAttributeNamedParameter[] 5c1cc590 404 11312 System.Security.SecurityElement 5c1e2a30 578 13872 System.Collections.ArrayList 5c1b50e4 335 14740 System.Int16[] 5c1b41d0 1735 87172 System.Object[] 5c1e0a00 718 167212 System.String 5c1e3470 70 174272 System.Byte[] Total 6922 objects
既然我们的代码中使用了Object数组,那么我们就把目标放在托管堆上的Object数组中。从上面的信息中我们已经获得了Object数组的MT地址,于是我们继续列举出托管堆上的此类对象:
0:000> !dumpheap -mt 5c1b41d0 Address MT Size 01fd141c 5c1b41d0 80 01fd1c84 5c1b41d0 16 01fd1cc0 5c1b41d0 32 ... 0205baa4 5c1b41d0 20 0205bc4c 5c1b41d0 20 0205bc60 5c1b41d0 32 0205bdc4 5c1b41d0 16 0205be48 5c1b41d0 20 0205be74 5c1b41d0 20 0205c058 5c1b41d0 36 02fd1010 5c1b41d0 4096 02fd2020 5c1b41d0 528 02fd2240 5c1b41d0 4096 total 1735 objects Statistics: MT Count TotalSize Class Name 5c1b41d0 1735 87172 System.Object[] Total 1735 objects
我们随意抽取一个Object数组对象,查看它的内容:
0:000> !do 02fd2020 Name: System.Object[] MethodTable: 5c1b41d0 EEClass: 5bf9da54 Size: 528(0x210) bytes Array: Rank 1, Number of elements 128, Type CLASS Element Type: System.Object Fields: None
WinDbg清楚明白地告诉我们,这个数组是1维的,共有128个元素。那么这个数组的长度信息是如何保存下来的呢(这个信息肯定是对象自带的,这个很容易理解吧)?我们直接查看这个数组对象地址上的数据吧:
0:000> dd 02fd2020 02fd2020 5c1b41d0 00000080 5c1e061c 01fd1198 02fd2030 0205bdf0 00000000 00000000 00000000 02fd2040 00000000 00000000 00000000 00000000 02fd2050 00000000 00000000 00000000 00000000 02fd2060 00000000 00000000 00000000 00000000 02fd2070 00000000 00000000 00000000 00000000 02fd2080 00000000 00000000 00000000 00000000 02fd2090 00000000 00000000 00000000 00000000
十六进制数00000080不就是十进制的128吗?没错,老赵对多个数组对象进行分析之后,发现数组对象存放的结构是从对象的地址开始:
- 偏移0字节:存放了这个数组对象的MT地址,例如上面的5c1b41d0便是Object[]类型的MT地址。
- 偏移4字节:存放了数组长度。
- 偏移8字节:存放了数组元素类型的MT地址,例如上面的5c1e061c便是Object类型的MT地址,您可以使用!dumpmt -md 5c1e061c指令进行观察。
- 偏移12字节:从这里开始,便存放了数组的每个元素了。也就是说,如果这是一个引用类型的数组,那么偏移12字节则存放了第1个(下标为0)元素的地址,偏移16字节则存放第2个元素的地址,以此类推。
实际上,这些是老赵在自己的试验过程中,从接下去会讲解的汇编代码出发猜测出来的结果,经过验证发现恰好符合。为了避免您走这些弯路,老赵就先将这一结果告诉大家了。
分析Main函数的汇编代码接下去便要观察Main函数的汇编代码了。获取汇编代码的方法很简单,如果您对此还不太了解,老赵的文章《使用WinDbg获得托管方法的汇编代码》会给您一定帮助。Main函数的汇编代码如下:
0:000> !u 01d40070 Normal JIT generated code TestConsole.Program.Main(System.String[]) Begin 01d40070, size e2 >>> 01d40070 push ebp 01d40071 mov ebp,esp 01d40073 push edi 01d40074 push esi 01d40075 push ebx ... 01d4011d mov ecx,eax // 打印字样“Here comes the testing code.” 01d4011f mov edx,dword ptr ds:[2FD2030h] ("Here comes the testing code.") 01d40125 mov eax,dword ptr [ecx] 01d40127 call dword ptr [eax+0D8h] // 将MyArrayList对象的地址保存在ecx寄存器中 01d4012d mov ecx,esi // 将edx寄存器清零,作为访问下面get_Item方法的参数 01d4012f xor edx,edx // 获取地址0x343424中的数据(它是get_Item方法的访问入口),并调用 01d40131 call dword ptr ds:[343424h] (...MyArrayList.get_Item(Int32), ...) // 将MyList<object>对象的地址保存在ecx寄存器中 01d40137 mov ecx,edi // 将edx寄存器清零,作为访问下面get_Item方法的参数 01d40139 xor edx,edx // 获取地址0x343594中的数据(它是get_Item方法的访问入口),并调用 01d4013b call dword ptr ds:[343594h] (...MyList`1[...].get_Item(Int32), ...) // 调用Console.ReadLine方法,请注意静态方法不需要把对象地址放到ecx寄存器中 01d40141 call mscorlib_ni+0x6d1af4 (5c641af4) (System.Console.get_In(), ...) 01d40146 mov ecx,eax 01d40148 mov eax,dword ptr [ecx] 01d4014a call dword ptr [eax+64h] 01d4014d pop ebx 01d4014e pop esi 01d4014f pop edi 01d40150 pop ebp 01d40151 ret
老赵为上面这段汇编代码添加了注释,我们主要从打印出“Here comes the testing code.”字样的代码开始进行分析。值得注意的是,在调用MyArrayList或MyList<object>的get_Item方法之前,都会把这个对象的地址放置到ecx寄存器中,然后把edx寄存器清零作为get_Item方法的参数。这样做的好处是加快访问对象及参数的速度,如果每次都需要从线程栈上读取这些(就像我们学习汇编时的那些经典案例),其性能肯定比不上读取寄存器。显然,调用Console.ReadLine静态方法是不需要对象地址的,因此无须对ecx寄存器有所操作。
分析get_Item方法的汇编代码从Main函数的汇编代码中我们可以获得get_Item方法的入口。那么我们现在就来分析MyArrayList类型的get_Item方法,请注意,此时ecx寄存器保存的是MyArrayList对象的地址,edx保存了get_Item方法的参数:
0:000> dd 343424h 00343424 01d40168 71060003 20000006 01d40190 00343434 fffffff8 00000004 00000001 00080000 00343444 0000000c 00040011 00000004 5c1e061c 00343454 00342ff8 00343478 0034141c 00000000 00343464 00000000 5c136aa0 5c136ac0 5c136b30 00343474 5c1a7410 00000080 00000000 003434c0 00343484 10000002 90000000 003434c0 00000000 00343494 0034c05c 00020520 00000004 00000004 0:000> !u 01d40168 Normal JIT generated code TestConsole.MyArrayList.get_Item(Int32) Begin 01d40168, size 17 >>> 01d40168 55 push ebp 01d40169 8bec mov ebp,esp // 把MyArrayList对象的m_items字段地址(对象地址偏移4字节)保存至eax寄存器中 01d4016b 8b4104 mov eax,dword ptr [ecx+4] // 比较传入的参数(edx寄存器)与数组长度(eax寄存器为数组地址,再偏移4字节)的大小 01d4016e 3b5004 cmp edx,dword ptr [eax+4] // 如果参数超过数组长度,则跳转至错误处理代码 01d40171 7306 jae 01d40179 // 把需要的元素地址放置到eax寄存器中 // 从数组地址开始偏移12字节为第一个元素的地址,再偏移“下标 * 4”自然就是我们所需要的元素 01d40173 8b44900c mov eax,dword ptr [eax+edx*4+0Ch] 01d40177 5d pop ebp // 返回 01d40178 c3 ret // 如果参数大于数组长度,就会跳转到此 01d40179 e806c2a15c call mscorwks!JIT_RngChkFail (5e75c384) 01d4017e cc int 3
如果要理解上面的代码,可能需要您再去回味文章上半段的分析。尤其是几个偏移量:
- MyArrayList对象偏移4字节则为m_items字段地址
- 数组地址偏移4字节则为其长度
- 数组地址偏移12字节为其第一个元素的地址
然后,再结合ecx(MyArrayList对象地址),edx(参数)以及eax(保存了方法返回值)几个寄存器的作用,相信理解上面这段代码也并非难事。
MyArrayList的代码分析完了,那么MyList<object>的汇编代码又是如何?
0:000> dd 343594h 00343594 01d401b8 01d401e0 00010001 003435a4 003435a4 5c1e0670 00000000 00000000 00000080 003435b4 00000000 fffffff8 00000004 00000001 003435c4 00080010 0000000c 00040011 00000004 003435d4 5c1e061c 00342ff8 00343610 0034355a 003435e4 00343600 00000000 5c136aa0 5c136ac0 003435f4 5c136b30 5c1a7410 00010001 00343604 00343604 5c1e061c 00000000 00000000 00000080 0:000> !u 01d401b8 Normal JIT generated code TestConsole.MyList`1[[System.__Canon, mscorlib]].get_Item(Int32) Begin 01d401b8, size 17 >>> 01d401b8 55 push ebp 01d401b9 8bec mov ebp,esp 01d401bb 8b4104 mov eax,dword ptr [ecx+4] 01d401be 3b5004 cmp edx,dword ptr [eax+4] 01d401c1 7306 jae 01d401c9 01d401c3 8b44900c mov eax,dword ptr [eax+edx*4+0Ch] 01d401c7 5d pop ebp 01d401c8 c3 ret 01d401c9 e8b6c1a15c call mscorwks!JIT_RngChkFail (5e75c384) 01d401ce cc int 3
是否发现,两者的代码除了几个地址之外可以说完全一样?
总结还需要多说什么吗?我们通过比较汇编代码,已经证明了MyArrayList和MyList<Object>在执行时所经过的指令几乎完全相同。到了这个地步,您是否还认为泛型会影响程序性能?
最后继续强调一句:老赵并不喜欢IL,更不喜欢汇编。除非万不得已,老赵是不会往这方面去思考问题的。我们有太多东西可学,如果不是目标明确,老赵建议您还是不要投身于IL或汇编这类东西为好。
最后附上dump文件。