Android虚拟机中有一个概念叫做deoptimize。它是编译优化的反向过程,表示从AOT/JIT回退到解释执行。可是好不容易编译出的机器码,为啥就要回退到解释执行呢?这样运行效率不就下降了么?
确实,如果单看AOT和解释器对同一段代码的执行效率,那无疑是下降的。但是如果这个下降能够带来AOT代码的进一步优化,或许综合下来会有更好的效果?而这才是deoptimize的真正作用,它给编译假设一些前提条件,譬如数组不会越界、调用类型是匹配的,从而使得最终的编译代码更加精简和优化。当运行时这些条件无法满足时,原本编译执行的的代码就会deoptimize为解释执行,去处理这些异常情况。
我们以数组越界为例。如下的两个方法我们都传入了一个int数组,长度未知,因此它们都可能产生数组越界的异常。
AOT在编译oneSet
时不会有特殊的优化,直接判断数组长度是否为0,如果为0则进入Out-of-bounds(OOB)的异常抛出流程,而异常一定指向arr[0] = 1
这一行。
可是AOT在编译fourSets
时却有些特殊的优化,它假定了一个前提条件,即数组的访问不会越界(数组长度大于等于4),这样后面的四次赋值过程中便不需要为每一次访问都生成一个OOB的检测。如果前提条件不满足,那么就deoptimize到解释执行。之所以不能简单地通过pThrowArrayBounds
来抛出异常,是因为异常还要告诉我们具体出错的是哪一行。编译代码无法告诉我们这个信息,因为它只判断了最大的边界,并没有为每一次赋值都进行检测,所以只能回退到解释执行。
[Java代码]
class Main {
public static double oneSet(int[] arr) {
arr[0] = 1;
return 79.3d;
}
public static double fourSets(int[] arr) {
arr[0] = 1;
arr[1] = 1;
arr[2] = 1;
arr[3] = 1;
return 79.3d;
}
}
[编译后的汇编代码]
double Main.oneSet(int[]) [60 bytes]
0x00004080 stp x0, lr, [sp, #-16]!
0x00004084 mov w0, #0x1
0x00004088 ldr w2, [x1, #8] //取出数组的长度
StackMap[0] native_pc=0x408c, dex_pc=0x2, register_mask=0x0, stack_mask=0b
0x0000408c cmp w2, #0x0 (0)
0x00004090 b.ls #+0x14 (addr 0x40a4) //如果数组长度小于等于0,则抛出异常
0x00004094 str w0, [x1, #12]
0x00004098 ldr d0, pc+28 (addr 0x40b4) (79.3)
0x0000409c ldp xzr, lr, [sp], #16
0x000040a0 ret
0x000040a4 mov x1, x2
0x000040a8 mov w0, #0x0
0x000040ac bl #+0xd4 (addr 0x4180) ; pThrowArrayBounds
StackMap[1] native_pc=0x40b0, dex_pc=0x2, register_mask=0x0, stack_mask=0b
0x000040b0 ldr xzr, pc+12 (addr 0x40bc)
0x000040b4 unallocated (Unallocated)
0x000040b8 unallocated (Unallocated)
double Main.fourSets(int[]) [88 bytes]
0x00004020 sub x16, sp, #0x2000 (8192)
0x00004024 ldr wzr, [x16]
StackMap[0] native_pc=0x4028, dex_pc=0x0, register_mask=0x0, stack_mask=0b
0x00004028 str x0, [sp, #-32]!
0x0000402c str lr, [sp, #24]
0x00004030 mov w0, #0x1
0x00004034 ldr w2, [x1, #8] //取出数组的长度
StackMap[1] native_pc=0x4038, dex_pc=0x2, register_mask=0x0, stack_mask=0b
0x00004038 cmp w2, #0x3 (3)
0x0000403c b.ls #+0x24 (addr 0x4060) //如果数组长度小于等于3,则deoptimize去处理异常
0x00004040 str w0, [x1, #12]
0x00004044 str w0, [x1, #16]
0x00004048 str w0, [x1, #20]
0x0000404c str w0, [x1, #24]
0x00004050 ldr d0, pc+32 (addr 0x4070) (79.3)
0x00004054 ldr lr, [sp, #24]
0x00004058 add sp, sp, #0x20 (32)
0x0000405c ret
0x00004060 str x0, [sp, #8]
0x00004064 mov x0, #0x5
0x00004068 bl #+0x108 (addr 0x4170) ; pDeoptimize
StackMap[2] native_pc=0x406c, dex_pc=0x2, register_mask=0x2, stack_mask=0b
0x0000406c ldr xzr, pc+12 (addr 0x4078) (0x39ce00000000 / 63556926046208)
0x00004070 unallocated (Unallocated)
0x00004074 unallocated (Unallocated)
这种策略通常是正向的,因为异常抛出本身就是耗时且低概率的,在它身上增加一点耗时并无太大影响。而这点耗时换来的将是高概率执行路径的性能优化,以及更加精简的代码(code size变小)。
当然,deoptimize还有些其他的用途,但总体上分为两类:一个是调试,另一个就是为了AOT/JIT时能够有更好的优化(如上述的例子)。说起来挺魔幻的,deoptimize是为了更好的optimize。
[Deoptimize的类型]
enum class DeoptimizationKind {
kAotInlineCache = 0,
kJitInlineCache,
kJitSameTarget,
kLoopBoundsBCE,
kLoopNullBCE,
kBlockBCE,
kCHA,
kDebugging,
kFullFrame,
kLast = kFullFrame
};
运行过程中deoptimize,有点像汽车行驶过程中换轮胎。它并没有暂停线程,而是在运行过程中完成了编译执行到解释执行的切换。这个过程有两个核心的工作,一个是Shadow Frame重建,另一个是程序流向的控制。
Dalvik字节码的设计基于虚拟寄存器,相较于编译执行时物理寄存器作为程序的上下文(context),解释执行时这些虚拟寄存器(vregs)就是程序的上下文。因此从编译执行切换为解释执行时,必须要找到这些vregs并往其中填入正确的值。
我们都知道,程序运行时每一次方法调用都会在栈上开辟出新的区域,这块区域通常称为栈帧,仿佛老式胶卷中一帧帧的画面。对于编译执行而言,栈上的一帧就是一次真实的方法调用;可是解释执行并非如此,因为在栈上开辟空间的是解释器,因此栈上3、4帧才对应于一次真实的方法调用。既然如此,解释执行就抽象出一个新的数据结构:Shadow Frame,用它来对应真实的方法调用,其中就包括保存vregs这些上下文信息。
Vregs虽说是虚拟寄存器,但是它的值却来源于真实的物理世界,可能来源于物理寄存器、栈或者干脆是个常量。
[Vreg的来源]
enum class Kind : int32_t {
kInvalid = -2, // only used internally during register map decoding.
kNone = -1, // vreg has not been set.
kInStack, // vreg is on the stack, value holds the stack offset.
kConstant, // vreg is a constant value.
kInRegister, // vreg is in low 32 bits of a core physical register.
kInRegisterHigh, // vreg is in high 32 bits of a core physical register.
kInFpuRegister, // vreg is in low 32 bits of an FPU register.
kInFpuRegisterHigh, // vreg is in high 32 bits of an FPU register.
};
对于非调试原因的deoptimize,通常只需要deoptimize最上面一帧,因为异常发生在那里。如果它含有内联帧,那么需要为每一个内联帧都创建对应的Shadow Frame,因为在解释执行时没有内联的概念,这样切换到解释执行后栈回溯才不会出错。
至于程序流向的控制,当我们回退到Frame #1后可以通过art_quick_to_interpreter_bridge
来进入解释执行。但是这里有个重要的问题需要解决。常规情况下,调用会从方法的头部开始运行,可是异常发生时头部的代码很可能已经运行过了。因此art_quick_to_interpreter_bridge
内部会对deoptimize有单独的处理,让解释器直接从Shadow Frame中保存的dex_pc处开始执行。
至此,整个切换就完成了。
后记
其实没啥好后记的,只是我习惯每次写完文章后都闲扯两句。夏天就要到了,记得在老家的时候,最喜欢傍晚搬把椅子坐在楼顶上看晚霞,有时一坐就是个把小时。果然,最美的云总要经历最酷热的炙烤,人生亦是如此。祝各位在夏天时都能抬头看到美美的云。
最后
如果想要成为架构师或想突破20~30K薪资范畴,那就不要局限在编码,业务,要会选型、扩展,提升编程思维。此外,良好的职业规划也很重要,学习的习惯很重要,但是最重要的还是要能持之以恒,任何不能坚持落实的计划都是空谈。
如果你没有方向,这里给大家分享一套由阿里高级架构师编写的《Android八大模块进阶笔记》,帮大家将杂乱、零散、碎片化的知识进行体系化的整理,让大家系统而高效地掌握Android开发的各个知识点。
相对于我们平时看的碎片化内容,这份笔记的知识点更系统化,更容易理解和记忆,是严格按照知识体系编排的。
欢迎大家一键三连支持,若需要文中资料,直接扫描文末CSDN官方认证微信卡片免费领取↓↓↓(文末还有ChatGPT机器人小福利哦,大家千万不要错过)
PS:群里还设有ChatGPT机器人,可以解答大家在工作上或者是技术上的问题