逃逸分析

逃逸分析
首先我们需要知道,逃逸分析并不是直接的优化手段,而是而是分码分析手段。具体而言就是:

逃逸分析是“一种确定指针动态范围的静态分析,它可以分析在程序的哪些地方可以访问到指针”。Java虚拟机的即时编译器会对新建的对象进行逃逸分析,判断对象是否逃逸出线程或者方法。即时编译器判断对象是否逃逸的依据有两种:

对象是否被存入堆中(静态字段或者堆中对象的实例字段),一旦对象被存入堆中,其他线程便能获得该对象的引用,即时编译器就无法追踪所有使用该对象的代码位置。

简单来说就是,如类变量或实例变量,可能被其它线程访问到,这就叫做线程逃逸,存在线程安全问题。

对象是否被传入未知代码中,即时编译器会将未被内联的代码当成未知代码,因为它无法确认该方法调用会不会将调用者或所传入的参数存储至堆中,这种情况,可以直接认为方法调用的调用者以及参数是逃逸的。(未知代码指的是没有被内联的方法调用)

比如说,当一个对象在方法中定义之后,它可能被外部方法所引用,作为参数传递到其它方法中,这叫做方法逃逸。

方法逃逸我们可以用个案例来演示一下:

//StringBuffer对象发生了方法逃逸
public static StringBuffer createStringBuffer(String s1, String s2) {
    StringBuffer sb = new StringBuffer();
    sb.append(s1);
    sb.append(s2);
    return sb;
  }

  public static String createString(String s1, String s2) {
    StringBuffer sb = new StringBuffer();
    sb.append(s1);
    sb.append(s2);
    return sb.toString();
  }

基于逃逸分析的优化:

即时编译器可以根据逃逸分析的结果进行诸如同步消除、栈上分配以及标量替换的优化。

同步消除(锁消除)
线程同步本身比较耗费资源,JIT 编译器可以借助逃逸分析来判断,如果确定一个对象不会逃逸出线程,无法被其它线程访问到,那该对象的读写就不会存在竞争,则可以消除对该对象的同步锁,通过-XX:+EliminateLocks(默认开启)可以开启同步消除。 这个取消同步的过程就叫同步消除,也叫锁消除。

我们还是通过案例来说明这一情况,来看看何种情况需要线程同步。

@Getter
public class Worker {

  private String name;
  private double money;

  public Worker() {
  }

  public Worker(String name) {
    this.name = name;
  }

  public void makeMoney() {
    money++;
  }
}


/*测试代码*/
public class SynchronizedTest {


  public static void work(Worker worker) {
    worker.makeMoney();
  }

  public static void main(String[] args) throws InterruptedException {
    long start = System.currentTimeMillis();

    Worker worker = new Worker("hresh");

    new Thread(() -> {
      for (int i = 0; i < 20000; i++) {
        work(worker);
      }
    }, "A").start();

    new Thread(() -> {
      for (int i = 0; i < 20000; i++) {
        work(worker);
      }
    }, "B").start();

    long end = System.currentTimeMillis();
    System.out.println(end - start);
    Thread.sleep(100);

    System.out.println(worker.getName() + "总共赚了" + worker.getMoney());
  }

}



结果:

52
hresh总共赚了28224.0

可以看出,上述两个线程同时修改同一个 Worker 对象的 money 数据,对于 money 字段的读写发生了竞争,导致最后结果不正确。像上述这种情况,即时编译器经过逃逸分析后认定对象发生了逃逸,那么肯定不能进行同步消除优化。

不发生逃逸的情况

//JVM参数:-Xms60M -Xmx60M  -XX:+PrintGCDetails -XX:+PrintGCDateStamps
public class SynchronizedTest {

  public static void lockTest() {
    Worker worker = new Worker();
    synchronized (worker) {
      worker.makeMoney();
    }
  }

  public static void main(String[] args) throws InterruptedException {
    long start = System.currentTimeMillis();

    new Thread(() -> {
      for (int i = 0; i < 500000; i++) {
        lockTest();
      }
    }, "A").start();

    new Thread(() -> {
      for (int i = 0; i < 500000; i++) {
        lockTest();
      }
    }, "B").start();

    long end = System.currentTimeMillis();
    System.out.println(end - start);
  }

}

结果

56
Heap
 PSYoungGen      total 17920K, used 9554K [0x00000007bec00000, 0x00000007c0000000, 0x00000007c0000000)
  eden space 15360K, 62% used [0x00000007bec00000,0x00000007bf5548a8,0x00000007bfb00000)
  from space 2560K, 0% used [0x00000007bfd80000,0x00000007bfd80000,0x00000007c0000000)
  to   space 2560K, 0% used [0x00000007bfb00000,0x00000007bfb00000,0x00000007bfd80000)
 ParOldGen       total 40960K, used 0K [0x00000007bc400000, 0x00000007bec00000, 0x00000007bec00000)
  object space 40960K, 0% used [0x00000007bc400000,0x00000007bc400000,0x00000007bec00000)
 Metaspace       used 4157K, capacity 4720K, committed 4992K, reserved 1056768K
  class space    used 467K, capacity 534K, committed 640K, reserved 1048576K

在 lockTest 方法中针对新建的 Worker 对象加锁,并没有实际意义,经过逃逸分析后认定对象未逃逸,则会进行同步消除优化。JDK8 默认开启逃逸分析,我们尝试关闭它,再看看输出结果。

-Xms60M -Xmx60M  -XX:-DoEscapeAnalysis -XX:+PrintGCDetails -XX:+PrintGCDateStamps

结果:

73
2022-03-01T14:51:08.825-0800: [GC (Allocation Failure) [PSYoungGen: 15360K->1439K(17920K)] 15360K->1447K(58880K), 0.0018940 secs] [Times: user=0.01 sys=0.00, real=0.00 secs] 
Heap
 PSYoungGen      total 17920K, used 16340K [0x00000007bec00000, 0x00000007c0000000, 0x00000007c0000000)
  eden space 15360K, 97% used [0x00000007bec00000,0x00000007bfa8d210,0x00000007bfb00000)
  from space 2560K, 56% used [0x00000007bfb00000,0x00000007bfc67f00,0x00000007bfd80000)
  to   space 2560K, 0% used [0x00000007bfd80000,0x00000007bfd80000,0x00000007c0000000)
 ParOldGen       total 40960K, used 8K [0x00000007bc400000, 0x00000007bec00000, 0x00000007bec00000)
  object space 40960K, 0% used [0x00000007bc400000,0x00000007bc402000,0x00000007bec00000)
 Metaspace       used 4153K, capacity 4688K, committed 4864K, reserved 1056768K
  class space    used 466K, capacity 502K, committed 512K, reserved 1048576K

经过对比发现,关闭逃逸分析后,执行时间变长,且内存占用变大,同时发生了垃圾回收。

不过,基于逃逸分析的锁消除实际上并不多见。一般来说,开发人员不会直接对方法中新构造的对象进行加锁,如上述案例所示,lockTest 方法中的加锁操作没什么意义。

事实上,逃逸分析的结果更多被用于将新建对象操作转换成栈上分配或者标量替换。

标量替换

在讲解 Java 对象的内存布局时提到过,Java 虚拟机中对象都是在堆上分配的,而堆上的内容对任何线程大都是可见的(除开 TLAB)。与此同时,Java 虚拟机需要对所分配的堆内存进行管理,并且在对象不再被引用时回收其所占据的内存。

如果逃逸分析能够证明某些新建的对象不逃逸,那么 Java 虚拟机完全可以将其分配至栈上,并且在 new 语句所在的方法退出时,通过弹出当前方法的栈桢来自动回收所分配的内存空间。这样一来,我们便无须借助垃圾回收器来处理不再被引用的对象。

但是目前 Hotspot 并没有实现真正意义上的栈上分配,而是使用了标量替换这么一项技术。

所谓的标量,就是仅能存储一个值的变量,比如 Java 代码中的局部变量。与之相反,聚合量则可能同时存储多个值,其中一个典型的例子便是 Java 对象。

若一个数据已经无法再分解成更小的数据来表示了,Java虚拟机中的原始数据类型(int、long等数值类型及reference类型等)都不能再进一步分解了,那么这些数据就可以被称为标量。相对的,如果一个数据可以继续分解, 那它就被称为聚合量(Aggregate),Java中的对象就是典型的聚合量。

标量替换这项优化技术,可以看成将原本对对象的字段的访问,替换为一个个局部变量的访问。

如下述案例所示:

public class ScalarTest {

  public static double getMoney() {
    Worker worker = new Worker();
    worker.setMoney(100.0);
    return worker.getMoney() + 20;
  }

  public static void main(String[] args) {
    getMoney();
  }

}

经过逃逸分析,Worker 对象未逃逸出 getMoney()的调用,因此可以对聚合量 worker 进行分解,得到局部变量 money,进行标量替换后的伪代码:

public class ScalarTest {

  public static double getMoney() {
    double money = 100.0;
    return money + 20;
  }

  public static void main(String[] args) {
    getMoney();
  }

}

对象拆分后,对象的成员变量改为方法的局部变量,这些字段既可以存储在栈上,也可以直接存储在寄存器中。标量替换因为不必创建对象,减轻了垃圾回收的压力。

另外,可以手动通过-XX:+EliminateAllocations可以开启标量替换(默认是开启的),-XX:+PrintEliminateAllocations(同样需要debug版本的JDK)查看标量替换情况。

栈上分配

故名思议就是在栈上分配对象,其实目前 Hotspot 并没有实现真正意义上的栈上分配,实际上是标量替换。

在一般情况下,对象和数组元素的内存分配是在堆内存上进行的。但是随着 JIT 编译器的日渐成熟,很多优化使这种分配策略并不绝对。JIT编译器就可以在编译期间根据逃逸分析的结果,来决定是否需要创建对象,是否可以将堆内存分配转换为栈内存分配。
————————————————
原文链接:https://blog.csdn.net/Herishwater/article/details/123780967

posted @ 2023-05-13 20:22  Bepowerful  阅读(95)  评论(0)    收藏  举报