科普文:一文搞懂jvm实战(四)深入理解逃逸分析Escape Analysis

概叙

Java 中的对象是否都分配在堆内存中?

好了太抽象了,那具体一点,看看下面这个对象是在哪里分配内存?

java 复制代码
public void test() { 
    Object object = new Object(); 
 }
    这个方法中的object对象,是在堆中分配内存么?

说结果:object可能在栈上分配内存,也可能在堆上分配内存。

重点来了:在JVM的实现中,为了提高JVM的性能和节省内存空间,JVM提供了一种叫做 "逃逸分析" 的特性,逃逸分析是目前Java虚拟机中比较前沿的优化技术,也是JIT中一个很重要的优化技术。 jdk6才开始引入该技术,jdk7开始默认开启逃逸分析,jdk8开始完善逃逸分析,并默认开启,直到 JDK 9 ,逃逸分析将作为默认的优化手段,不再需要特别的编译参数。

现在理解"object可能在栈上分配内存,也可能在堆上分配内存" 这句话,jdk7之前这里的object肯定是在堆上分配内存;jdk7、8则有可能在栈上分配,因为jdk7才开始支持逃逸分析;jdk9则是大概率是在栈上分配(这里的 object对象很小),因为jdk9才真正支持和默认开启逃逸分析。

随着JIT编译器(即时编译器)的发展与逃逸分析技术的逐渐成熟,栈上分配、标量替换优化技术将会导致"所有的对象都会分配到堆上"变的不那么绝对了. 在Java虚拟机中,对象是在堆中分配内存的,但是有一种特殊情况,那就是如果经过逃逸分析后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配.当方法执行完毕,栈帧弹出,对象就被释放了.这样就无需在堆上分配内存和经历垃圾回收了(Hotspot目前并未这样做).这也是最常见的堆外存储技术.

在JDK 6u23(可记大版本JDK7)后,Hotspot中默认就已经开启了逃逸分析.如果使用较早版本可以通过选项"-XX:+DoEscapeAnalysis"显示开启逃逸分析.通过"-XX:+PrintEscapeAnalysis"查看逃逸分析的筛选结果.

Hotspot通过逃逸分析实现了标量替换(未逃逸的对象被替换为标量和聚合量,能提升代码效率,),但是未逃逸的对象仍然会在堆上分配内存,所以仍然可以说所有的对象都是在堆上分配内存.

此外,基于Open JDK深度定制的TaoBao VM,其中创新的GCIH(GC invisible heap)技术实现off-heap,将生命周期较长的对象从heap中移致heap外,并且GC不管理GCIH内部的Java对象,以此达到降低GC回收频率和提升GC回收效率的目的.

**栈:**每个方法被执行的时候都会同时创建一个栈帧用于存储局部变量表、操作栈、动态连接、方法出口等信息。每个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。

**堆:**当实例化对象时,会把对象分配到堆中,然后把指向该堆的引用压入栈中。

**逃逸:**当一个对象的指针被多个方法或线程引用时,我们称这个指针发生了逃逸,一般情况返回对象、对全局变量的肤质一般都会发生逃逸。

**逃逸分析:**用来分析这种逃逸现象的方法称为逃逸分析

**逃逸分析优化-栈上分配:**栈上分配的意思是方法内局部变量(未发生逃逸)生成的实例在栈上分配,不用在堆中分配,分配完成后,继续在调用栈内执行,最后线程结束,栈空间被回收,局部变量对象也被回收。

java对象内存分配过程

  1. 如果开启栈上分配(逃逸分析),JVM会先进行栈上分配
  2. 如果没有开启栈上分配或不符合条件,则会进入TLAB分配
  3. 如果TLAB分配不成功或者不符合,则判断是否可以进入年老代分配
  4. 如果不能进入年老代,则进入eden分配
  5. 并不是所有对象都分配在堆上,除了堆意外,还可以将对象分配到栈和TLAB中。(大多数的对象都分配在堆中)

Java中的对象一定是在堆上分配的吗?

答:不一定。

如果满足了逃逸分析的条件,一个对象,完全可以在栈上分配。减少堆内存的分配和GC压力。 由于栈内存有限,所以, 如果对象符合标量替换的条件,进一步为对象来一次化整为零的手术。 标量替换具体的做法是:JVM会将对象进一步打散,将对象分解为若干个被这个方法使用的成员变量,从而,达到更好的利用栈内存和寄存器的目标。

如何将堆上的对象分配到栈,需要使用逃逸分析手段。

这是一种可以有效减少Java程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。通过逃逸分析,Java Hotspot编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。

逃逸分析的基本行为就是分析对象动态作用域:

  1. 当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸。
  2. 当一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸。例如作为调用参数传递到其他地方中。

在计算机语言编译器优化原理中,逃逸分析是指分析指针动态范围的方法,它同编译器优化原理的指针分析和外形分析相关联。当变量(或者对象)在方法中分配后,其指针有可能被返回或者被全局引用,这样就会被其他方法或者线程所引用,这种现象称作指针(或者引用)的逃逸(Escape)。通俗点讲,如果一个对象的指针被多个方法或者线程引用时,那么我们就称这个对象的指针(或对象)逃逸(Escape)了(因为此时,对象逃出了方法或线程的,局部作用域)。

