CLR系列:浅析.NET的JIT编译

JIT(Just In Time,这是我们通过.net编译器生成的应用程序最终面向机器的编译器,因此大家对JIT了解一下其工作原理还是很有必要的。最近研究了一下,参考了很多文章,也在msdn上查了些资料,以下是最近我对JIT的理解和总结。大家都知道CLR只执行本机的机器代码。目前有两种方式产生本机的机器代码:实时编译(JIT)和预编译方式(产生native image)。下面,我想分析一下JIT。当我们需要校调对性能要求很高的代码时,查看IL通常不是最好的做法,因为JIT优化器会默默的优化我们的代码,使用reflector或者ildasm你能很快发现releasedebug模式下产生的IL代码几乎完全相同,那么是什么让release模式的代码运行起来如此迅速呢?这就是JIT优化的结果,通过查看managed代码(IL代码),我们没有办法看到这些优化,所以我们将通过native code(本地代码)来查找系统所做的优化,CLR使用类型的方法表来路由所有的方法调用。只有当要调用某个方法时,JIT编译器才会将IL的方法体编译为相应的本机机器码版本。这样可以优化程序的工作集。

首先我们需要一段测试代码:

 

 

 1 static int i;
 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类的信息。

Module: 00a82c3c (JIT.exe)
Token: 
0x02000003
MethodTable: 00a83060
EEClass: 00a81214
Name: JIT.Program

这个命令给出了有关我们的类的丰富信息,上面有许多有用的信息,但是其中最重要的莫过于方法描述(MethodTable)的地址,我们可以用这个地址找到更多信息。

0:003> !dumpmt -md 00a83060

MethodDesc Table
   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方法的本地代码:

0:003> !u 00db0070
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的代码:

0:003> !u 00a83038
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一起调试就很好理解。

以上是这次要讲解的内容。

欢迎大家指出问题。

待续。。。 

 

posted on 2008-12-01 16:37  gjcn  阅读(6603)  评论(18编辑  收藏  举报

导航