图灵架构师vip课程二三四五期--逃逸分析--百度云

【微信642620018,获取全套课程】
引言

面试问题:实例对象存储在内存中的哪里?
完整正确的回答:实例对象内存存储在堆中,实例对象的引用存储在线程栈中,实例对象的类元信息和静态变量存储在方法区(也叫元空间)

那实例对象内存都是存放在堆中的吗?
答案是不一定,因为JIT会对代码进行逃逸分析,对代码进行优化,有逃逸行为的对象会存放在堆中,没有逃逸行为的对象可能存在堆中也可能存在线程栈中

在《深入理解Java虚拟机中》关于Java堆内存有这样一段描述:
但是,随着JIT编译期的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。
具体对JIT相关的讲解,可以参见另一个篇笔记《JIT即时编译器》,这里简单讲述一下:
Java程序通过解释器进行解释执行时,JVM会对方法和代码块的调用次数进行统计,对调用次数较高的方法或者代码块(专业名词叫做“热点代码”)会交由JIT进行即时编译成对应的机器指令,在这个过程中还会对代码进行优化,逃逸分析就是其中的优化之一,最后将编译之后的机器指令缓存起来,以备后面使用。

逃逸分析

逃逸分析是目前Java虚拟机中比较前沿的优化技术,这是一种可以有效减少Java程序中同步负载和堆内存分配压力的跨函数全局数据流分析算法。通过逃逸分析,Java虚拟机能够分析出一个新对象的引用使用范围,从而决定是否一定要将此对象分配在堆内存中。
逃逸分析的基本行为就是分析对象动态作用域:一个创建在方法中的对象,可能跟随着方法的返回被外部方法所引用,也可能跟随方法的参数传递至下一个方法调用,以上两种情况都称之为对象逃逸。
根据作用域可分为下面三种情况:
GlobalEscape(全局逃逸):一个对象的引用逃出了方法或者线程。例如:对象的引用赋值给类变量或者静态变量,对象跟随者方法返回至另一个方法的变量中,或者存储在一个已经逃逸的对象当中;
ArgEscape(参数级逃逸):在方法调用过程中,对象的引用被通过方法的参数传递至下一个方法中使用。 这种状态可以通过分析被调方法的二进制代码确定;
NoEscape(没有逃逸):一个可以进行标量替换的对象,或者对象的作用域范围就只在本方法中,随着方法栈帧的进栈而生,出栈而亡。该对象可以不被分配在传统的堆上。

下面我们通过一个例子来看逃逸行为:

public void buildPubFulltext(PubDetailVO pubDetailVO,  Long pubId, Long reqPsnId) {
    PubFulltextDTO fullText = new PubFulltextDTO();
    PubFullTextPO fullTextPO =  pubFullTextService.get(pubId);
    if (fullTextPO != null) {
      ...
      fullText.setFileName(fullTextPO.getFileName());
      ...
      pubDetailVO.setFullText(fullText);
    }
 }

这是实际项目中的代码,构建成果全文的方法,其中对象fullText和fullTextPO的逃逸情况究竟是怎么样呢?
对于fullText,我们可以从第5行代码可以知道,fullText是被逃逸对象pubDetailVO所引用到的,所以fullText属于全局逃逸
对于fullTextPO,是查询数据构建出来的PO对象,只是利用此对象内的属性数据,并未进行引用的传递,也未返回,作用域只在当前方法中,因为是没有逃逸的

使用逃逸分析,编译器可以对代码进行如下优化:
一.同步省略,如果一个对象被发现只能被一个线程访问到,那么对于此对象的同步操作可以忽略(我们锁原理中可以知道每一个对象其实都是一个Monitor对象)
二.将堆分配转化成栈分配,如果一个对象在方法内没有逃逸行为,那么对象可能是栈分配的候选,而不是堆分配(也就是可以分配在栈上,但也不一定分配在栈上,还是可以分配在堆中)
三. 分离对象或标量替换。有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在CPU寄存器中。

开启和关闭逃逸分析

在Java代码运行时,通过JVM参数可指定是否开启逃逸分析,
-XX:+DoEscapeAnalysis : 表示开启逃逸分析
-XX:-DoEscapeAnalysis : 表示关闭逃逸分析
从jdk 1.7开始已经默认开始逃逸分析,如需关闭,需要指定 -XX:-DoEscapeAnalysis

逃逸分析优化一:同步省略

Java锁优化中的锁消除技术,其中正是通过逃逸分析技术来实现的,也就是我们这里所讲到的同步忽略。
在动态编译同步块的时候,JIT编译器可以借助逃逸分析来判断同步块所使用的锁对象是否只能够被一个线程访问而没有被发布到其他线程。

从下面例子来分析同步省略
void synchronizedOmit(){
synchronized (new Object()){
// TODO 完善逻辑
}
}
例子中的synchronized锁,锁的是一个new Object对象,不难看出其实这把锁是没有任何意义的,每一个线程获取到的都是新的Object对象,而不是一块共享资源,因此这个锁是无效的,无法保证序列化的执行同步代码块内逻辑,但如果这样一个无效锁,Java解释器还依旧当做是需要进行同步,开启锁空间来进行处理的话,我们知道加锁和解锁的过程是比较消耗性能与资源的,因而JIT在进行逃逸分析之后,判定同步代码块内的锁对象只能被一个线程所访问,那么JIT编译器在编译这个同步块过程中就会取消对这个代码块的同步,这个过程也就是锁的消除,也叫同步忽略。
优化之后的结果:
void synchronizedOmit(){
// TODO 完善逻辑
}

逃逸分析优化二:将堆分配转化成栈分配

在Java虚拟机中,实例化对象绝大多数情况是存储在堆内存中的,但是也有例外,一个对象在经过逃逸分析之后,发现并没有逃逸出方法时,那么这个对象可能会被优化存储在栈中,这种情况下,也并不是绝对存储在栈中,也有可能还是存储在堆中。

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

public static void main(String[] args) {
    long start = System.currentTimeMillis();
    for (int i = 0; i < 500000; i++) {
        alloc();
    }
    long end = System.currentTimeMillis();
    //查看执行时间
    System.out.println("cost-time " + (end - start) + " ms");
    try {
        Thread.sleep(100000);
    } catch (InterruptedException e1) {
        e1.printStackTrace();
    }
}
 
private static Studentalloc() {
    //Jit对编译时会对代码进行 逃逸分析
    //并不是所有对象存放在堆区,有的一部分存在线程栈空间
    Student student = new Student();
    return student;
}
static class Student {
    private String name;
    private int age;
}

}
分析代码:alloc()方法中的student对象虽然作为方法返回至main之中,但是没有变量进行接受返回对象,引用自然而然就没有被使用到,其实这就可以认定为假逃逸(也就是未逃逸)
for (int i = 0; i < 500000; i++)

如果代码改造成这种情况,你认为还会不会出现存储在栈中的情况呢? 答案是会的,这同样是假逃逸,student变量并未使用到

for (int i = 0; i < 500000; i++) {
Student student = alloc();
System.out.println(student);
}
继续改造,这种情况还会不会出现存储在栈中的情况呢? 答案是不会的,student对象已在另一个栈帧被使用到,真正的属于逃逸至方法外,因而不会存储在栈中

第一种情况:配置关闭JVM的逃逸分析行为:-XX:-DoEscapeAnalysis

通过jmap来查看关闭逃逸分析之后的堆内存:

这里我们清楚可知道,堆中Student实例个数是50W

第二中情况:配置开启JVM逃逸分析行为:-XX:+DoEscapeAnalysis

通过jmap查看堆上信息:

可以明显的发现堆中的Student实例对象格式只有16W

这就可以说明开启逃逸分析之后,对未出现逃逸行为的对象,是会有几率存储在栈中的,跟随着栈帧的出栈而消亡,存储在栈中的对象也不必考虑对其进行垃圾回收。

除了以上通过jmap验证对象个数的方法以外,读者还可以尝试将堆内存调小,然后执行以上代码,根据GC的次数来分析,也能发现,开启了逃逸分析之后,在运行期间,GC次数会明显减少。正是因为很多堆上分配被优化成了栈上分配,所以GC次数有了明显的减少。

逃逸分析优化三:分离对象或标量替换

标量(Scalar)是指一个无法再分解成更小的数据的数据。Java中的原始数据类型就是标量。相对的,那些还可以分解的数据叫做聚合量(Aggregate),Java中的对象就是聚合量,因为他可以分解成其他聚合量和标量。
在JIT编译器编译阶段,如果逃逸分析发现一个对象不会被外界访问的话,那么经过JIT优化,就会把这个对象拆解成若干个其中所包含的若干个成员变量来代替,这个过程就是标量替换。

开启和关闭标量替换
-XX:+EliminateAllocations:开启标量替换(默认打开)
-XX:-EliminateAllocations: 关闭标量替换