什么是逃逸分析 ?

简要说明:"逃逸分析:一种确定指针动态范围的静态分析,它可以分析在程序的哪些地方可以访问到指针。" 在JVM的即时编译语境下,逃逸分析将判断新建的对象是否逃逸。

即时编译判断对象是否逃逸的依据:一种是对象是否被存入堆中(静态字段或者堆中对象的实例字段),另一种就是对象是否被传入未知代码。

逃逸分析(Escape Analysis)是目前Java虚拟机中比较前沿的优化技术,它与类型继承关系分析一样,并不是直接优化代码的手段,而是为其他优化手段提供依据的分析技术。

逃逸分析Escape Analysis:是一个很重要的JIT优化技术,用于判断对象是否会在方法外部被访问到,也就是逃出方法的作用域。逃逸分析是JIT编译器的一个步骤,通过JIT我们能够确定哪些对象可以被限制在方法内部使用,不会逃逸到外部,然后可以对它们进行优化,比如把它们分配在栈上而不是堆上,或者进行标量替换,把一个对象拆散成多个基本类型来存储。是一种可以有效减少Java程序中同步负载和内存堆分配和垃圾回收压力的跨函数全局数据流分析算法。通过逃逸分析,Java Hotspot编译器能够分析出一个新对象的引用的使用范围,而决定是否要将这个对象分配到堆上。

逃逸分析主要针对局部变量,判断堆上分配的对象是否逃逸出方法的作用域。它同编译器优化原理的指针分析和外形分析相关联。当变量(或者对象)在方法中分配后,其指针有可能被返回或者被全局引用,这样就会被其他方法或者线程所引用,这种现象称作指针(或者引用)的逃逸Escape。通俗点讲,如果一个对象的指针被多个方法或者线程引用时,那么我们就称这个对象的指针发生了逃逸。合理地设计代码结构和数据的使用方式能够更好地利用逃逸分析来优化程序的性能。我们还可以通过逃逸分析减少堆上分配对象的开销,提高内存利用率。

逃逸分析是一种用于确定对象在方法的生命周期内是否逃逸出方法外部范围的技术。在Java开发中,逃逸分析用于确定对象的生命周期和作用域,以便进行相应的优化,提高程序的性能和内存利用效率。

当一个对象被创建后,它可以在方法内部使用,也可以被传递给其他方法或线程,并在方法外部继续存在。如果对象没有逃逸出方法的作用域,那么JVM可以将其分配在栈上而不是堆上,从而避免了堆内存的分配和垃圾回收的开销。

java 复制代码
    关于逃逸分析的论文在1999年就已经发表,但直到Sun JDK 1.6才实现了逃逸分析,而且直到现在这项优化尚未足够成熟,仍有很大的改进余地。不成熟的原因主要是不能保证逃逸分析的性能收益必定高于它的消耗。如果要完全准确的判断一个对象是否会逃逸,需要进行数据流敏感的一系列复杂分析,从而确定程序各个分支执行时对此对象的影响。这是一个相对高耗时的过程,如果分析完后发现没有几个不逃逸的对象,那这些运行期耗用的时间就白白浪费了,所以目前虚拟机只能采用不那么准确,但时间压力相对较小的算法来完成逃逸分析。还有一点是,基于逃逸分析的一些优化手段,如上面提到的"栈上分配",由于HotSpot虚拟机目前的实现方式导致栈上分配实现起来比较复杂,因此在HotSpot中暂时还没有做这项优化。
    在测试结果中,实施逃逸分析后的程序在MicroBenchmarks中往往能运行出不错的成绩,但是在实际的应用程序,尤其是大型程序中反而发现实施逃逸分析可能出现效果不稳定的情况,或因分析过程耗时但却无法有效判别出非逃逸对象而导致性能(即使编译的收益)有所下降,所以在很长的一段时间里,即使是Server Compiler,也默认不开启逃逸分析(在JDK 1.6 Update 23的Server Compiler中才开始默认开启了逃逸分析),甚至在某些版本(如JDK 1.6 Update 18)中还曾经短暂的完全禁止了这项优化。
    如果有需要,并且确认对程序运行有益,用户可以使用参数-XX:+DoEscapeAnalysis来手动开启逃逸分析,开启之后可以通过参数-XX:+PrintEscapeAnalysis来查看分析结果。有了逃逸分析支持之后,用户可以使用参数-XX:EliminateAllocations来开启标量替换,使用+XX:+EliminateLocks来开启同步消除,使用参数-XX:PrintEliminateAllocations查看标量的替换情况。
    尽管目前逃逸分析的技术仍不是十分成熟,但是他却是即时编译器优化技术的一个重要的方向,在今后的虚拟机中,逃逸分析技术肯定会支撑起一系列使用有效的优化技术。

逃逸分析的基本原理

JVM逃逸分析的基本原理是通过静态和动态两种分析方法来确定对象的逃逸情况。

