Friday, March 04, 2011

JIT In .Net(ZT)

JIT(Just In Time简称JIT)是.Net边运行边编译的一种机制,这种机制的命名来源于丰田汽车在20世纪60年代实行的一种生产方式,中文译为“准时制”。

.Net 的JIT编译器在设计初衷和运行方式来上讲,都与丰田汽车的这种“准时生产”思想体系有着很大的相似之处,所以让我们先来透过“准时生产”方式来理解.Net的JIT机制吧。

“准时生产”的基本思想可概括为“在需要的时候,按需要的量生产所需的产品”,这正是.Net JIT编译器的设计初衷,即在需要的时候编译需要的代码。

第一节.Me JIT

以C#为例,在C#代码运行前,一般会经过两次编译,第一阶段是C#代码向MSIL的编译,第二阶段是IL向本地代码的编译。第一阶段的编译成果是生成托管模块,第二阶段的编译成果是生成本地代码以供运行,从这里各位同学可以看出,第一阶段生成的MSIL是不能直接运行的。

这里先要解释一下什么是MSIL和托管模块。

MSIL:

MSIL 全称为Microsoft Intermediate Language,中文译为“微软中间语言”,它是一种介于高级语言和汇编语言之间的伪汇编语言(姑且这么叫,各位有不同意见的同学不必激动)。当用户编译运行一个.NET程序时,高级语言编译器会将源代码翻译成一组可以独立于CPU的指令。

可以看出IL 包括用于加载(ldstr )、存储(压栈、弹栈)和初始化对象(locals)以及调用对象方法(call)的指令,还包括用于算术和逻辑运算、控制流、直接内存访问、异常处理和其他操作的指令。

C#代码:

string str_test = "test";
System.String Str_test = "test";



对应IL码:



 



// 代码大小 14 (0xe)
.maxstack 1
.locals init ([0] string str_test,[1] string Str_test)
IL_0000: nop
IL_0001: ldstr "test"
IL_0006: stloc.0
IL_0007: ldstr "test"
IL_000c: stloc.1
IL_000d: ret



托管模块:



托管模块(managed module)是一个标准32位或64位Microsoft  Windows可移植可执行体(PE32或PE32+)文件,托管模块需要CLR才能执行,它包含了上面介绍的IL代码,还包含元数据、PE头、CLR头几部分。



元数据(metadata)可以理解为一个HashTable,Table中映射了内置类型和成员以及引用的类型和成员,这些类型与成员供IL使用,所以元数据总是需要关联对应的IL代码,编译器也是同时生成元数据与IL,以保证自描述的同步。



PE头(Portable Executable,中文译为可移植的可执行的)包括了PE32与PE32+,标示了托管模块的运行环境以及JIT优化本地代码时所要用到的信息,这在后面会讲到。



CLR头主要包括方法的入口地址标记,以及资源、强命名等信息,这些信息是GAC重要的参数依据。



下图可以表示出JIT的介入时机:



 



image



JIT是运行时的一个重要职责模块,它将IL转换为本地CPU指令,从上图可以看出,也许你不敢相信,即时编译这个过程是在运行时发生的,这会不会对性能产生影响呢?事实上答案是虽然是肯定的,但这种开销物有所值。



1、JIT所造成的性能开销并不显著。



2、JIT遵循计算机体系理论中两个经典理论:局部性原理与8020原则。局部性原理指出,程序总是趋向于使用最近使用过的数据和指令,这包括空间的和时间的,将局部性原理引申可以得出,程序总是趋向于使用最近使用过的数据和指令,以及这些正在使用的数据和指令临近的数据和指令(凭印象写的,但不曲解原意);而8020原则指出,系统大多数时间总是花费80%的时间去执行那20%的代码。   根据这两个原则,JIT在运行时会实时的向前、后优化代码,这样的工作只有在运行时才可以做到。



3、JIT只编译需要的那一段代码,而不是全部,这样节约了不必要的内存开销。



