详解逃逸分析标量替换栈上分配

古人学问无遗力,少壮工夫老始成。这篇文章主要讲述详解逃逸分析标量替换栈上分配相关的知识,希望能为你提供帮助。
我们都知道,以编译方式执行本地代码比解释执行方式更快,一方面是因为节约了虚拟机解释执行字节码额外消耗的时间;另一方面是因为虚拟机设计团队几乎把所有对代码的优化措施都集中到了即时编译器中。本课时我们来介绍下 HotSpot 虚拟机的即时编译器在编译代码时采用的优化技术,即逃逸分析、标量替换、栈上分配。

逃逸分析

我们先来详细探讨一下逃逸分析,然后再来探讨编译器优化的两大措施,即标量替换以及栈上分配。你应该知道变量替换和栈上分配都是基于逃逸分析去做的。

先来探讨逃逸分析,逃逸分析不是直接优化代码的手段,而是为其它优化手段提供依据的分析技术。逃逸分析的基本行为就是分析对象的动态作用域:当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其它方法中,称为方法逃逸。甚至还有可能被外部线程访问到,例如赋值给类变量或可以在其他线程中访问的实例变量,称为线程逃逸。简单的说,逃逸分析指的是分析变量能不能逃出它的作用域。

如果能证明一个对象不会逃逸到方法或者线程之外,也就是别的方法和线程无法通过任何途径访问到这个方法,则可能为这个变量进行一些高效优化。

逃逸分析可以细分为四种场景:

第一:全局变量赋值逃逸。
第二:方法返回至逃逸。
第三:实例引用逃逸。
第四:线程逃逸。

这样讲比较的抽象,我们来看一段代码示例。创建一个 SomeClass 类。

public class SomeClass { public void printClassName(EscapeDemo1 escapeDemo1){ System.out.println(escapeDemo1.getClass().getName()); } }


public staticSomeClass someClass; //全局变量赋值逃逸 public void globalVariablePointerEscape(){ someClass=new SomeClass(); }


globalVariablePointerEscape 方法里面,我们把一个局部变量复制给了一个静态变量,局部变量的作用域是在方法内部。类变量的作用域是在类里面,所以作用域被放大了,显然发生了逃逸。

//方法返回值逃逸 public void someMethod(){ SomeClass someClass=methodPointerEscape(); } public SomeClass methodPointerEscape(){ return new SomeClass(); }


methodPointerEscape 方法里面,我们返回了一个对象,这个对象的作用域一开始也能在方法内部的。但是我们作为返回值返回了,那么这个时候假设有另外一个方法,比如这里的 someMethod 的方法调用了这里的 methodPointerEscape 方法。那么这个 someClass 的作用域是在 someMethod 方法里面,所以 someClass 这个变量的作用域就是在methodPointerEscape 扩张到了 someMethod 的方法。所以也发生了逃逸。

//实例引用传递逃逸 public void instancePassPointerEscape(){ this.methodPointerEscape().printClassName(this); } }


instancePassPointerEscape 方法中,this 传给了下面的 printClassName 方法。this 的作用域原先是在当前实例下的,但是现在扩张到了 SomeClass 的实例下面去了,所以也发生了逃逸。

另外我们这里还有一个线程逃逸,没有去做代码示例。线程逃逸其实比较好总结。当赋值给类变量或者赋值给其他线程里面可以访问的实例变量就会发生现身逃逸。

以上是对象逃逸的四种场景,JVM 在做逃逸分析的时候,会针对这些场景进行分析,分析完成之后,会为对象做一个逃逸状态标记?一个对象主要有三种逃逸状态标记:

第一态:全局级别逃逸,它表示一个对象可能从一个方法或者对象里面逃逸。也就是说,其他的方法或者其他的线程也能够访问到这个对象。那么主要有以下几种场景,第一,对象作为方法的返回值返回。第二,对象作为静态字段(static field)的或者成员变量(field)。可以对应到我们前面说到的全局变量赋值逃逸和方法返回值逃逸两种场景。第三,如果重写了某一个类的 finalize 方法,那么这个类的变量都会被标记为全局逃逸状态,并且一定会放在堆内存里面。