在Java的编译体系中,一个Java的源代码文件变成计算机可执行的机器指令的过程中,需要经过两段编译:

第一段编译,指前端编译器把**.java文件** 转换成 .class文件(字节码文件)。前端编译器产品可以是JDK的Javac、Eclipse JDT中的增量式编译器。

第二编译阶段,JVM 通过解释字节码将其翻译成对应的机器指令,逐条读入字节码,逐条解释翻译成机器码。

很显然,由于有一个解释的中间过程,其执行速度必然会比可执行的二进制字节码程序慢很多。这就是传统的JVM的解释器(Interpreter)的功能。

如何去掉中间商,提升效率?

为了解决这种效率问题,引入了JIT(即时编译器,Just In Time Compiler)技术。

引入了 JIT 技术后,Java程序还是通过解释器进行解释执行,也就是说,主体还是解释执行,只是局部去掉中间环节。

JIT Compiler(Just-in-timeCompiler) 即时编译。最早的Java建置方案是由一套转译程式(interpreter),将每个Java指令都转译成对等的微处理器指令,并根据转译后的指令先后次序依序执行,由于一个Java指令可能被转译成十几或数十几个对等的微处理器指令,这种模式执行的速度相当缓慢。

怎么做局部去掉中间环节呢?

当JVM发现某个方法或代码块运行特别频繁的时候,就会认为这是"热点代码"(Hot Spot Code)。然后JIT会把部分"热点代码"翻译成本地机器相关的机器码,并进行优化,然后再把翻译后的机器码缓存起来,以备下次使用。

把翻译后的机器码缓存在哪里呢? 这个 缓存,叫做 Code Cache。 可见,JVM和WEB应用实现高并发的手段是类似的,还是使用了缓存架构。

当JVM下次遇到相同的热点代码时,跳过解释的中间环节,直接从 Code Cache加载机器码,直接执行,无需 再编译。

所以,JIT总的目标是发现热点代码, 热点代码变成了提升性能的关键,hotspot JVM的名字,也就是这么来的,把识别热点代码,写在名字上,作为毕生的追求。

所以,JVM总的策略为:

  • 对于占据大部分的不常用的代码,我们无需耗费时间将其编译成机器码,而是采取解释执行的方式运行;

  • 另一方面,对于仅占据小部分的热点代码,我们则可以将其编译成机器码,以达到理想的运行速度。

JIT(即时编译)的出现与 解释器的区别

(1)解释器是将字节码解释为机器码,下次即使遇到相同的字节码,仍会执行重复的解释。

(2)JIT 是将一些字节码编译为机器码,并存入 Code Cache,下次遇到相同的代码,直接执行,无需 再编译。

(3)解释器是将字节码解释为针对所有平台都通用的机器码。

(4)JIT 会根据平台类型,生成平台特定的机器码。

JVM包含多个即时编译器,主要有C1和C2,还有个Graal (实验性的)。

多个即时编译器, 都会对字节码进行优化并生成机器码

  • C1会对字节码进行简单可靠的优化,包括方法内联、去虚拟化、冗余消除等,编译速度较快,可以通过-client强制指定C1编译
  • C2会对字节码进行激进优化,包括分支频率预测、同步擦除等,可以通过-server强制指定C2编译

JVM 将执行状态分成了 5 个层次:

  • 0 层,解释执行(Interpreter)

  • 1 层,使用 C1 即时编译器编译执行(不带 profiling)

  • 2 层,使用 C1 即时编译器编译执行(带基本的 profiling)

  • 3 层,使用 C1 即时编译器编译执行(带完全的 profiling)

  • 4 层,使用 C2 即时编译器编译执行

JVM不会直接启用C2,而是先通过C1编译收集程序的运行状态,再根据分析结果判断是否启用C2。

分层编译模式下, 虚拟机执行状态由简到繁、由快到慢分为5层

在编译期间,JIT 除了对 热点代码做缓存提速,会对代码做很多优化。

其中有一部分优化的目的就是减少内存堆分配压力,其中JIT优化中一种重要的技术叫做逃逸分析。根据逃逸分析,即时编译器会在编译过程中对代码做如下优化:

  • 锁消除:当一个锁对象只被一个线程加锁时,即时编译器会把锁去掉
  • 栈上分配:当一个对象没有逃逸时,会将对象直接分配在栈上,随着线程回收,由于JVM的大量代码都是堆分配,所以目前JVM不支持栈上分配,而是采用标量替换
  • 标量替换:当一个对象没有逃逸时,会将当前对象打散成若干局部变量,并分配在虚拟机栈的局部变量表中

1. 静态分析是在编译时进行的分析

它通过对代码的静态结构进行检查,确定对象是否可能逃逸。例如,当一个对象被赋值给类的成员变量或返回给外部方法时,可以确定该对象逃逸。

2. 动态分析是在运行时进行的分析

它通过观察方法调用和对象引用的行为来确定对象是否逃逸。例如,当一个对象被多个线程引用时,可以判断该对象逃逸。

