逃逸分析
在《深入理解Java虚拟机》中关于java堆内存有这段一段描述
随着JIT编译器的发展与逃逸分析技术的逐渐成熟,站上分配,标量替换优化技术会导致一些微妙的变化,所有的对象都分配到堆上渐渐变得不那么绝对了
在JVM中,对象是在堆中分配内存,这是一个普遍尝试.但是也有一种特殊情况,那就是如果经过逃逸分析(Escape Analysis)后发现,一个对象没有逃逸出方法的话,那么可能被优化成栈上分配.这样就无需在堆上分配内存,也无须进行垃圾回收了,这也是最常见的对外存储技术.
此外,阿里基于OpenJDK深度定制TaoBaoVM,其中创新的GCIH(GC invisible heap) 技术实现off-heap,将生命周期较长的java对象从heap中移到heap外,并且GC不能管理GCIH内存的java对象,以此达到降低GC的回收频率和提高GC回收效率的目的
如何将堆上的对象分配到栈,需要使用逃逸分析手段
这是一种可以有效减少java程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法.通过逃逸分析,Java HotSpot编译器能够分析出一个新的对象和引用的使用返回从而决定是否将这个对象分配到堆上.逃逸分析基本行为就是分析对象的动态作用域
- 当一个对象在方法中被定义,对象只在方法内部使用,则没有发生逃逸
- 当一个对象在方法中被定义,但他有可能被外部的方法所引用,则发生了逃逸. 比如返回,或者作为方法参数 传入
逃逸分析
没有发生逃逸的对象,可以直接分配到栈上,随着方法调用执行结束,栈空间移除
// 没有发生逃逸 对象在方法内部使用之后消亡
public void my_method() {
V v = new V();
// use v
// ....
v = null;
}
// 发生了逃逸 将对象返回出去 方法外部可能会被引用
public static StringBuffer createStringBuffer(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb;
}
// StringBuilder 没有发生逃逸 当StringBuilder.toString()返回的字符串发生了逃逸
public static String createStringBuffer(String s1, String s2) {
StringBuilder sb = new StringBuilder();
sb.append(s1);
sb.append(s2);
return sb.toString();
}
/**
* 判断是否发生了逃逸,主要是判断 对象能不能在方法外部被引用
*/
public class EscapeAnalysis {
public EscapeAnalysis obj;
/**
* 因为方法返回了的EscapeAnalysis对象 可能会在其他方法中被引用 所有发生了逃逸
*
* @return
*/
public EscapeAnalysis getInstance() {
return obj == null ? new EscapeAnalysis() : obj;
}
/**
* 发生了逃逸, 因为外部成员变量 引用到了对象 这个成员变量可能会被其他方法引用
*/
public void setObj() {
this.obj = new EscapeAnalysis();
}
/**
* 没有发生逃逸, new的对象值在方法内部使用
*/
public void useEscapeAnalysis() {
EscapeAnalysis e = new EscapeAnalysis();
}
/**
* 发生了逃逸 因为e对象获取的方式是从外部方法获取,方法相当于成员变量
*/
public void useEscapeAnalysis2() {
EscapeAnalysis e = getInstance();
// getInstance().XXX 发生逃逸
}
}
参数设置
JDK7之后HotSpot默认开始了逃逸分析,也可以通过-XX:+DoEscapeAnalysis 显示开启逃逸分析 也可以通过-XX:+PrintEscapeAnalysis 查看逃逸分析之后筛选的结果
结论
- 开发中能使用局部变量,就不要在方法外部定义
- 使用逃逸分析,编译器可以对代码进行如下优化
- 栈上分配 将堆分配转换为栈分配 如果一个对象在子程序中被分配,要使指向该对象的指针永远不会发生逃逸,对象可能是栈上分配的候选,而不是在堆上
- 同步省略(锁消除) 如果一个对象被发现只在一个线程中被访问,那么对于这个对象的操作可以不考虑同步
- 分离对象或标量替换: 有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分或全部可以不存在内存,而是存在CPU寄存器(栈)上
栈上分配
JIT编译器在编译期间根据逃逸分析的结果,发现如果一个对象并没有逃逸出方法的话,就可能被优化成线上分配.分配完成后,继续在调用栈内执行,最后线程结束,栈空间被回收,局部变量对象也被回收.这样就无需进行垃圾回收了
常见的栈上分配场景 在逃逸分析中,在方法外面无法被引用的对象可能会被栈上分配
/**
* @author : Jim Wu
* @version 1.0
* @function : 栈上分配测试 -Xms1G -Xmx1G -XX:-DoEscapeAnalysis -XX:+PrintGCDetails
* 如果关闭了逃逸分析 创建1000W次对象耗时458ms 发生了6次GC
* 开启逃逸分析 4ms结束 JVM中对象实例Person的个数远远小于关闭逃逸分析的个数
* @since : 2020/9/21 16:18
*/
public class StackDispatchTest {
public static void main(String[] args) throws InterruptedException {
long start = System.currentTimeMillis();
for (int i = 0; i < 100000000; i++) {
createObj();
}
long end = System.currentTimeMillis();
System.out.println(end - start + "ms");
TimeUnit.HOURS.sleep(1);
}
public static void createObj() {
Person p = new Person();
}
}
class Person {
}
- 关闭了逃逸分析 发生了多次GC 并且Person在JVM的实例数较多,但使用了逃逸分析之后5ms左右即可完成并且不会发生GC
同步省略
线程同步的代价是相当高的,同步的后果是降低并发性和性能
在动态编译同步块的时候,JIT编译器可以借助逃逸分析来判断同步块锁所有的锁对象是否只能被一个线程访问而没有发布到其他线程.如果没有,那么JIT编译器在编译这个同步块的时候就会取消这部分代码的同步.这样就能大大提高并发性能.这个取消同步的过程称为同步省略,也称为锁消除
public void f() {
Object hellis = new Object();
synchronized(hellis) {
System.out.println(hellis);
}
}
// 同步省略之后
public void f() {
Object hellis = new Object();
System.out.println(hellis);
}
// 省略之后在字节码中还是可以看到monitorexit的指令,但实际在运行时JIT会叫这块同步消除
分离对象或标量替换
-
标量Scalar指一个无法再分解成更小的数据的数据.java的基本数据类型就是标量
-
相对,如果还可以分解的的数据叫做聚合量 Aggregate,java中的对象就是聚合量,因为他可以分解成其他聚合量和标量
-
在JIT阶段,如果经过逃逸分析,发现一个对象不会被外界访问,那么经过JIT优化之后,会把这个对象拆解成若干个其中包含若干个成员变量来代替,这个过程称为标量替换
-
标量替换的好处在于可以大大减少堆内存的占用,因为一旦不需要创建对象了.那么就不再需要分配堆内存了.标量替换为站上分配提供了很好基础
-
标量替换涉及参数
- -server: 启动Server模式,只有在Server模式下才会启动逃逸分析.普遍的JDK默认就是server模式
- -XX:+DoEscapeAnalysis: 启动逃逸分析 JDK7默认开启
- -Xmx: 最大堆空间
- -XX:+EliminateAllocations:开始标量替换 默认打开,允许将对象打散分配到栈上,比如对象拥有id和name字段,那么这两个字段江北视为两个独立的局部变量进行分配
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); } // 因为 point 对象没有发生逃逸 经过标量替换 alloc会变成 /** * Point 聚合量没有发生逃逸 被替换成了2个标量. */ private static void alloc() { int x = 1; int y = 2; System.out.println("point.x = " + x + "; point.y=" + y); }
逃逸分析不足
关于逃逸分析论文在1999年已经发表了,直到JDK6才有实现,而且这项技术如果也不是十分成熟.
其根本原因就是无法保证逃逸分析的性能消耗一定高于他的消耗.虽然经过逃逸分析可以做标量替换,栈上分配和锁清除.但是逃逸分析自身也是需要进行一系列复杂的分析,这本身也是相对耗时的过程.一个极端的列子,就是经过逃逸分析之后,发现所以对象都发生了逃逸,就导致逃逸分析的过程没有任何优化反而消耗了分析的时间.
虽然这项技术并不十分成熟,但是他也是JIT优化技术中一个十分重要的手段.注意到有一些观点,认为通过逃逸分析,JVM会在栈上分配那些不逃逸的对象,这理论上可行,但取决于JVM设计者的选择.Oracle的HotSpot并没有这么做,这点在逃逸分析相关文档已经说明,所以可以明确的是所有的对象实例都是创建在堆上.但添加了DoScapeAnalysis会有性能优化是因为HotSpot应用了标量替换
小结
年轻代管理对象的诞生,成长,消亡的区域,一个对象在这里产生,应用,最后对GC回收结束生命
老年代放置生命周期较长的对象,通常由survivor区域筛选拷贝过来的java对象.特殊情况下,普通对象会被优先分配到每个线程自身的TLAB上,如果对象较大超过了TLAB容量,JVM会尝试分配到Eden的其他位置上,如果对象太大,无法在新生代找到足够长的连续内存空间存储,JVM会直接分配到老年代,并且触发MinorGC对新生代进行GC操作.
GC只在新生代方法的时候,这种GC称为MinorGC
当GC发生在老年代时则被称谓MajorGC或者FullGC,一般MinorGC发生频率会比MajorGC高许多,速度也会高很多.在发生FullGC之前一定会触发一次MinorGC对新生代进行回收,发现新生代GC之后存不下,然后尝试放在老年代也放不下,此时触发FullGC对整个堆进行垃圾回收.