本文主要讲解dalvik jit的框架,至于更深层次的内容,例如编译的方法、寄存器的分配等问题,将在以后讨论。请先阅读 dalvik VM的解释器分析
Dalvik JIT的简要介绍
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有一个编译线程,负责编译,编译过程是
-
while
(
1
)
{
-
JitRuns jitRuns
= GetFromWorkQueue
(
)
;
-
addr
= compile
(jitRuns
)
;
-
SetJitAddr
(addr
)
;
-
}
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相关的。
Thread中的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_updateProfile
:
-
/* 对等C++代码*/
-
int idx
= dvmJitHash
(rPC
)
;
-
if
(
--thread
-
>pJitProfTable
[idx
]
!
=
0
)
{
-
goto_op_code
(ip
)
;
//这是跳转到对应的解释器入口处。
-
}
-
-
thread
-
>pJitProfTable
[idx
]
= thread
-
>jitThreshold
;
-
-
EXPORT_PC
(
)
//save dPC到StackArea中,这是解释器要保存的事情
-
if
(
(thread
-
>inJitCodeCache
= dvmJitGetTraceAddrThread
(dPC, thread
)
)
!
=
NULL
)
{
-
thread
-
>inJitCodeCache
(
)
;
//调用jit入口
-
}
-
//其他情况,调用common_selectTrace,后续在详细讨论
其中common_selectTrace是负责开始trace代码的。
这里面比较难以理解的部分,是还要reset counter,似乎看起来是延长了进入jit的次数。
DVM进入common_updateProfile的点
DVM有很多地方都会进入到common_updateProfile中,这些包括:
-
dvmMterpStdRun入口处;
-
invoke函数,准备好参数后
-
IF指令处
-
GOTO指令
-
SWITCH指令
-
JIT指令转移到解释模式后,也有机会再次进入到JIT代码。这种情况下,jit会考虑将两者连在一起,在jit中直接跳转。
这些说明,JIT是以一段连续的没有跳转的代码为单位进行编译的。
Trace代码
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的状态变化有什么关系?
下面我们回答这个问题。
SubMode的秘密
进入kSubModeJitTraceBuild子模式
我们知道,当调用完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是什么呢? 从代码中,我们可以看到:
-
newValue.
ctl.
curHandlerTable
=
(newValue.
ctl.
breakFlags
)
-
? thread
-
>altHandlerTable
: thread
-
>mainHandlerTable
;
当进入kSubModeJitTranceBuild时,newValue.ctl.BreakFlags标记就会被设置上,因此,这种情况下,curHandlerTable的值是altHandlerTable。而平常情况下,则是mainHandlerTable。
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为例。
首先,我们看看其源代码是如何编写的:
-
/* ------------------------------ */
-
.
balign
64
-
.
L_ALT_OP_MOVE
:
/* 0x01 */
-
/* File: armv5te/alt_stub.S */
-
/*
-
* Inter-instruction transfer stub. Call out to dvmCheckBefore to handle
-
* any interesting requests and then jump to the real instruction
-
* handler. Note that the call to dvmCheckBefore is done as a tail call.
-
* rIBASE updates won't be seen until a refresh, and we can tell we have a
-
* stale rIBASE if breakFlags==0. Always refresh rIBASE here, and then
-
* bail to the real handler if breakFlags==0.
-
*/
-
ldrb r3,
[rSELF,
#offThread_breakFlags]
-
adrl lr, dvmAsmInstructionStart
+
(
1
*
64
)
//(1)
-
ldr rIBASE,
[rSELF,
#offThread_curHandlerTable]
-
cmp r3,
#0
-
bxeq lr
//(2) @ nothing to do - jump to real handler
-
EXPORT_PC
(
)
-
mov r0, rPC @ arg0
-
mov r1, rFP @ arg1
-
mov r2, rSELF @ arg2
-
b dvmCheckBefore
//(3) @ (dPC,dFP,self) tail call
这是一个很精巧的设计。首先我把它翻译成对等的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是保存代码结构的。所有要编译的代码信息,就保存在这里。首先看该结构的定义:
-
/*
-
* Element of a Jit trace description. If the isCode bit is set, it describes
-
* a contiguous sequence of Dalvik byte codes.
-
*/
-
struct JitCodeDesc
{
-
unsigned numInsts
:
8
;
// Number of Byte codes in run
-
unsigned runEnd
:
1
;
// Run ends with last byte code
-
JitHint hint
:
7
;
// Hint to apply to final code of run
-
u2 startOffset
;
// Starting offset for trace run
-
}
;
-
-
struct JitTraceRun
{
-
union
{
-
JitCodeDesc frag
;
-
void
* meta
;
-
} info
;
-
u4 isCode
:
1
;
-
u4 unused
:
31
;
-
}
;
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
dvmJitCheck是在每次指令完成后调用的。他根据jitState作出不同的动作。JitTraceRun数组是放在Thread.trace对象中的。为了逻辑清晰,我用伪代码给出,不再引用他的源码了。有兴趣的同学可以自己查看。
当jitState为kJitTSelect状态时,表示正在进行收集指令工作
-
//这段伪代码将字节码执行的逻辑也放进来,让大家能够清楚宏观上的结构
-
u2
* lastPC
==
0
;
-
currentTraceRun
=
0
;
-
while
(
1
)
{
//解释器的最外层循环
-
inst
= GetCurrentInst
(curPC
)
;
//获取当前指令
-
if
(lastPC
==
0
)
{
-
lastPC
= curPC
;
//第一次收集
-
thread
-
>trace
[currentTraceRun
].
info.
frag.
numInsts
=
1
;
-
thread
-
>trace
[currentTraceRun
].
info.
frag.
startOffset
= lastPC
- method
-
>insns
;
//取得地址偏移
-
thread
-
>trace
[currentTraceRun
].
isCode
=
true
;
-
}
else
{
-
if
(inst is Invoke
)
//函数调用
-
{
-
//把 callee的this, classloader和method对象放入
-
insertClassMethodInfo
(
)
;
-
currentTraceRun
+
=
3
;
-
//把返回地址放入
-
insertMoveResult
(
)
;
-
currentTraceRun
++
;
-
}
else
if
(inst is Branch or Goto or Throw or
return
)
{
-
insertLastPC
(lastPC
)
;
//把最后的pc放入到trace中
-
jitState
= kJitTSelectEnd
;
//结束
-
break
;
-
}
else
{
-
thread
-
>trace
[currentTraceRun
].
info.
frag.
numInsts
++
;
//增加指令个数
-
}
-
}
-
lastPC
= curPC
;
-
/* 解释执行inst */
-
....
-
}
通过这个简单的模型,我们可以看出,当遇到分支(if/else, switch/case) goto指令,throw和return指令后,trace就会结束。
另外,dalvik会尽量的将连续的代码放在一个run内,保证内存使用精简。
几乎所有用到的数据都要保存在Thread对象中。因为Thread对象是这个线程内运行的全局变量。
当进入kJitTSelectEnd后,就要调用dvmCompilerWorkEnqueue函数了。jit将所有的run对象拷贝一份到对象JitTraceDescription中,然后放入队列中,然编译线程继续运行。
编译及code代码存储
编译线程
在dalvik中,执行编译任务的是线程compilerThreadStart。这个线程由函数dvmCompilerStartup来创建。真正执行编译任务的是 dvmCompilerDoWork→dvmCompileTrace。
dvmCompileTrace是真正执行编译任务的函数。
编译完成后,得到的新地址会被保存到gDvmJit中。通过dvmJitSetCodeAddr调用实现。
详细的编译过程将另开辟一个文章来说明。因为编译过程实在是太复杂了。
编译出的代码内存如何分配
我曾经以为jit代码会在整个系统内共享,但是实际上,每个进程有自己的内存空间。函数dvmCompilerSetupCodeCache完成内存开辟的任务。内存首先会开辟一个gDvmJit.codeCacheSize大小(在arm下,是1500 * 1024大小)。并命名为dalvik-jit-code-cache.我们可以通过查看/proc//maps中找对应的段,就知道了。
当需要jit代码后,每次就从这个缓冲区获取一块。因为代码总是只分配不回收,所以分配方法非常简单。