逃逸分析会对代码进行深度分析,以确定对象在方法的生命周期内是否逃逸出方法外部范围。如果对象没有逃逸,JVM可以将其分配在栈上,而不是堆上。

逃逸状态:全局逃逸、参数逃逸、没有逃逸

一个对象有三种逃逸状态:全局逃逸、参数逃逸、没有逃逸。

全局逃逸(GlobalEscape):即一个对象的作用范围逃出了当前方法或者当前线程,

一般有以下几种场景:

① 对象是一个静态变量

② 对象是一个已经发生逃逸的对象

③ 对象作为当前方法的返回值

参数逃逸(ArgEscape):即一个对象被作为方法参数传递或者被参数引用,但在调用过程中不会发生全局逃逸,这个状态是通过被调方法的字节码确定的。

没有逃逸:即方法中的对象没有发生逃逸。

逃逸状态示例代码如下:

public class EscapeAnalysisTest {

    public static Object globalVariableObject;

    public Object instanceObject;

    public void globalVariableEscape(){
        globalVariableObject = new Object();  // 静态变量,外部线程可见,发生逃逸
    }

    public void instanceObjectEscape(){
        instanceObject = new Object();  // 赋值给堆中实例字段,外部线程可见,发生逃逸
    }
    
    public Object returnObjectEscape(){
        return new Object();   // 返回实例,外部线程可见,发生逃逸
    }

    public void noEscape(){
        Object noEscape = new Object();   // 仅创建线程可见,对象无逃逸
    }

}

逃逸的方式:方法逃逸和线程逃逸

1.方法逃逸:在一个方法体内,定义一个局部变量,而它可能被外部方法引用,比如作为调用参数传递给方法,或作为对象直接返回。或者,可以理解成对象跳出了方法。

方法逃逸包括:

  • 通过调用参数,将对象地址传递到其他方法中,
  • 对象通过return语句将对象指针,返回给其他方法
  • 等等
java 复制代码
我们可以用下面的代码来表示这个现象。

//StringBuffer对象发生了方法逃逸
public static StringBuffer createStringBuffer(String s1, String s2) {
    StringBuffer sb = new StringBuffer();
    sb.append(s1);
    sb.append(s2);
    return sb;
}
上面的例子中,StringBuffer 对象通过return语句返回。

StringBuffer sb是一个方法内部变量,上述代码中直接将sb返回,这样这个StringBuffer有可能被其他方法所改变,这样它的作用域就不只是在方法内部,虽然它是一个局部变量,称其逃逸到了方法外部。

甚至还有可能被外部线程访问到,譬如赋值给类变量或可以在其他线程中访问的实例变量,称为线程逃逸。

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

具体的代码如下:

// 非方法逃逸
public static String createString(String s1, String s2) {
    StringBuffer sb = new StringBuffer();
    sb.append(s1);
    sb.append(s2);
    return sb.toString();
}
可以看出,想要逃逸方法的话,需要让对象本身被外部调用,或者说, 对象的指针,传递到了 方法之外。

如何快速的判断是否发生了逃逸分析,大家就看new的对象实体是否在方法外被调用。

java 复制代码
public class EscapeAnalysis {
 
    public EscapeAnalysis obj;
 
    /**
     * 方法返回EscapeAnalysis对象,发生逃逸
     * @return
     */
    public EscapeAnalysis getInstance() {
        return obj == null ? new EscapeAnalysis():obj;
    }
 
    /**
     * 为成员属性赋值,发生逃逸
     */
    public void setObj() {
        this.obj = new EscapeAnalysis();
    }
 
    /**
     * 对象的作用于仅在当前方法中有效,没有发生逃逸
     */
    public void useEscapeAnalysis() {
        EscapeAnalysis e = new EscapeAnalysis();
    }
 
    /**
     * 引用成员变量的值,发生逃逸
     */
    public void useEscapeAnalysis2() {
        EscapeAnalysis e = getInstance();
    }
}

2.线程逃逸:这个对象被其他线程访问到,比如赋值给了实例变量,并被其他线程访问到了。对象逃出了当前线程。

逃逸分析的优化策略

逃逸分析可以为Java程序带来以下优化策略:栈上分配,同步消除、标量替换、方法内联;

逃逸分析相关参数:

java 复制代码
-XX:+DoEscapeAnalysis 开启逃逸分析
-XX:+PrintEscapeAnalysis 开启逃逸分析后,可通过此参数查看分析结果。
-XX:+EliminateAllocations 开启标量替换
-XX:+EliminateLocks 开启同步消除
-XX:+PrintEliminateAllocations 开启标量替换后,查看标量替换情况。

1. 栈上分配(Stack Allocation)

逃逸分析可以确定哪些对象不会逃逸出方法的作用域,将这些对象分配在栈上而不是堆上。栈上分配的对象在方法调用生命周期内创建和销毁,无需进行垃圾回收,从而提高了程序的执行效率。

一般情况下,不会逃逸的对象所占空间比较大,如果能使用栈上的空间,那么大量的对象将随方法的结束而销毁,减轻了GC压力

栈上分配思路 栈上分配是JVM提供的一项优化技术。

