本文主要讲解dalvik jit的框架,至于更深层次的内容,例如编译的方法、寄存器的分配等问题,将在以后讨论。请先阅读 dalvik VM的解释器分析
Dalvik JustInTime技术,是在DVM解释器工作过程中,识别和分析出热点代码,然后将其编译为机器码,然后运行的过程。
jit的基本原理比较简单,我用下面的伪代码来解释其模型:对于一个特定的指令入口dalvik PC
if (dPC被执行次数 > 阀值) {
jit_addr = GetJitAddr(dPC);
if (jit_addr != NULL) {
jit_addr(); //进入jit代码
} else {
收集 dPC 所指代码到一组JitTraceRun中;
AddCompileWorkQueue(jitTraceRuns);
}
}
CompileWorkQueue有一个编译线程,负责编译,编译过程是
JIT总体可以分成3个重要的课题:
JIT的入口、Trace和代码管理;内容包括1.1 如何从解释代码进入到JIT过程;1.2 DVM如何收集将要被JIT的代码;1.3 DVM是如何分配和保存代码数据的;
JIT的编译过程,JIT如何将如何编译
JIT与解释模式的相互调用。
因为内容比较多,因此,我将文章分为3篇,本篇文章主要介绍JIT的入口,JIT的主要运行框架;其余两篇文章,将介绍JIT的编译与JIT编译后的代码调用。
Dalik代码中,哪些数据和重要的函数是和JIT有关的?通过Dalvik源代码,我们很容易看到,所有被WITH_JIT宏包围的代码,都是和JIT相关的。
struct Thread {
....
#ifdef WITH_JIT
struct JitToInterpEntries jitToInterpEntries; //jit代码跳转到解释模式的桥接口,配合jit编译出代码使用的
/*
* Whether the current top VM frame is in the interpreter or JIT cache:
* NULL : in the interpreter
* non-NULL: entry address of the JIT'ed code (the actual value doesn't
* matter)
*/
void* inJitCodeCache; //当前jit的入口地址代码。用于解释到jit的跳转
unsigned char* pJitProfTable; //这是一个以davik PC为key的hash表,记录对应代码的执行次数
int jitThreshold;
const void* jitResumeNPC; // Translation return point 这些是用来jit暂停再重启要保存的数据
const u4* jitResumeNSP; // Native SP at return point
const u2* jitResumeDPC; // Dalvik inst following single-step
JitState jitState; //用于jit trace的状态信息保存
int icRechainCount;
const void* pProfileCountdown;
const ClassObject* callsiteClass; //invoke子对象时必须的数据
const Method* methodToCall;
#endif
....
}
比较重要的数据是pJitProfTable,它是一个hash表,以dalvik PC为key,通过dvmJitHash函数算出索引。索引所指是一个unsgind char数据,一开始,数据被写入255,然后直至递减至0,表示可以开始代码的Trace了。
gDvmJit以及dvmJitGetTraceAddr
gDvmJit是一个维护Jit信息的全局对象。其中最重要的是维护jit代码入口的hash表。gDvmJit.pJitEntryTable是JitEntry的hash表。这是一个桶hash对象。通过dvmJitGetTraceAddr和dvmJitGetTraceAddrThread等函数获取到dalvik pc对应的代码地址。
JIT的入口
我们知道,DVM解释器的入口是dvmMterpStdRun ,在这个函数入口处,有一个common_updateProfile的标签(汇编函数),这个标签下的代码,就是解释模式进入到JIT世界的入口。
dalvik大部分都运行在arm设备上,因此对应的源码是vm/mterp/out/InterpAsm-armv7-a-neon.S.
common_updateProfile代码分析
为了方便大家阅读,我将对应的汇编码翻译成对等的C++代码
其中common_selectTrace是负责开始trace代码的。
这里面比较难以理解的部分,是还要reset counter,似乎看起来是延长了进入jit的次数。
DVM有很多地方都会进入到common_updateProfile中,这些包括:
dvmMterpStdRun入口处;
invoke函数,准备好参数后
IF指令处
GOTO指令
SWITCH指令
JIT指令转移到解释模式后,也有机会再次进入到JIT代码。这种情况下,jit会考虑将两者连在一起,在jit中直接跳转。
这些说明,JIT是以一段连续的没有跳转的代码为单位进行编译的。
Trace代码的功能,是收集将要被编译的代码,然后将结果放入到编译队列中。
Davik完成这个工作,是一边继续执行解释代码,一边收集要被编译的代码的。这个过程是通过一个巧妙的方法实现的。
为了完成这个工作,Dalvik用一个简单的状态机来实现过程控制。其中Thread.jitState是用来保存状态的。分别有5个状态:
kJitNone : 初始状态,此时没有开始jit
kJitTSelectRequest/kJitTSelectRequestHot:请求代码收集。是在common_updateProfile被调用后发现对应code还没有
kJitTSelect : 开始收集代码的阶段,当遇到分支跳转代码、return代码、throw代码后,就会转入Select
kJitTSelectEnd : 完成收集代码的阶段,会调用dvmCompilerWorkEnqueue,将字节码加入队列
kJitDone : 完成编译,并调用dvmJitSetCodeAddr,将编译后代码的地址保存起来。
除了jitState之外,Thread还有一种subMode,来表示当前解释器运行在那种状态下。subMode由枚举值ExecutionSubModes定义。ExecutionSubModes有很多,但是,我只列出重要的几个:
kSubModeNormal : 正常状态,没有活动的子模式
kSubModeJitTraceBuild : Trace状态,现在以Trace状态运行。
kSubModeJitTraceBuild是我们关注的子模式。那么,kSubModeJitTraceBuild这种子模式究竟有什么作用呢?与Trace代码和jitState的状态变化有什么关系?
下面我们回答这个问题。
我们知道,当调用完common_updateProfile后,如果尚未获取jit代码,就会调用common_selectTrace。那么,common_selectTrace要做什么呢?为了简化代码,我还是将其翻译成对应的C++代码:对等的C++代码:
if (thread->subMode & kSubModeJitTraceBuild != 0) {
thread->jitState = new_jitState; //(即kJitTSelectRequest);
EXPORT_PC();
SAVE_PC_FP_TO_SELF(); //这两行是保存PC和FP信息的,与trace关系不大,忽略
dvmJitCheckTraceRequest(self); //(1)
}
FETCH_INST(); //取下个指令
int opcode = GetInstOpcode(inst);
goto thread->curHandlerTable + opcode * 64; //(2)
(1) dvmJitCheckTraceRequest指令,就是要将jitState由kJitNone转到kJitTSelectRequest状态的函数。另外,该函数将调用dvmEnableSubMode函数,完成解释器模式的转变;
(2) goto 语句是伪代码,表示现在跳转到thread→curHandlerTable + opcode * 64的指令处执行。
实际上,这是dalvik一个非常精巧的一个手段。
首先,curHandlerTable的值,是可以通过dvmEnableSubMode改变的,也就是说,当我们需要开始收集要编译指令时,需要进入kSubModeJitTraceBuild子模式,而模式的切换,是通过改变curHandlerTable的值实现的;
其次,curHandlerTable是什么呢? 从代码中,我们可以看到:
当进入kSubModeJitTranceBuild时,newValue.ctl.BreakFlags标记就会被设置上,因此,这种情况下,curHandlerTable的值是altHandlerTable。而平常情况下,则是mainHandlerTable。
mainHandlerTable其实就是dvmAsmInstructionStart。dvmAsmInstructionStart这个符号,是定义在InterpAsm-armv7-a-neon.S中,是这样定义的:
.global dvmAsmInstructionStart
.type dvmAsmInstructionStart, %function
dvmAsmInstructionStart = .L_OP_NOP
.text
....
从这里,我们可以看出两个信息:
-
所有的dalvik字节码指令,都有对应的一段汇编码实现,而且每段汇编码的命名是.L_OP_XXX,XXX表示opcode的名字;
-
dvmAsmInstructionStart是这组汇编码的首地址。也就是说,curHandlerTable其实是一组与dalvik字节码对应的,用于解释字节码的汇编码的数组。因此,curHandlerTable + opcode *64就是找到对应的.L_OP_opcode 代码地址,然后调用这段汇编码。
那么,为什么要乘以64呢? 因为每个.L_OP_XXX标签前面,都有一个 .align 64的伪汇编指令。他告诉汇编器,这些标签必须以64字节为对齐。所以,所有的.L_OP_XXX代码,全部被安排在以dvmAsmInstructionStart为开始的连续空间内,并以64字节对齐。
换句话说,每个OPCODE,它的编码必须是连续的,且每个汇编码的实现,不能超出64字节。如果超出了怎么办?那就用一个branch指令,跳转到一个不受限制的空间内执行。
那么,altHandlerTable又是谁呢?
altHandlerTable的秘密
altHandlerTable,对应的代码入口是dvmAsmAltInstructionStart,他是一组.L_ALT_OP_XXX入口的标签。与mainHandlerTable一样,它也是一组数组。
那么,.L_ALT_OP_XXX与.L_OP_XXX有什么不同呢?
我们以.L_ALT_OP_MOVE为例。
首先,我们看看其源代码是如何编写的:
这是一个很精巧的设计。首先我把它翻译成对等的C++代码:
.L_ALT_OP_MOVE:
real_code_addr = dvmAsmInstrunctionstart + (1*64); //其实就是.L_OP_MOVE的地址,.L_OP_MOVE是第二条指令的实现
if (thread->breakFlags != 0) {
dvmCheckBefore(dPC, dFP, self);
}
goto real_code_addr; //即goto .L_OP_MOVE
这里的关键是使用了lr寄存器保存.L_OP_MOVE的地址。
当代码执行(2)时,如果breakFlags == 0,就直接跳转到.L_OP_MOVE了。如果不等于0,就会调用dvmCheckBefore函数。
但是,一般情况下,我们都使用bl (branch link)来调用函数,而不是B。 bl的作用是,当调用函数时,将bl下条指令的地址,存放在lr寄存器内。
当函数返回时,使用 bx lr这样的指令,或者直接将lr的值赋值给pc,就可以返回了。
但是,这里(3) 使用了b,而没有使用bl,这样,lr的值就一直是 .L_OP_MOVE的地址。当dvmCheckBefore函数返回后,就会直接调用.L_OP_MOVE了。
由此可以看出,altHandlerTable其实就是在调用每条指令前,先调用dvmCheckBefore函数。
如何获取Trace代码
dvmCheckBefore函数会完成很多任务,其中也包括TraceBuild。因为我们只关心Trace部分,因此我们只找和Trace相关的代码。Trace代码的任务,主要由dvmCheckJit完成。
JitTraceRun
JitTraceRun是保存代码结构的。所有要编译的代码信息,就保存在这里。首先看该结构的定义:
JitTraceRun不是单独存在,必须以一个数组的方式存在。JitTraceRun.isCode表示是否是代码。当是代码时,JitCodeDesc frag结构被使用。
如果遇到 isCode==1 && info.frag.runEnd == 1就表示结束。
当isCode为1时,frag变量的每个成员含义是:
numInsts: 当前被trace的指令个数
startOffset: 当前指令开始偏移。这个偏移是相对于指令所在method的偏移。偏移从0开始,表示第一条指令。
hint : 忽略,本阶段不考虑
runEnd: 必须为0,表示有效的地址。
当isCode == 0时,meta数据被使用,这个定义很宽泛,可以是任意值。但是这里,它们被用作标记一个函数调用(invoke指令)。
要标记一个函数调用,必须用4个连续的JitTranceRun,其中前3个是meta数据,最后一个是isCode。为了方便,我们用表格列出如下
索引 | isCode | meta/frag 数据 | 说明 |
---|---|---|---|
0 | False | meta = thisClass | callee 函数的this指针 |
1 | False | meta = thisClass→classLoader | thisClass的loader |
2 | False | meta = calleeMethod | 被调用函数对象 |
3 | True | frag.startOffset = resultPC | 函数返回后,要继续运行的dalvik PC偏移 |
当Trace完成后,我们就会得到这样一组JitTraceRun的数组,从而让我们能够完成对代码的进一步处理。
dvmJitCheck是在每次指令完成后调用的。他根据jitState作出不同的动作。JitTraceRun数组是放在Thread.trace对象中的。为了逻辑清晰,我用伪代码给出,不再引用他的源码了。有兴趣的同学可以自己查看。
当jitState为kJitTSelect状态时,表示正在进行收集指令工作
通过这个简单的模型,我们可以看出,当遇到分支(if/else, switch/case) goto指令,throw和return指令后,trace就会结束。
另外,dalvik会尽量的将连续的代码放在一个run内,保证内存使用精简。
几乎所有用到的数据都要保存在Thread对象中。因为Thread对象是这个线程内运行的全局变量。
当进入kJitTSelectEnd后,就要调用dvmCompilerWorkEnqueue函数了。jit将所有的run对象拷贝一份到对象JitTraceDescription中,然后放入队列中,然编译线程继续运行。
在dalvik中,执行编译任务的是线程compilerThreadStart。这个线程由函数dvmCompilerStartup来创建。真正执行编译任务的是 dvmCompilerDoWork→dvmCompileTrace。
dvmCompileTrace是真正执行编译任务的函数。
编译完成后,得到的新地址会被保存到gDvmJit中。通过dvmJitSetCodeAddr调用实现。
详细的编译过程将另开辟一个文章来说明。因为编译过程实在是太复杂了。
我曾经以为jit代码会在整个系统内共享,但是实际上,每个进程有自己的内存空间。函数dvmCompilerSetupCodeCache完成内存开辟的任务。内存首先会开辟一个gDvmJit.codeCacheSize大小(在arm下,是1500 * 1024大小)。并命名为dalvik-jit-code-cache.我们可以通过查看/proc//maps中找对应的段,就知道了。
当需要jit代码后,每次就从这个缓冲区获取一块。因为代码总是只分配不回收,所以分配方法非常简单。