编译器优化技术 — 逃逸分析

一、基本原理与名词

逃逸分析是目前较前沿的优化技术,它不会进行代码的直接优化,而是为其他优化技术提供分析的技术。

原理

通过其对象动态作用域进行分析,从而得到逃逸程度。

方法逃逸

当一个对象在方法里面被定义后,它可能被外部方法所引用,作为调用参数传递到其他方法中。

线程逃逸

赋值到可以在其他线程中访问到的实例变量。

逃逸程度

逃逸程度从低到高分为三个级别:

  • 不逃逸:其他方法或线程都无法通过任何途径访问到这个对象。
  • 逃逸程度低:即方法逃逸,线程以内的逃逸。
  • 逃逸程度高:即线程逃逸,可逃逸至线程外。

二、逃逸优化

1. 栈上分配

当确定一个对象不会逃逸出线程之外,直接让对象在栈上进行内存分配 即可,对象占用的内存会随栈帧出栈而销毁。

在实际应用开发中,不逃逸和逃逸程度低的对象所占比例是很大的,大量对象随着方法结束会自动销毁,垃圾收集的压力会大大减小。但此方式 不支持线程逃逸

2. 标量替换

标量
一个数据已无法再分解成更小的数据来表示,那么就可以称它为标量。
例如:Java 虚拟机的原始数据类型(intlong 等数值类型及 reference 类型等),都无法进一步进行分解。

聚合量
一个数据可以继续分解,则为聚合量。
例如:Java 中的对象就是典型的聚合量。

标量替换
如果把一个对象(聚合量)拆散,根据程序访问情况,将用到的 成员变量恢复为原始类型(标量)来进行访问,此过程即为标量替换。

当确定一个对象不会被方法外部访问,并且这个对象可以被拆散,则程序执行时可能不会创建这个对象,由此带来两点好处:

  • 对象可直接分配和读写在栈上,而栈上的数据很大机会被分配至物理机器的高速寄存器中存储。
  • 为后续进一步优化创造条件。

但此方式要求更高,不允许对象逃逸出方法范围内

3. 同步消除

线程同步本身是一个相对耗时的过程,当确定一个变量不会逃逸出线程时(其他线程无法访问),此变量读写肯定不会有竞争,对这个变量进行的 同步措施可以安全消除

4. 工作过程示例

初始代码

class Point {
    private int x;
    private int y;
    public Point(int x, int y) {
        this.x = x;
        this.y = y;
    }
}

public int test(int x) {
    int xx = x + 2;
    Point p = new Point(xx, 42);
}

内联化
Point 的构造函数和 getX() 方法进行内联优化(参考内联相关博客)。

public int test(int x) {
    int xx = x + 2;
    Point p = point_memory_alloc();    // 在堆上分配 p 对象的表示(非真实代码)
    p.x = xx;    // ```Point``` 的构造函数内联后的表示(非真实代码)
    p.y = 42;
    return p.x;    // ```Point::getX()``` 被内联后的表示(非真实代码)
}

逃逸分析(标量替换)
整个 test() 方法的范围内 Point 对象实例不会发生任何程度的逃逸,故可进行标量替换优化。
把内部 x 和 y 直接置换出来,分解为 test() 方法内的局部变量,从而不用直接实例化 Point 对象实例,达到优化目的。

public int test(int x) {
    int xx = x + 2;
    int px = xx;
    int py = 42;
    return px;
}

数据流分析
经过数据流分析(参考数据流分析相关博客),发现 py 的值不会对方法造成影响,故可直接消除优化。

public int test(int x) {
    return x + 2;
}

三、实验(栈上分配验证)

测试代码
public static void main(String[] args) {
    long start = System.currentTimeMillis();
    for (int i = 0; i < 1000000; i++) {
        alloc();
    }
    // 查看执行时间
    long end = System.currentTimeMillis();
    System.out.println("cost " + (end - start) + " ms");
    // 为了方便查看堆内存中对象个数,线程 sleep
    try {
        Thread.sleep(600000);
    } catch (InterruptedException e1) {
        e1.printStackTrace();
    }
}