其思路是:

  1. 对于线程私有的对象(不能被其它线程访问的对象),可以将它们分配到栈内存上,而不是堆内存中,也就是将聚变量进行标量替换的方案。
  2. 分配到栈上的优势是可以在方法结束后自动销毁,不需要GC介入,提供系统性能
  3. 对于大量零散的对象,栈上分配提供了一种很好的对象分配策略,栈上分配速度块,可以有效避免GC回收带来的负面影响。

问题:由于栈内存比较小,因而大对象不能也不适合进行栈上分配。

开启栈上分配

栈上分配是基于逃逸分析和标量替换的,所以必须开启逃逸分析和标量替换,当然JDK1.8是默认都是开启的。

java 复制代码
开启逃逸分析:-XX:+DoEscapeAnalysis
关闭逃逸分析:-XX:-DoEscapeAnalysis
显示分析结果:-XX:+PrintEscapeAnalysis


开启标量替换:-XX:+EliminateAllocations
关闭标量替换:-XX:-EliminateAllocations
显示标量替换详情:-XX:+PrintEliminateAllocations

栈上分配示例:

java 复制代码
示例1
import java.lang.management.ManagementFactory;
import java.util.List;
/** 
* 逃逸分析优化-栈上分配 
 * 栈上分配,意思是方法内局部变量(未发生逃逸)生成的实例在栈上分配,不用在堆中分配,分配完成后,继续在调用栈内执行,最后线程结束,栈空间被回收,局部变量对象也被回收。 
 * 一般生成的实例都是放在堆中的,然后把实例的指针或引用压入栈中。 
 *虚拟机参数设置如下,表示做了逃逸分析  消耗时间在10毫秒以下
 * -server  -Xmx10M  -Xms10M
    -XX:+DoEscapeAnalysis  -XX:+PrintGC
 *
 *虚拟机参数设置如下,表示没有做逃逸分析   消耗时间在1000毫秒以上
 * -server -Xmx10m  -Xms10m
     -XX: -DoEscapeAnalysis -XX:+PrintGC
 * @author 734621 
 * 
 */  

public class OnStack{
   public static void alloc(){
      byte[] b=new byte[2];
      b[0]=1;
   }
public static void main(String [] args){
    long b=System.currentTimeMillis();
    for(int i=0;i<100000000;i++){
       alloc();
      }
    long e=System.currentTimeMillis();
    System.out.println("消耗时间为:" + (e - b)); 
    List<String> paramters = ManagementFactory.getRuntimeMXBean().getInputArguments();  
      for(String p : paramters){  
          System.out.println(p);  
      }  
}
}


加逃逸分析的结果
[GC (Allocation Failure)  2816K->484K(9984K), 0.0013117 secs]
消耗时间为:7
-Xmx10m
-Xms10m
-XX:+DoEscapeAnalysis
-XX:+PrintGC



没有加逃逸分析的结果如下:
[GC (Allocation Failure)  3320K->504K(9984K), 0.0003174 secs]
[GC (Allocation Failure)  3320K->504K(9984K), 0.0002524 secs]
消耗时间为:1150
-Xmx10m
-Xms10m
-XX:-DoEscapeAnalysis
-XX:+PrintGC


以上测试可以看出,栈上分配可以明显提高效率: 效率是不开启的1150/7= 160倍


示例2
我们通过举例来说明 开启逃逸分析 和 未开启逃逸分析时候的情况

class User {
    private String name;
    private String age;
    private String gender;
    private String phone;
}
public class StackAllocation {
    public static void main(String[] args) throws InterruptedException {
        long start = System.currentTimeMillis();
        for (int i = 0; i < 100000000; i++) {
            alloc();
        }
        long end = System.currentTimeMillis();
        System.out.println("花费的时间为:" + (end - start) + " ms");
 
        // 为了方便查看堆内存中对象个数,线程sleep
        Thread.sleep(10000000);
    }
 
    private static void alloc() {
        // 未发生逃逸
        User user = new User(); 
    }
}
设置JVM参数,表示未开启逃逸分析
-Xmx1G -Xms1G -XX:-DoEscapeAnalysis -XX:+PrintGCDetails
花费的时间为:664 ms
然后查看内存的情况,发现有大量的User存储在堆中

开启逃逸分析
-Xmx1G -Xms1G -XX:+DoEscapeAnalysis -XX:+PrintGCDetails
然后查看运行时间,我们能够发现花费的时间快速减少,同时不会发生GC操作
花费的时间为:5 ms
在看内存情况,我们发现只有很少的User对象,说明User未发生逃逸,因为它存储在栈中,随着栈的销毁而消失。

通过对比,我们可以看到

  • 开启栈上分配,将未逃逸的对象分配在栈内存中,明显运行效率更高。
  • 关闭栈上分配后,GC频繁进行垃圾回收。

2. 同步消除(Lock Elimination)

逃逸分析可以检测到某些对象只被单个线程访问,并且不会逃逸到其他线程。因此,可以消除不必要的同步操作,减少了多线程程序的执行开销。