4、JIT会根据运行时环境,即时的优化IL代码,即同样的IL代码运行在不同CPU上,JIT编译出的本地代码是不同的,这些不同代码面向自己的CPU做出了优化。



5、JIT会对代码的运行情况进行检测,并对那些特殊的代码经行重新编译,在运行过程中不断优化。



实际上JIT的优点还不止如此,它对内联、策略引擎(.Net Discovery 系列之四--深入理解.Net垃圾收集机制(下) 中包含对策略引擎的描述)、CLR反馈、代码回收(非垃圾回收,这在第二节中会有介绍)等方面都会有不可磨灭的贡献。



必须指出的是JIT在第一次编译IL后,会修改对应方法相应的内存地址入口(绕口啊~~),下一次需要执行这个方法时,CLR会直接访问对应的内存地址,而不会经过JIT了。



第二节.编译与执行



在上一节中我们讨论了与JIT相关的一些元素和JIT的优势,这一节将为大家重点介绍JIT在编译方面的原理。



C#等高级语言必须被编译为IL才可被执行,IL在执行前必须被便以为本地代码才可运行,这里有两种方法可以获得本地代码,JIT方式和Native Image Generator方式,本节主要讨论JIT方式。



必须指出的是JIT在第一次编译IL后,会修改对应方法相应的内存地址入口,下一次需要执行这个方法时,CLR会直接访问对应的内存地址,而不会经过JIT了,这样无疑加快了程序运行的速度,这是怎样的一个过程呢?



以Load()方法为例,假如Load()方法中调用了两次同类型中的方法:



 



Void Load()
{
A.a1("First");

A.a1("Second");
}
static class A
{
Public void a1(string str){}

Public void a2(string str){}

Public void a3(string str){}
}



运行时,操作系统会根据托管模块中各种头信息,装载相应的运行时框架,Load()被加载,由于是第一次加载,这会触发对Load()的即时编译,JIT会检测Load()中引用的所有类型,并结合元数据遍历这些类型中定义的所有方法实现,并用一个特殊的HashTable(仅用于理解)储存这些类型方法与其对应的入口地址(在未被JIT前,这个入口地址为一个预编译代理 (PreJitStub),这个代理负责触发JIT编译),根据这些地址,就可以找到对应的方法实现。





在初始化时,HashTable中各个方法指向的并不是对应的内存入口地址,而是一个JIT预编译代理,这个函数负责将方法编译为本地代码。注意,这里JIT还没有进行编译,只是建立了方法表



下表(表1)为首次加载调用时HashTable的情况:



表1 方法表示意



方法槽



方法描述



a1()



PreJitStub



a2()



PreJitStub



a3()



PreJitStub



好了有了这个HashTable后,JIT开始编译第一个被调用的方法A.a1("First"),这是由一个JIT内部函数来完成的(上面提到的),遗憾的事,目前还没有发现介绍这个函数的相关资料,有些书中称它为“JIT编译者”,那本文也这么称呼它吧。



下图为首次调用方法时的示意图:



image



图2 触发JIT编译



JIT借助元数据和IL生成被调用方法的本地代码后,会将这些代码缓存在动态内存中,然后修改HashTable中对应方法的入口地址,将其修改为本地代码的内存片地址(如表2所示),并将这个地址返回给CLR经行执行,A.a1("First")执行完毕,代码继续运行。



运行至A.a1("Second ")时,会直接执行A.a1()方法的内存代码,不会进行再次编译,表2 为再次加载时HashTable的情况。



表2 方法表变化



方法槽



方法描述



a1()



XXXXXXXXX内存地址



a2()



PreJitStub



a3()



PreJitStub



再次加载流程示意图:



image



图3 未触发JIT编译



image



图4 方法表、方法描述、预编译代理关系