第二:参数级别逃逸,如果一个对象作为参数传给一个方法,但是处这个方法以外,其他的方法不能访问这个对象,其他的线程也不能访问这个对象。那么就说明是参数级别逃逸,像我们前面介绍的实力引用传递就是参数级别逃逸。
【详解逃逸分析标量替换栈上分配】
第三::无逃逸状态,指的是一个对象不会逃逸。

标量替换

好,了解逃逸分析之后,再来聊聊标量替换。

首先什么是标量,所谓的标量指的是不能进一步分解的量。像 java 的基础数据类型(int、long等数值类型以及 reference 类型等)以及对象的地址引用都是标量,因为它们是没有办法继续分解的。与标量对应的是聚合量,聚合量指的是可以进一步分解的量,比如字符串就是一个聚合量,因为字符串是用字节数组实现的,可以分解。又比如我们自己定义的变量也都是聚合量。

那么什么是标量替换呢?根据程序访问的情况,将其使用到的成员变量恢复原始类型来访问就叫做标量替换。如果逃逸分析证明一个对象不会被外部访问,并且这个对象可以被拆散的话,那程序真正执行的时候将可能不创建这个对象,而改为直接创建它的若干个被这个方法使用到的成员变景来代替。将对象拆分后,除了可以让对象的成员变量在栈上(栈上存储的数据,很大机会会被虚拟机分配至物理机器的高速寄存器中存储)分配和读写之外,还可以为后续进一步的优化手段创建条件。

举个例子,假设有一个 Person 类。然后在这个类里面有一些成员变量,这些成员变量都是基础类型的,也就是说是标量。

public void personTest(){ Person person=new Person(); person.id=01; person.age=22; }class Person{ int id; int age; }


如果开启了标量替换,并不会直接创建 personTest 的这个实例,而是创建 personTest 成员变量去代替。也就是说开启变量替换之后,原先的代码就被优化。

public void personTest(){ Person person=new Person(); person.id=01; person.age=22; //开启标量替换后 int id =01; int age=22; }


那么把对象进行标量替换之后,原本的对象就不需要分配内存空间了。可以使用这个参数:-XXEliminateAllocations 开启标量替换,JDK 8 默认就是开启的。

那么标量替换有什么好处呢?就是可以大大减少堆内存的占用。因为一旦不需要创建对象了,那么就不再需要分配堆内存了。

栈上分配

好,下面再来聊聊栈上分配。我们知道 Java 虚拟机中,绝大多数对象都是存放在堆里面的,几乎是 Java 程序员都清楚的常识了,Java 堆中的对象对于各个线程都是共享和可见的,只要持有这个对象的引用,就可以访问堆中存储的对象数据。虚拟机的垃圾收集系统可以回收掉堆中不再使用的对象,但回收动作无论是筛选可回收对象,还是回收和整理内存都需要耗费时间。

但是,有一种特殊情况,那就是如果经过逃逸分析后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配,这样就无需在堆上分配内存,也无须进行垃圾回收了。

那么什么是栈上分配呢?指的是如果通过逃逸分析确认对象不会被外部访问到的话。那么就直接在栈上分配对象,那么在栈上分配对象的话,这个对象占用的空间就会在战争出站的时候被销毁了,所以通过栈上分配可以降低垃圾回收的压力。

同步消除

好,最后我们聊聊同步消除。如果逃逸分析能确定一个变量不会逃逸出线程,无法被其它线程访问,那这个变量的读写就不会有多线程竞争的问题,因而变量的同步措施也就可以消除了。

总结

好,简单总结一下本课时的内容。这课时首先看到了什么是逃逸分析。如果经过逃逸分析发现变量不会被外部访问到的话,那么会有两种优化。

一是标量替换,变量替换可以把聚合量用若干个标量代替,从而节省内存。

二是栈上分配,直接在栈上分配这个对象,这样可以降低垃圾回收的压力。

本课时相关的 JVM 参数如下:

参数 默认值(JDK)
-XX:+DoEscapeAnalysis 开启 是否开启逃逸分析
-XX:+EliminateAllocations 开启 是否开启标量替换
-XX:+EliminateLocks 开启 是否开启锁消除
-XX:+PrintEscapeAnalysis 开启 开启逃逸分析后,可通过此参数查看分析结果。
-XX:+PrintEliminateAllocations 开启 开启标量替换后,查看标量替换情况。


不要忘记基于逃逸分析可以实现所消除,那么所消除也可以认为是即时编译器的一种优化机制。有关所消除。

    推荐阅读