同步锁时非常消耗性能的,所以编译器确定一个对象没有发生逃逸时,它会移除该对象的同步锁。JDK1.8 默认开启了同步锁,但是建立在开启逃逸分析的基础上。

java 复制代码
-XX:+EliminateLocks #开启同步锁消除(JVM默认状态)
-XX:-EliminateLocks #关闭同步锁消除
java 复制代码
通过示例: 明显可以看到"逃逸分析和锁消除" 对性能的提升

    public void testLock(){
        long t1 = System.currentTimeMillis();
        for (int i = 0; i < 100_000_000; i++) {
            locketMethod();
        }
        long t2 = System.currentTimeMillis();
        System.out.println("耗时:"+(t2-t1));
    }

    public static void locketMethod(){
        EscapeAnalysis escapeAnalysis = new EscapeAnalysis();
        synchronized(escapeAnalysis) {
            escapeAnalysis.obj2="abcdefg";
        }
    }

设置JVM参数,开启逃逸分析,  耗时:
java -Xmx64m -Xms64m -XX:+DoEscapeAnalysis

设置JVM参数,关闭逃逸分析,  耗时:
java -Xmx64m -Xms64m -XX:-DoEscapeAnalysis

设置JVM参数,关闭锁消除,再次运行
java -Xmx64m -Xms15m -XX:+DoEscapeAnalysis -XX:-EliminateLocks

设置JVM参数,开启锁消除,再次运行
java -Xmx64m -Xms15m -XX:+DoEscapeAnalysis -XX:+EliminateLocks

线程同步的代价是相当高的,同步的后果是降低并发性和性能。

在动态编译同步块的时候,JIT编译器可以借助逃逸分析来判断同步块所使用的锁对象是否只能够被一个线程访问而没有被发布到其他线程。如果没有,那么JIT编译器在编译这个同步块的时候就会取消对这部分代码的同步。这样就能大大提高并发性和性能。这个取消同步的过程就叫同步省略,也叫锁消除。

java 复制代码
例如下面的代码

public void f() {
    Object hellis = new Object();
    synchronized(hellis) {
        System.out.println(hellis);
    }
}
代码中对hellis这个对象加锁,但是hellis对象的生命周期只在f()方法中,并不会被其他线程所访问到,所以在JIT编译阶段就会被优化掉,优化成:

public void f() {
    Object hellis = new Object();
    System.out.println(hellis);
}
我们将其转换成字节码,此处发现,还是有同步锁的身影,是因为优化是在编译阶段的,在加载进内存后发生。

3. 标量替换(Scalar Replacement)

逃逸分析可以将一个对象拆分成多个标量,如基本类型或其他对象,并将它们分配在不同的位置。这样可以减少内存碎片和对象访问的开销,提高内存利用效率。

首先要明白标量和聚合量,基础类型和对象的引用可以理解为标量,它们不能被进一步分解。而能被进一步分解的量就是聚合量,比如:对象。

对象是聚合量,它又可以被进一步分解成标量,将其成员变量分解为分散的变量,这就叫做标量替换。

这样,如果一个对象没有发生逃逸,那压根就不用创建它,只会在栈或者寄存器上创建它用到的成员标量,节省了内存空间,也提升了应用程序性能。

标量替换在 JDK1.8 中也是默认开启的,但是同样也要建立在已开启逃逸分析的基础之上。

标量(scalar)是指一个无法再分解成更小的数据的数据。Java中的原始数据类型就是标量。

相对的,那些还可以分解的数据叫做聚合量(Aggregate),Java中的对象就是聚合量,因为他可以分解成其他聚合量和标量。

java 复制代码
public static void main(String args[]) {
    alloc();
}
class Point {
    private int x;
    private int y;
}
private static void alloc() {
    Point point = new Point(1,2);
    System.out.println("point.x" + point.x + ";point.y" + point.y);
}
以上代码,经过标量替换后,就会变成

private static void alloc() {
    int x = 1;
    int y = 2;
    System.out.println("point.x = " + x + "; point.y=" + y);
}

在JIT阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过JIT优化,就会把这个对象拆解成若干个其中包含的若干个成员变量来代替。这个过程就是标量替换。

可以看到,Point这个聚合量经过逃逸分析后,发现他并没有逃逸,就被替换成两个标量了。那么标量替换有什么好处呢?就是可以大大减少堆内存的占用。因为一旦不需要创建对象了,那么就不再需要分配堆内存了。 标量替换为栈上分配提供了很好的基础。

逃逸分析测试

java 复制代码
逃逸分析测试
代码如下,大致思路就是 for 循环 1 亿次,循环体内调用外部的 allot() 方法,而 allot() 方法的作用就是简单创建一个对象,但是这个对象是内部的,所以是未逃逸的,所以理论上 JVM 是会进行优化的,我们拭目以待。并且我们会对比开启和关闭逃逸分析之后各自程序的运行时间:

/**
 * @ClassName: EscapeAnalysisTest
 * @Description: http://www.jetchen.cn 逃逸分析 demo
 * @Author: Jet.Chen
 * @Date: 2020/11/23 14:26
 * @Version: 1.0
 **/