图2中所示的MS核心引擎指的是一个叫做MSCorEE的DLL,即Microsoft .NET Runtime Execution Engine,它是一个桥接DLL,连同mscorwks.dll主要完成以下工作:




  1. 查找程序集中包含的对应类型清单,并调用元数据遍历出包含的方法。


  2. 结合元数据获得这个方法的IL。


  3. 分配内存。


  4. 编译IL为本地代码,并保存在第3步所分配的内存中。


  5. 将类型表(就是指上文中提到的HashTable)中方法地址修改为第3步所分配的内存地址。


  6. 跳转至本地代码中执行。



所以随着程序的运行时间增加,越来越多的方法的IL被编译为本地代码,JIT的调用次数也会不断减少。



下面借助WinDbg来证实以上的说法,示例中的源程序可以到这里下载到:



http://files.cnblogs.com/isline/IsLine.JITTester.rar



代码中定义了3个类,分别为A、B、C,在“GO”按钮按下后,将调用类型A中的a1()方法,而Form1_Load 中什么也不做,目的是程序运行后,在空载的情况下查看方法描述对应地址入口的情况。



好,第一步运行JITTester.exe程序,并打开WinDbg附加这个进程



image



图 5 附件进程



第二步,附加进程成功后,在WinDbg中加载SOS.dll



image



图6 加载SOS.dll



第三步,使用name2ee命令遍历所有已加载模块,name2ee格式为name2ee *! [程序集].[类型]



image



图7 查看类型信息



回车后注意高亮区域的信息:



image



图8 JIT前A类型的信息



高亮区域显示的是“”,这说明虽然运行和程序,但未点击按钮时,A类型未被JIT,因为它还没有入口地址。这一点体现了即时、按需编译的思想。



同样,!name2ee *!JITTester.B和!name2ee *!JITTester.C命令会得到同样的结果。



好,现在做第4步操作,Detach Debuggee进程,并回到程序中点击“GO”按钮



image



图9 点击按钮



第五步 重新附加进程(参考第一步),这时程序已经调用了new A().a1()方法,并重新执行命令!name2ee *!JITTester.A ,注意高亮部分



image



图10 JIT后A类型的信息



和图8中的信息比较,图10中的方法表地址已经变为JIT后的内存地址,这时图4中的Stub槽将被一条强制跳转语句替换,跳转目标与该地址有关。这一点说明JIT在大多情况下,只编译一次代码。



同样命令查看B类型:



image



图11 JIT后B类型的信息



该类型未被调用,所以还未被JIT。



C类型:



image



图12 JIT后C类型的信息



由于实例化A类型时和C类型相关,所以C类型已经JIT了。



第三节.Native Image Generator



Native Image Generator中文译为本地代码生成器,我更习惯叫它“本地映像”,因为通过工具NGen.exe生成的本地代码是无法部分载入的,这意味着操作系统会加载整个程序集文件。



    上一节中提到过,有两种方法可以获得本地代码,JIT方式和Native Image Generator方式,JIT方式是在运行时动态编译需要的代码,而NGen.exe会创建托管程序集的本机映像,并且将该映像安装到GAC中,运行该程序集时,就会自动使用该本机映像而不是JIT它们。



这听起来似乎很美妙,但是你必须做好以下准备:




  1. 当FrameWork版本、CPU类型、操作系统版本发生变化时,.Net会恢复JIT机制。


  2. NGen.exe工具并不能避免发布IL,事实上,即使使用NGen.exe工具,CLR依然会使用到元数据和IL。 


  3. 忽略了局部性原理(上一节中提到的),系统会加载整个映像文件到内存中,并很可能重定位文件,修正内存地址引用。 


  4. NGen.exe生成的代码无法在运行时进行优化,无法直接访问静态资源,也无法在应用程序域之间共享程序集。 



此外,JIT不但有编译的本事,还会根据内存资源情况换出使用率低的代码,节省资源,这对于一些基于.Net平台的电子产品是很重要的。



所以,除非你已十分清楚程序性能是由于首次编译造成的性能问题,否则尽量不要人工生成本地代码。

0 Comments:

Post a Comment

<< Home