【Android ART】回退到解释执行

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开发的各个知识点。
img
相对于我们平时看的碎片化内容,这份笔记的知识点更系统化,更容易理解和记忆,是严格按照知识体系编排的。

欢迎大家一键三连支持,若需要文中资料,直接扫描文末CSDN官方认证微信卡片免费领取↓↓↓(文末还有ChatGPT机器人小福利哦,大家千万不要错过)

PS:群里还设有ChatGPT机器人,可以解答大家在工作上或者是技术上的问题
图片

  • 12
    点赞
  • 23
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值