public class EscapeAnalysisTest {

    public static void main(String[] args) {
        long t1 = System.currentTimeMillis();
        for (int i = 0; i < 100000000; i++) {
            allot();
        }
        long t2 = System.currentTimeMillis();
        System.out.println(t2-t1);
    }

    private static void allot() {
        Jet jet = new Jet();
    }

    static class Jet {
        public String name;
    }

}
上面就是我们进行逃逸分析测试的代码, mian() 方法末尾有一个线程暂停,目的是为了观察此时 JVM 中的内存情况。

Step 1:测试开启逃逸
由于环境是 jdk1.8,默认开启了逃逸分析,所以直接运行,得到结果如下,程序耗时 3 毫秒:


此时线程是处于睡眠状态的,我们观察下内存情况,发现堆内存中一共新建了 11 万个 Jet 对象。



Step 2:测试关闭逃逸
我们关闭逃逸分析再来运行一次(使用 java -XX:-DoEscapeAnalysis EscapeAnalysisTest 来运行代码即可),得到结果如下,程序耗时 400 毫秒:


此时我们观察下内存情况,发现堆内存中一共新建了 3 千多万个 Jet 对象。


所以,无论是从代码的执行时间(3 毫秒 VS 400 毫秒),还是从堆内存中对象的数量(11 万个 VS 3 千万个)来分析,在上述场景下,开启逃逸分析是有正向益的。

Step 3:测试标量替换
我们测试下开启和关闭 标量替换,如下图:


由上图我们可以看出,在上述极端场景下,开启和关闭标量替换对于性能的影响也是满巨大的,另外,同时也验证了标量替换功能生效的前提是逃逸分析已经开启,否则没有意义。

Step 4:测试锁消除
测试锁消除,我们需要简单调整下代码,即给 allot() 方法中的内容加锁处理,如下:

private static void allot() {
    Jet jet = new Jet();
    synchronized (jet) {
        jet.name = "jet Chen";
    }
}
然后我们运行测试代码,测试结果也很明显,在上述场景下,开启和关闭锁消除对程序性能的影响也是巨大的。


/**
 * 进行两种测试
 * 关闭逃逸分析,同时调大堆空间,避免堆内GC的发生,如果有GC信息将会被打印出来
 * VM运行参数:-Xmx4G -Xms4G -XX:-DoEscapeAnalysis -XX:+PrintGCDetails -XX:+HeapDumpOnOutOfMemoryError
 *
 * 开启逃逸分析  jdk8默认开启
 * VM运行参数:-Xmx4G -Xms4G -XX:+DoEscapeAnalysis -XX:+PrintGCDetails -XX:+HeapDumpOnOutOfMemoryError
 *
 * 执行main方法后
 * jps 查看进程
 * jmap -histo 进程ID
 *
 */
@Slf4j
public class EscapeTest {

    public static void main(String[] args) {
        long start = System.currentTimeMillis();
        for (int i = 0; i < 500000; i++) {
            alloc();
        }
        long end = System.currentTimeMillis();
        log.info("执行时间:" + (end - start) + " ms");
        try {
            Thread.sleep(Integer.MAX_VALUE);
        } catch (InterruptedException e1) {
            e1.printStackTrace();
        }
    }


    /**
     * JIT编译时会对代码进行逃逸分析
     * 并不是所有对象存放在堆区,有的一部分存在线程栈空间
     * Ponit没有逃逸
     */
    private static String alloc() {
        Point point = new Point();
        return point.toString();
    }

    /**
     *同步省略(锁消除)  JIT编译阶段优化,JIT经过逃逸分析之后发现无线程安全问题,就会做锁消除
     */
    public void append(String str1, String str2) {
        StringBuffer stringBuffer = new StringBuffer();
        stringBuffer.append(str1).append(str2);
    }

    /**
     * 标量替换
     *
     */
    private static void test2() {
        Point point = new Point(1,2);
        System.out.println("point.x="+point.getX()+"; point.y="+point.getY());

//        int x=1;
//        int y=2;
//        System.out.println("point.x="+x+"; point.y="+y);
    }


}

@Data
@AllArgsConstructor
@NoArgsConstructor
class Point{
    private int x;
    private int y;
}

4. 方法内联(Method Inlining)

逃逸分析可以确定某些方法调用不会逃逸出当前方法的作用域。因此,可以对这些方法进行内联优化,减少方法调用的开销,提高程序的执行效率。

通过这些优化策略,逃逸分析可以帮助JVM更好地优化代码,减少垃圾回收的开销,提高程序的执行效率和响应性,并减少内存的占用。

实际应用场景

逃逸分析在实际的Java应用中具有广泛的应用场景,以下是一些常见的应用场景:

  1. 对象作为方法参数传递时,逃逸分析可以确定对象是否逃逸,从而决定对象是在堆上还是栈上分配
    1. 对象作为方法返回值时,逃逸分析可以确定对象是否逃逸,从而决定对象是在堆上还是栈上分配
    1. 对象被线程共享时,逃逸分析可以确定对象是否逃逸,从而决定是否需要进行同步操作
    1. 循环中的临时对象创建时,逃逸分析可以确定对象是否逃逸,从而决定对象是否需要频繁创建和销毁。

