逃逸分析
1、堆是分配对象的唯一选择吗?
在《深入理解Java虚拟机》中关于Java堆内存有这样一段描述:随着JIT编译期的发展与逃逸技术的成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变的不那么绝对了。
在Java虚拟机中,对象是在堆内存中分配内存的,这是一个普遍常识。但是,有一种特殊技术,那就是如果经过逃逸分析(Escape Analysis)后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配。这样就无需在堆上分配内存,也不需要垃圾回收了,这就是常见的堆外存储技术。
此外,前面提到的基于OpenJDK深度定制的TaoBaoVM,其中创新的GCIH(GC invisble heap)技术实现off-heap,将生命周期长的Java对象从heap中移到heap外,并且GC不能管理GCIH内部的对象,以此达到降低GC的回收频率和提升GC回收效率的目的。
总之,堆不是对象分配的唯一选择;基于逃逸技术的栈上分配技术和TaoBaoVM的堆外对象分配都不在堆上分配。
2、逃逸分析概述
-
逃逸分析手段是一种可以有效减少Java程序中同步负载和内存堆分配压力的跨函数数据流分析算法。
-
通过逃逸分析,Java Hotspot编译能够分析出一个新的对象引用的使用范围从而决定是否要将这个对象分配到堆上。
-
逃逸分析的基本行为是分析对象动态作用域:
- 当一个对象在方法中被定义后,对象只在方法内部使用,则没有发生逃逸。
- 当一个对象在方法中被定义后,对象被外部方法所用,则认为发生了逃逸。
没有逃逸的对象可以在栈上分配,随着方法的出栈而被销毁,不用GC回收。
public static StringBuffer createStringBuffer(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb;
}
以上方法中,sb对象可能会在外部别使用,发生方法逃逸。
public static String createString(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb.toString()
}
以上方法中,sb对象只在createString方法被使用,没有发生方法逃逸。
JDK7及其之后Hotspot默认开启逃逸分析。由于开启栈上分配一定程度减少了GC的压力,因此:开发中尽量使用局部变量。
3、基于方法逃逸的代码优化
使用逃逸技术,编译器可以做以下优化:
- 栈上分配。将堆分配转换为栈分配。如果一个对象在子程序中被分配,钥匙指向该对象的指针永远不会逃逸,对象可能是栈上分配的候选,而不是堆分配。
- 同步省略。如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步。
- 分离对象或变量替换。有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分可以存储在内存,而是存储在CPU寄存器上。
代码优化之栈上分配
JIT编译器在编译期间可以根据逃逸分析的结果,如果发现一个对象没有逃逸出方法,就可以被优化为栈上分配。分配完成后,继续在调用栈内执行,最后线程结束,栈空间被回收,局部变量也被回收。这样就无需垃圾回收了。
逃逸常发生于:成员变量赋值、方法返回值、实例传递。
栈上分析效果演示:
public class Demo06StackAllocation {
public static void main(String[] args) throws InterruptedException {
long startTime = System.currentTimeMillis();
// 调用方法创建一千万个对象
for (int i = 0; i < 10000000; i++) {
allocate();
}
long endTime = System.currentTimeMillis();
System.out.println("花费总时间:" + (endTime - startTime) + "ms");
// 相当于阻塞main线程
Thread.sleep(Integer.MAX_VALUE);
}
// 注意:此方法中user对象没有发生逃逸,因此很有可能会栈上分配
public static void allocate() {
User user = new User();
}
}
class User {}
以上程序创建了一千万个对象,接下来我们通过调整JVM参数查看:开启逃逸分析以及关闭逃逸分析之间的性能差异。
关闭逃逸分析:
1、添加JVM参数:-Xmx256M -Xms256M -XX:-DoEscapeAnalysis -XX:+PrintGCDetails
2、运行程序,并打开Java VisualVM
开启逃逸分析:
1、添加JVM参数:-Xmx256M -Xms256M -XX:+DoEscapeAnalysis -XX:+PrintGCDetails
2、运行程序,并打开Java VisualVM
分析
从图1到图4我们可以发现,开启和关闭逃逸分析对该程序的性能还是比较大。
开启了逃逸分析,使得那些:不会发生方法逃逸的对象可以在栈上分配。
在栈上分配的对象在方法出栈时可以自动释放,而不用GC。
因此可以发现开启了栈上分配,该程序没有发生之前的GC;而关闭了逃逸分析,则发生了2次GC。
代码优化之同步省略(同步消除)
线程同步的代价是很高的,同步的后果是降低并发性和性能。
在动态编译同步块的时候,JIT编译器借助逃逸分析来判断同步代码块所使用的🔒锁对象是否只能别一个线程访问而没有发布到其他线程。
如果没有,那么JIT编译器在编译这个同步块的时候就会取消这部分代码的同步。这样可以大大提高并发性和性能。
这个取消同步的过程就叫同步省略,也就锁消除。🔓
如以下程序:
public void m1() {
Object lock = new Object()
synchronized(lock) {
System.out.println(lock);
}
}
由于以上使用lock对象加锁,而lock对象的生命周期只在ml方法中,不会被其他线程访问,因此会在JIT编译阶段被优化掉。优化为如下代码:
public void m1() {
Object lock = new Object()
System.out.println(lock);
}
代码优化之标量替换
标量(Scalar):是指一个无法再分解成更小的数据的数据。Java中的原始数据类型就是标量。
相对的,那些还可以分解的数据叫做聚合量(Aggregate),Java中的对象就是聚合量,因为可以分解成其他聚合量和标量。
在JIT阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过JIT优化,就回把这个对象成若干其中包含的若干成员变量来替换。这个过程称为标量替换。
public class Demo07ScalarReplace {
public static void alloc() {
Point p1 = new Point(1, 2);
System.out.println("point.x=" + point.x + "point.y=" + point.y);
}
public static void main(String[] args) {
alloc();
}
}
class Point {
private int x;
private int y;
}
以上的代码经过标量替换可以变成:
public static void alloc() {
int x = 1;
int y = 2;
System.out.println("point.x=" + x + "point.y=" + y);
}
可以看出,聚合量Point经过逃逸分析,发现它并没有逃逸,就被替换成立两个聚合量。这样可以得到减少堆内存的占用。因为一旦不需要创建对象,那么就不需要再分配堆内存了。
标量替换为栈上分配提供了很好的基础。
我们通过下面代码来测试一下开关标量替换的性能差异
public class Demo07ScalarReplace {
public static class User {
public int id;
public String name;
}
public static void alloc() {
User user = new User();
user.id = 1000;
user.name = "tobing";
}
public static void main(String[] args) {
long start = System.currentTimeMillis();
for (int i = 0; i < 10000000; i++) {
alloc();
}
long end = System.currentTimeMillis();
System.out.println("花费总时间为: " + (end - start) + "ms");
}
}
关闭标量替换:
-Xms100m -Xmx100m -XX:+PrintGC -XX:+DoEscapeAnalysis -XX:-EliminateAllocations
开启标量替换:
-Xms100m -Xmx100m -XX:+PrintGC -XX:+DoEscapeAnalysis -XX:+EliminateAllocations
注意:通过-XX:+EliminateAllocations
可以开启标量替换【默认是开启的】,逃逸分析是标量替换的基础,因此必须开启逃逸分析才能看到标量替换的效果。