CLR系列:浅析.NET的JIT编译
JIT(Just In Time),这是我们通过.net编译器生成的应用程序最终面向机器的编译器,因此大家对JIT了解一下其工作原理还是很有必要的。最近研究了一下,参考了很多文章,也在msdn上查了些资料,以下是最近我对JIT的理解和总结。大家都知道CLR只执行本机的机器代码。目前有两种方式产生本机的机器代码:实时编译(JIT)和预编译方式(产生native image)。下面,我想分析一下JIT。当我们需要校调对性能要求很高的代码时,查看IL通常不是最好的做法,因为JIT优化器会默默的优化我们的代码,使用reflector或者ildasm你能很快发现release和debug模式下产生的IL代码几乎完全相同,那么是什么让release模式的代码运行起来如此迅速呢?这就是JIT优化的结果,通过查看managed代码(IL代码),我们没有办法看到这些优化,所以我们将通过native code(本地代码)来查找系统所做的优化,CLR使用类型的方法表来路由所有的方法调用。只有当要调用某个方法时,JIT编译器才会将IL的方法体编译为相应的本机机器码版本。这样可以优化程序的工作集。
首先我们需要一段测试代码:
2 static void Main( string[] args )
3 {
4 i = And( i, 0 );
5 //PrintLine();
6 Console.Read();
7 }
8 static int And( int i1, int i2 )
9 {
10 return i1 & i2;
11 }
12 static void Print()
13 {
14 PrintLine();
15 }
16 static void PrintLine()
17 {
18 Console.WriteLine( "GuoJun" );
19 }
下面通过Windbg+sos调试看看debug下JIT为我们的代码产生的native code(本地代码):
0:003> .load C:\WINDOWS\Microsoft.NET\Framework\v2.0.50727\SOS.dll 加载SOS模块
0:003> !name2ee JIT.exe JIT.Program 查看Program类的信息。
Token: 0x02000003
MethodTable: 00a83060
EEClass: 00a81214
Name: JIT.Program
这个命令给出了有关我们的类的丰富信息,上面有许多有用的信息,但是其中最重要的莫过于方法描述(MethodTable)的地址,我们可以用这个地址找到更多信息。
0:003> !dumpmt -md 00a83060
Entry MethodDesc JIT Name
79371278 7914b928 PreJIT System.Object.ToString()
7936b3b0 7914b930 PreJIT System.Object.Equals(System.Object)
7936b3d0 7914b948 PreJIT System.Object.GetHashCode()
793624d0 7914b950 PreJIT System.Object.Finalize()
00db0070 00a83038 JIT JIT.Program.Main(System.String[])
00db00b8 00a83040 JIT JIT.Program.And(Int32, Int32)
00a8c019 00a83048 NONE JIT.Program.Print()
00a8c01d 00a83050 NONE JIT.Program.PrintLine()
00a8c021 00a83058 NONE JIT.Program..ctor()
在这里很多人已经注意到我们的方法还没有被JIT编译,这就是为什么我们通过间接引用来调用方法的原因之一 :在main方法编译他之前,程序并不知道需要到哪里去调用.这就引出了一个问题:
JIT怎么知道何时编译一个方法
本质上来说,JIT是延迟加载我们的模块,通过一种被叫做块的技术,JIT能捕获到我们对方法的第一次调用,所谓块是一小段非托管代码,当我们第一次加载某个类型的时候,由CLR通过Emit生成块.块简单的包含对JIT的调用。
当一个方法第一次被调用的时候,调用方从MethodTable中读取指向一个代码块的地址,也就是方法的描述(MethodDesc),然后调用这个块,块接着调用JIT。关键的地方在于,当JIT完成了编译后,将改变MethodTable,使其直接指向已经被JIT编译过的代码,也就是说无论代码是否被JIT编译,对方法的调用都是通过调用MethodTable中方法地址来实现的,若代码尚未编译,则这个地址指向一个代码块,他会帮助你编译代码,然后修改MethodTable中的指针,指向本地代码。
下面我们看看debug下Main方法的本地代码:
Normal JIT generated code
JIT.Program.Main(System.String[])
Begin 00db0070, size 34
>>> 00db0070 56 push esi
00db0071 50 push eax
00db0072 890c24 mov dword ptr [esp],ecx
00db0075 833d082ea80000 cmp dword ptr ds:[0A82E08h],0
00db007c 7405 je 00db0083
00db007e e8c4823779 call mscorwks!CorLaunchApplication+0x972d (7a128347) (JitHelp: CORINFO_HELP_DBG_IS_JUST_MY_CODE)
00db0083 90 nop
00db0084 8b0d1830a800 mov ecx,dword ptr ds:[0A83018h]
00db008a 33d2 xor edx,edx
00db008c ff159c30a800 call dword ptr ds:[0A8309Ch] (JIT.Program.And(Int32, Int32), mdToken: 06000008)
00db0092 8bf0 mov esi,eax
00db0094 89351830a800 mov dword ptr ds:[0A83018h],esi
00db009a e82d996378 call mscorlib_ni+0x3299cc (793e99cc) (System.Console.Read(), mdToken: 060007b6)
00db009f 90 nop
00db00a0 90 nop
00db00a1 59 pop ecx
00db00a2 5e pop esi
00db00a3 c3 ret
然后再看看release的代码:
Normal JIT generated code
JIT.Program.Main(System.String[])
Begin 00db0070, size 14
00db0070 33c0 xor eax,eax
00db0072 a31830a800 mov dword ptr ds:[00A83018h],eax
00db0077 e86c756378 call mscorlib_ni+0x3275e8 (793e75e8) (System.Console.get_In(), mdToken: 0600076e)
00db007c 8bc8 mov ecx,eax
00db007e 8b01 mov eax,dword ptr [ecx]
00db0080 ff5054 call dword ptr [eax+54h]
00db0083 c3 ret
为什么同样的IL会在debug和release下经过JIT生成的本地代码会不同呢?这里引出了另一个问题:
JIT做的自动优化
我们可以到看生成的本地在release下比debug下小很多,也简洁清晰很多。这样导致整个项目的大小进一步的得到了优化,更能高效的执行本地代码。举个例子:
00db0075 833d082ea80000 cmp dword ptr ds:[0A82E08h],0
00db007c 7405 je 00db0083
00db007e e8c4823779 call mscorwks!CorLaunchApplication+0x972d (7a128347) (JitHelp: CORINFO_HELP_DBG_IS_JUST_MY_CODE)
00db0083 90 nop
这里对Main方法的参数个数与0比较,如果比0小就跳到00db0083,而00db0083那行的代码是:nop,nop表示什么都不做,这样不但会增加代码的大小还会影响代码的执行速度。JIT经常会通过识别IL中的模式来进行优化,大家平时多看看本地代码,也许比只看IL能学到更多的东西。如果大家就觉得上面的代码就只能看到这么多,其实是错的。现在引出了我今天讲的最后一个问题:
JIT的方法inline
我们看看release的本地代码
00db0070 33c0 xor eax,eax
00db0072 a31830a800 mov dword ptr ds:[00A83018h],eax
这里第一句就直接得出方法的放回结果,然后将结果存到静态变量i。这就实现了方法的inline。我们知道:所有的方法调用都会带来开销。例如,需要将参数推入堆栈中或存储在寄存器中,需要执行的方法起头 (prolog) 和结尾 (epilog) 等。只需要将被调用方法的方法主体移入调用方的主体,就可以避免某些方法的调用开销。这一操作称为方法内联。JIT 使用大量的探测方法来确定是否应内联某个方法。那么是不是每个方法都能inline呢?通过msdn的介绍我们知道以下方法是不能通过内联的:
IL 超过 32 字节的方法不会内联。
虚函数不会内联。
包含复杂流程控制的函数不会内联。复杂流程控制是除 if/then/else 以外的任意流程控制,在这种情况下,为 switch 或 while。
包含异常处理块的方法不会内联,但是引发异常的方法可以内联。
如果某个方法的所有定参都为结构,则该方法不会内联
有兴趣的朋友可以自己验证一下。一般情况下,属性的 Get 和 Set 方法都非常适合内联,因为它们主要用于初始化私有数据成员。但是我们不要为了内联就故意改变我们平时写代码的风格。我们必须使你的代码先工作起来,你必须清楚的知道哪些代码是不值得优化的,同时你总是应该把判断的依据建立在对速度的实际测量上,而非仅仅是阅读代码,最后,其实数据结构的选择比底层的优化重要的多。至于JIT是怎么怎么启动的,CLR通过jitinterface.cpp文件的CallCompileMethodWithSEHWrapper方法进入JIT的,还有JIT是怎么通过地址一步一步调用方法的。这些问题有兴趣的朋友可以将上面的示例代码的main方法改为Print(),然后结合windbg一起调试就很好理解。
以上是这次要讲解的内容。
欢迎大家指出问题。
待续。。。