逃逸分析的不足

关于逃逸分析的论文在1999年就已经发表了,但直到JDK1.6才有实现,而且这项技术到如今也并不是十分成熟。

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

一个极端的例子,就是经过逃逸分析之后,发现没有一个对象是不逃逸的。那这个逃逸分析的过程就白白浪费掉了。

虽然这项技术并不十分成熟,但是它也是即时编译器优化技术中一个十分重要的手段。注意到有一些观点,认为通过逃逸分析,JVM会在栈上分配那些不会逃逸的对象,这在理论上是可行的,但是取决于JvM设计者的选择。据我所知,oracle Hotspot JVM中并未这么做,这一点在逃逸分析相关的文档里已经说明,所以可以明确所有的对象实例都是创建在堆上。

目前很多书籍还是基于JDK7以前的版本,JDK已经发生了很大变化,intern字符串的缓存和静态变量曾经都被分配在永久代上,而永久代已经被元数据区取代。但是,intern字符串缓存和静态变量并不是被转移到元数据区,而是直接在堆上分配,所以这一点同样符合前面一点的结论:对象实例都是分配在堆上。上面的示例之所以加快了,是因为标量替换。

逃逸分析的好处

如果一个对象不会在方法体内,或线程内发生逃逸(或者说是通过逃逸分析后,认定其未能发生逃逸),就可以做如下优化:

栈上分配:

一般情况下,不会逃逸的对象所占空间比较大,如果能使用栈上的空间,那么大量的对象将随方法的结束而销毁,减轻了GC压力

同步消除:

如果你定义的类的方法上有同步锁,但在运行时,却只有一个线程在访问,此时逃逸分析后的机器码,会去掉同步锁运行。

标量替换:

Java虚拟机中的原始数据类型(int,long等数值类型以及reference类型等)都不能再进一步分解,它们可以称为标量。相对的,如果一个数据可以继续分解,那它称为聚合量,Java中最典型的聚合量是对象。如果逃逸分析证明一个对象不会被外部访问,并且这个对象是可分解的,那程序真正执行的时候将可能不创建这个对象,而改为直接创建它的若干个被这个方法使用到的成员变量来代替。拆散后的变量便可以被单独分析与优化,属性被扁平化后可以不用再通过引用指针来建立关系,可以连续紧凑的存储,对各种存储都更友好,执行期间能省去大量由于数据搬运造成性能损耗。同时还可以各自分别在栈帧或寄存器上分配空间,原本的对象就无需整体分配空间了。

小结

年轻代是对象的诞生、成长、消亡的区域,一个对象在这里产生、应用,最后被垃圾回收器收集、结束生命。

老年代放置长生命周期的对象,通常都是从survivor区域筛选拷贝过来的Java对象。当然,也有特殊情况,我们知道普通的对象会被分配在TLAB上;如果对象较大,JVM会试图直接分配在Eden其他位置上;如果对象太大,完全无法在新生代找到足够长的连续空闲空间,JVM就会直接分配到老年代。当GC只发生在年轻代中,回收年轻代对象的行为被称为MinorGc。

当GC发生在老年代时则被称为MajorGc或者FullGC。一般的,MinorGc的发生频率要比MajorGC高很多,即老年代中垃圾回收发生的频率将大大低于年轻代。

JVM逃逸分析通过静态和动态两种分析方法,确定对象是否可能逃逸出方法的范围。它可以帮助JVM优化代码,提高Java程序的性能和内存利用效率。

逃逸分析的优化策略包括栈上分配、同步消除、标量替换和方法内联。这些优化策略可以减少垃圾回收的开销,提高程序的执行效率和响应性,并减少内存的占用。

参考:

https://zhuanlan.zhihu.com/p/693382698

JVM-堆-逃逸分析-08-CSDN博客

JIT内存逃逸分析_java关闭标量替换-CSDN博客

查看JVM所有配置的参数值

java -XX:+PrintFlagsFinal  #输出打印所有参数jvm参数




相关推荐
程序员志哥6 小时前
JVM系列(十三) -常用调优工具介绍
jvm
后台技术汇6 小时前
JavaAgent技术应用和原理:JVM持久化监控
jvm
程序员志哥6 小时前
JVM系列(十二) -常用调优命令汇总
jvm
黄油饼卷咖喱鸡就味增汤拌孜然羊肉炒饭6 小时前
聊聊volatile的实现原理?
java·jvm·redis
_LiuYan_10 小时前
JVM执行引擎JIT深度剖析
java·jvm
王佑辉10 小时前
【jvm】内存泄漏的8种情况
jvm
工业甲酰苯胺10 小时前
JVM简介—1.Java内存区域
java·jvm·python
yuanbenshidiaos1 天前
c++---------数据类型
java·jvm·c++
java1234_小锋1 天前
JVM对象分配内存如何保证线程安全?
jvm
40岁的系统架构师1 天前
1 JVM JDK JRE之间的区别以及使用字节码的好处
java·jvm·python