private static void alloc() {
    User user = new User();
}

static class User {

}
分析

代码内容很简单,使用 for 循环,创建 100 万个 User 对象。

其中,alloc 方法中定义了 User 对象,但是并没有在方法外部引用。故这个对象并不会逃逸到 alloc 外部。经过 JIT 的逃逸分析之后,就可以对其内存分配进行优化。

参数设定
  • 第一组
    指定以下 JVM 参数:
    -Xmx4G -Xms4G -XX:-DoEscapeAnalysis -XX:+PrintGCDetails -XX:+HeapDumpOnOutOfMemoryError
    其中 -XX:-DoEscapeAnalysis 表示 关闭 逃逸分析。
  • 第二组
    指定以下 JVM 参数:
    -Xmx4G -Xms4G -XX:+DoEscapeAnalysis -XX:+PrintGCDetails -XX:+HeapDumpOnOutOfMemoryError
    其中 -XX:+DoEscapeAnalysis 表示 开启 逃逸分析。
运行结果

分别使用两组参数运行代码。
在程序打印出 cost XX ms 后,代码运行结束之前,我们使用 jmap 命令,来查看下当前堆内存中有多少个 User 对象。

> jmap -histo 2809       // 其中 2809 为当前 JVM 进程 ID
  • 第一组
 num     #instances         #bytes  class name
----------------------------------------------
   1:           524       87282184  [I
   2:       1000000       16000000  StackAllocTest$User
   3:          6806        2093136  [B
   4:          8006        1320872  [C
   5:          4188         100512  java.lang.String
   6:           581          66304  java.lang.Class
  • 第二组
 num     #instances         #bytes  class name
----------------------------------------------
   1:           524      101944280  [I
   2:          6806        2093136  [B
   3:         83619        1337904  StackAllocTest$User
   4:          8006        1320872  [C
   5:          4188         100512  java.lang.String
   6:           581          66304  java.lang.Class
分析

从上面的 jmap 执行结果中我们可以看到。

  • 第一组
    堆中共创建了 100 万个 StackAllocTest$User 实例。
  • 第二组
    堆中共创建了 8.3 万多个 StackAllocTest$User 实例。
结论

在关闭逃避分析的情况下,虽然在 alloc 方法中创建的 User 对象并没有逃逸到方法外部,但是还是被分配在堆内存中。
故没有 JIT 编译器优化,没有逃逸分析技术,所有对象都分配到堆内存中。

在打开逃避分析的情况下,在堆内存中只有 8 万多个 StackAllocTest$User 对象。也就是说在经过 JIT 优化之后,堆内存中分配的对象数量,从 100 万降到了 8.3 万。

除以上通过 jmap 验证对象个数的方法以外,还可以尝试将堆内存调小,然后执行以上代码,根据GC的次数来分析。
也能发现,开启了逃逸分析之后,在运行期间,GC 次数会明显减少。因为很多堆上分配对象内存被优化至栈上分配,随着方法结束而自动销毁,导致 GC 次数减少。

四、总结

发展与现状

关于逃逸分析的论文在 1999 年就已经发表了,但直到 JDK 1.6,HotSpot 才初步支持逃逸分析实现,而且这项技术到如今也并不是十分成熟的,仍有很大的改进余地。

根本原因

无法保证逃逸分析的性能消耗一定能高于他的消耗。虽然经过逃逸分析可以做标量替换、栈上分配和同步消除。但是逃逸分析自身也是需要进行一系列复杂的分析的,这其实也是一个相对耗时的过程。

举一个极端的例子,经过逃逸分析之后,发现没有一个对象是不逃逸的,那这个逃逸分析的过程就白白浪费掉了。故目前虚拟机只能采用不那么准确,但时间压力相对小的算法。

虽然这项技术并不十分成熟,但是他也是即时编译器优化技术中一个十分重要的手段。

你可能感兴趣的:(编译器优化技术 — 逃逸分析)