Java即时编译器原理解析及实践( 六 )


比如下面这段代码 , 我们增加一个实现:
多实现的虚调用
public class SimpleInliningTest{public static void main(String[] args) throws InterruptedException {VirtualInvokeTest obj = new VirtualInvokeTest();VirtualInvoke1 obj1 = new VirtualInvoke1();VirtualInvoke2 obj2 = new VirtualInvoke2();for (int i = 0; i < 100000; i++) {invokeMethod(obj);invokeMethod(obj1);invokeMethod(obj2);}Thread.sleep(1000);}public static void invokeMethod(VirtualInvokeTest obj) {obj.methodCall();}private static class VirtualInvokeTest {public void methodCall() {System.out.println("virtual call");}}private static class VirtualInvoke1 extends VirtualInvokeTest {@Overridepublic void methodCall() {super.methodCall();}}private static class VirtualInvoke2 extends VirtualInvokeTest {@Overridepublic void methodCall() {super.methodCall();}}}经过反编译得到下面的汇编代码:
0x000000011f5f0a37: callq0x000000011f4fd2e0; OopMap{off=28};*invokevirtual methodCall//代表虚调用; - SimpleInliningTest::invokeMethod@1 (line 20);{virtual_call}//虚调用未被优化可以看到多个实现的虚调用未被优化 , 依然是virtual_call 。
Graal编译器针对这种情况 , 会去收集这部分执行的信息 , 比如在一段时间 , 发现前面的接口方法的调用add和sub是各占50%的几率 , 那么JVM就会在每次运行时 , 遇到add就把add内联进来 , 遇到sub的情况再把sub函数内联进来 , 这样这两个路径的执行效率就会提升 。 在后续如果遇到其他不常见的情况 , JVM就会进行去优化的操作 , 在那个位置做标记 , 再遇到这种情况时切换回解释执行 。
3. 逃逸分析
逃逸分析是“一种确定指针动态范围的静态分析 , 它可以分析在程序的哪些地方可以访问到指针” 。 Java虚拟机的即时编译器会对新建的对象进行逃逸分析 , 判断对象是否逃逸出线程或者方法 。 即时编译器判断对象是否逃逸的依据有两种:

  1. 对象是否被存入堆中(静态字段或者堆中对象的实例字段) , 一旦对象被存入堆中 , 其他线程便能获得该对象的引用 , 即时编译器就无法追踪所有使用该对象的代码位置 。
  2. 对象是否被传入未知代码中 , 即时编译器会将未被内联的代码当成未知代码 , 因为它无法确认该方法调用会不会将调用者或所传入的参数存储至堆中 , 这种情况 , 可以直接认为方法调用的调用者以及参数是逃逸的 。
逃逸分析通常是在方法内联的基础上进行的 , 即时编译器可以根据逃逸分析的结果进行诸如锁消除、栈上分配以及标量替换的优化 。 下面这段代码的就是对象未逃逸的例子:
pulbic class Example{public static void main(String[] args) {example();}public static void example() {Foo foo = new Foo();Bar bar = new Bar();bar.setFoo(foo);}}class Foo {}class Bar {private Foo foo;public void setFoo(Foo foo) {this.foo = foo;}}}在这个例子中 , 创建了两个对象foo和bar , 其中一个作为另一个方法的参数提供 。 该方法setFoo()存储对收到的Foo对象的引用 。 如果Bar对象在堆上 , 则对Foo的引用将逃逸 。 但是在这种情况下 , 编译器可以通过逃逸分析确定Bar对象本身不会对逃逸出example()的调用 。 这意味着对Foo的引用也不能逃逸 。 因此 , 编译器可以安全地在栈上分配两个对象 。
锁消除
在学习Java并发编程时会了解锁消除 , 而锁消除就是在逃逸分析的基础上进行的 。
如果即时编译器能够证明锁对象不逃逸 , 那么对该锁对象的加锁、解锁操作没就有意义 。 因为线程并不能获得该锁对象 。 在这种情况下 , 即时编译器会消除对该不逃逸锁对象的加锁、解锁操作 。 实际上 , 编译器仅需证明锁对象不逃逸出线程 , 便可以进行锁消除 。 由于Java虚拟机即时编译的限制 , 上述条件被强化为证明锁对象不逃逸出当前编译的方法 。 不过 , 基于逃逸分析的锁消除实际上并不多见 。
栈上分配
我们都知道Java的对象是在堆上分配的 , 而堆是对所有对象可见的 。 同时 , JVM需要对所分配的堆内存进行管理 , 并且在对象不再被引用时回收其所占据的内存 。 如果逃逸分析能够证明某些新建的对象不逃逸 , 那么JVM完全可以将其分配至栈上 , 并且在new语句所在的方法退出时 , 通过弹出当前方法的栈桢来自动回收所分配的内存空间 。
这样一来 , 我们便无须借助垃圾回收器来处理不再被引用的对象 。 不过Hotspot虚拟机 , 并没有进行实际的栈上分配 , 而是使用了标量替换这一技术 。 所谓的标量 , 就是仅能存储一个值的变量 , 比如Java代码中的基本类型 。 与之相反 , 聚合量则可能同时存储多个值 , 其中一个典型的例子便是Java的对象 。 编译器会在方法内将未逃逸的聚合量分解成多个标量 , 以此来减少堆上分配 。 下面是一个标量替换的例子: