逃逸分析/栈上分配/标量替换/同步消除

逃逸分析

前言

我们知道:

  • 栈中存放一些基本类型的变量数据(int/short/long/byte/float/double/Boolean/char)和对象引用。
  • 堆中主要存放对象,即通过new关键字创建的对象。

但是在《深入理解Java虚拟机》中关于Java堆内存有这样一段描述:

  • 随着JIT编译器的发展与逃逸分析技术逐渐成熟,栈上分配标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。

在编译期间,JIT 会对代码做很多优化。其中有一部分优化的目的就是减少内存堆分配压力,其中一种重要的技术叫做逃逸分析

逃逸分析(Escape Analysis)是目前Java虚拟机中比较前沿的优化技术。这是一种可以有效减少Java 程序中同步负载内存堆分配压力跨函数全局数据流分析算法。通过逃逸分析,Java Hotspot 编译器能够分析出一个新对象的引用的使用范围从而决定是否要将这个对象分配到堆上

逃逸分析的基本行为就是分析对象动态作用域:当一个对象在方法中被定义后,它有可能被外部方法所引用,例如,作为调用参数传递到其他地方中,称为方法逃逸

例如下面的方法返回值逃逸

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

StringBuffer sb是一个方法内部变量,上述代码中直接将sb返回,那么这个StringBuffer有可能被其他方法所改变,这样它的作用域就不只是在方法内部,虽然它是一个局部变量,但称其逃逸到了方法外部。甚至还有可能被外部线程访问到,譬如赋值给类变量或可以在其他线程中访问的实例变量,称为线程逃逸

上述代码如果想要StringBuffer sb不逃出方法,可以这样写:

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

不直接返回 StringBuffer,那么StringBuffer将不会逃逸出方法。

逃逸分析包括:

  • 全局变量赋值逃逸
  • 方法返回值逃逸
  • 实例引用发生逃逸
  • 线程逃逸:赋值给类变量或可以在其他线程中访问的实例变量

如下:

public class EscapeAnalysis {
    
    
 
     public static Object object;
     
     public void globalVariableEscape(){
    
       //全局变量赋值逃逸  
         object =new Object();  
      }  
     
     public Object methodEscape(){
    
        //方法返回值逃逸
         return new Object();
     }
     
     public void instancePassEscape(){
    
       //实例引用发生逃逸
        this.speak(this);
     }
     
     public void speak(EscapeAnalysis escapeAnalysis){
    
    
         System.out.println("Escape Hello");
     }
}

使用逃逸分析,编译器可以对代码做如下优化:

  1. 同步省略。如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步。
  2. 将堆分配转化为栈分配。如果一个对象在子程序中被分配,要使指向该对象的指针永远不会逃逸,对象可能是栈分配的候选,而不是堆分配。
  3. 分离对象或标量替换。有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在CPU寄存器中。

在Java代码运行时,通过JVM参数可指定是否开启逃逸分析:

  • -XX:+DoEscapeAnalysis : 表示开启逃逸分析

  • -XX:-DoEscapeAnalysis : 表示关闭逃逸分析

从 JDK 1.7 开始已经默认开启逃逸分析,如需关闭,需要指定-XX:-DoEscapeAnalysis

下面,主要来介绍逃逸分析的第二个用途:将堆分配转化为栈分配

栈上分配

我们通过JVM内存分配可以知道JAVA中的对象都是在堆上进行分配,当对象没有被引用的时候,需要依靠GC进行回收内存,如果对象数量较多的时候,会给GC带来较大压力,也间接影响了应用的性能。

为了减少临时对象在堆内分配的数量,JVM通过逃逸分析确定如果该对象不会被外部访问,那就通过标量替换将该对象在栈上分配内存,这样该对象所占用的内存空间就可以随栈帧出栈而销毁,就减轻了垃圾回收的压力。

标量替换

标量(scalar)和聚合量(aggregate)

标量即不可被进一步分解的量,而JAVA的基本数据类型就是标量(如:int,long等基本数据类型以及reference类型等)。

标量的对立面就是可以被进一步分解的量,而这种量称之为聚合量。在JAVA中,对象就是可以被进一步分解的聚合量。

标量替换的过程

通过逃逸分析确定该对象不会被外部访问,并且对象可以被进一步分解时,JVM 则不会创建该对象,而是会将该对象的成员变量分解成若干个被这个方法使用的成员变量所代替。这些代替的成员变量在栈帧寄存器上分配空间。

如果把一个Java对象拆散,将其成员变量恢复为分散的变量,这就叫做标量替换。拆散后的变量便可以被单独分析与优化,可以分别在活动记录(栈帧或寄存器)上分配空间,原本的对象就无需整体分配空间了。

同步消除

同步消除是 Java 虚拟机提供的一种优化技术。通过逃逸分析,可以确定一个对象是否会被其他线程所访问。

如果一个类的方法上有同步锁,但在运行时,却只有一个线程在访问,此时逃逸分析后的机器码,会去掉同步锁运行,这就是没有出现线程逃逸的情况。那么该对象的读写就不会存在资源的竞争,则可以消除对该对象的同步锁。

通过-XX:+EliminateLocks可以开启同步消除,进行测试执行的效率

public static void main(String[] args) {
    
    
    long start = System.currentTimeMillis();
    EscapeAnalysis escapeAnalysis = new EscapeAnalysis();
    for (int i = 0; i < 1000000; i++) {
    
    
      	escapeAnalysis.createString("Escape", "Hello");
    }
    long bufferCost = System.currentTimeMillis() - start;
    System.out.println("craeteString: " + bufferCost + " ms");
}

public String createString(String ... values){
    
    
    StringBuffer stringBuffer = new StringBuffer(); 
    for (String str : values) {
    
    
      	stringBuffer.append(str + " ");
    }
    return stringBuffer.toString();
}   
-server -XX:+DoEscapeAnalysis -XX:-EliminateLocks
craeteString: 202 ms
 
-server -XX:+DoEscapeAnalysis -XX:+EliminateLocks
craeteString: 173 ms

通过测试结果可以看出,如果开启了同步消除,在开启同步消除的执行效率比没有开启同步消除的高。

猜你喜欢

转载自blog.csdn.net/weixin_44471490/article/details/111502696