逃逸分析优化方式中的标量替换,可以看成将对象本身拆散为一个个字段,并把原本对象字段的访问,替换为对一个个局部变量的访问。
由于Sea-of-Nodes IR的特性,局部变量不复存在,取而代之的是一个个值。
1 | //原始代码 |
在X86_64的机器码中,每当使用call指令进入目标方法的方法中时,需要在栈上为当前方法分配一块内存作为其栈帧。而在退出该方法时,需要弹出当前方法所使用的栈帧。
寄存器rsp维护者当前线程的栈顶指针,因为这些操作都是通过增减寄存器rsp来实现的。偏移量为0x06a0以及0x06ae的指令。
HotSpot虚拟机的即时编译器将在方法返回时插入安全点测试指令。偏移量为0x06b3以及0x06ba的指令,真正的安全点测试是0x06b7指令。
如果虚拟机需要所有线程都到达安全点,那么该test指令所访问的内存地址所在的页将被标记为不可访问,而该指令也将触发segfault,并借由segfault处理器进入安全点之中。通常,该指令会附带{poll_return}注释。
在X86_64中,前几个传入参数会被放置于寄存器中,而返回值则需要存放在rax寄存器中。有时返回值被存入eax寄存器中,这其实是同一个寄存器(rax表示64位寄存器,eax表示32位寄存器)。
当忽略掉创建、弹出方法栈帧,安全点测试以及其他无关指令之后,所剩下的方法体就只剩下偏移量为0x06ac的mov指令,以及0x06ba的ret指令。前者将所传入的int型参数x移至代表返回值的eax寄存器中,后者是退出当前方法并返回至调用者中。
在现实中,Java程序中的对象或许本身便是逃逸的,或许因为方法内联不够彻底而被即使编译器当成是逃逸的。这两种情况都将导致即时编译器无法进行标量替换。这时候,针对对象字段访问的优化也变得格外重要。
1 | //对象o是传入参数,不属于逃逸分析的范围(JVM中的逃逸分析针对的是新建对象) |
字段读取优化
即时编译器能够作出类似上述的优化。
即时编译器会优化实例字段以及静态字段访问,以减少总的内存访问数目。具体来说,它将沿着控制流,缓存各个字段存储节点将要存储的值,或者字段读取节点所得到的值。
当即时编译器遇到对同一字段的读取节点时,如果缓存值还没有失效,那么它会将读取节点替换为该缓存值。
当即时编译器遇到对同一字段的存储节点时,它会更新所缓存的值。当即时编译器遇到可能更新字段的节点时,如方法调用节点(在即时编译器看来,方法调用会执行未知代码),或者内存屏障节点(其他线程可能异步更新了字段),那么它会采取保守的策略,舍弃所有缓存值。
1 | //缓存字段读取节点 |
1 | //bar方法中,实例字段a会被赋值为true,后面紧跟着一个以a为条件的while循环 |
实际上,即时编译器将在volatile字段访问前后插入内存屏障节点。这些内存屏障节点会阻止即时编译器将屏障之前所缓存的值用于屏障之后的读取节点之上。
在X86_64平台上,volatile字段读取操作前后的内存屏障是no-op,在即时编译过程中的屏障节点,还是会阻止即时编译器的字段读取优化,强制在循环中使用内存读取指令访问实例字段Foo.a的最新值。
1 | 0x00e0: movzx r11d,BYTE PTR [rbx+0xc] // 读取a |
同理,加锁、解锁操作也同样会阻止即时编译器的字段读取优化。
字段存储优化
除了字段读取优化之外,即时编译器还将消除冗余的存储节点。如果一个字段先后被存储了两次,而且这两次存储之间没有对第一次存储内容的读取,那么及时编译器可以将第一个字段存储给消除掉。
1 | class Foo { |
如果所存储的字段被标记为volatile,那么即时编译器也不能将冗余的存储操作消除掉。如两个存储之间隔着许多其他代码,或者因为方法内联的缘故,将两个存储操作(如构造器中字段的初始化以及随后的更新)纳入同一个编译单元里。
死代码消除
除了字段存储优化之外,局部变量的死存储(dead store)同样也涉及了冗余存储。这是死代码消除(dead code eliminiation)的一种。不过,由于Sea-of-Nodes IR的特性,死代码的优化无须额外代价。
1 | int bar(int x, int y) { |
死存储还有一种变体,即在部分程序路径上有冗余存储。
1 | int bar(boolean f, int x, int y) { |
另一种死代码消除则是不可达分支消除。不可达分支就是任何程序路径都不可到达的分支。
在即时编译过程中,经常因为方法内联、常量传播以及基于profile的优化等,生成许多不可达分支。通过消除不可达分支,即时编译器可以精简数据流,并且减少编译时间以及最终生成机器码的大小。
1 | int bar(int x) { |
总结
即时编译器将沿着控制流缓存字段存储、读取的值,并在接下来的字段读取操作时直接使用该缓存值。
这要求生成缓存值的访问以及使用缓存值的读取之间没有方法调用、内存屏障,或者其他可能存储该字段的节点。
即时编译器还会优化冗余的字段存储操作。如果一个字段的两次存储之间没有对该字段的读取操作、方法调用以及内存屏障,那么即时编译器可以将第一个冗余的存储操作给消除掉。
死代码消除的两种形式:
第一种是局部变量的死存储消除以及部分死存储消除。它们可以通过转换为Sea-of-Nodes IR来完成。
第二种则是不可达分支。通过消除不可达分支,即时编译器可以精简数据流,并且减少编译时间以及最终生成机器码的大小。