逃逸分析
随着JIT编译期的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。
1、什么是逃逸分析
1、逃逸分析(Escape Analysis)是目前Java虚拟机中比较前沿的优化技术。
2、是一种可以有效减少Java 程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。
3、通过逃逸分析,Java Hotspot编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。
逃逸分析的基本行为就是分析对象动态作用域:
当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他地方中,称为方法逃逸。
例如:
public static StringBuffer craeteStringBuffer(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb;
}
1、StringBuffer sb是一个方法内部变量,上述代码中直接将sb返回
2、这个StringBuffer有可能被其他方法所改变,
3、这样它的作用域就不只是在方法内部,虽然它是一个局部变量
4、称其逃逸到了方法外部。
5、还有可能被外部线程访问到,譬如赋值给类变量或可以在其他线程中访问的实例变量,称为线程逃逸。
上述代码如果想要StringBuffer sb不逃出方法,可以这样写:
public static String createStringBuffer(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb.toString();
}
不直接返回 StringBuffer,那么StringBuffer将不会逃逸出方法。
2、逃逸分析的好处
使用逃逸分析,编译器可以对代码做如下优化:
1、同步省略。如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步。
2、将堆分配转化为栈分配。如果一个对象在子程序中被分配,要使指向该对象的指针永远不会逃逸,对象可能是栈分配的候选,而不是堆分配。
3、分离对象或标量替换。有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在CPU寄存器中。
以上三种优化中,栈上内存分配其实是依靠标量替换来实现的。
1、在Java代码运行时,通过JVM参数可指定是否开启逃逸分析, -XX:+DoEscapeAnalysis
2、从jdk 1.7开始已经默认开始逃逸分析,如需关闭,需要指定-XX:-DoEscapeAnalysis
3、同步省略
在动态编译同步块的时候,JIT编译器可以借助逃逸分析来判断同步块所使用的锁对象是否只能够被一个线程访问而没有被发布到其他线程。
如果同步块所使用的锁对象通过这种分析被证实只能够被一个线程访问,那么JIT编译器在编译这个同步块的时候就会取消对这部分代码的同步。这个取消同步的过程就叫同步省略,也叫锁消除。
如以下代码:
public void f() {
Object hollis = new Object();
synchronized(hollis) {
System.out.println(hollis);
}
}
代码中对hollis这个对象进行加锁,但是hollis对象的生命周期只在f()方法中,并不会被其他线程所访问到,所以在JIT编译阶段就会被优化掉。优化成:
public void f() {
Object hollis = new Object();
System.out.println(hollis);
}
所以,在使用synchronized的时候,如果JIT经过逃逸分析之后发现并无线程安全问题的话,就会做锁消除。
4、标量替换
标量(Scalar):一个无法再分解成更小的数据的数据
1、Java中的原始数据类型就是标量。
聚合量(Aggregate): 可以分解的数据叫做聚合量
2、Java中的对象就是聚合量,因为他可以分解成其他聚合量和标量。
在JIT阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过JIT优化,就会把这个对象拆解成若干个其中包含的若干个成员变量来代替。这个过程就是标量替换。
public static void main(String[] args) {
alloc();
}
private static void alloc() {
Point point = new Point(1,2);
System.out.println("point.x="+point.x+"; point.y="+point.y);
}
class Point{
private int x;
private int y;
}
以上代码中,point对象并没有逃逸出alloc
方法,并且point对象是可以拆解成标量的。那么,JIT就会不会直接创建Point对象,而是直接使用两个标量int x ,int y来替代Point对象。
以上代码,经过标量替换后,就会变成:
private static void alloc() {
int x = 1;
int y = 2;
System.out.println("point.x="+x+"; point.y="+y);
}
可以看到,Point这个聚合量经过逃逸分析后,发现他并没有逃逸,就被替换成两个标量了。
标量替换有什么好处?
1、可以大大减少堆内存的占用。因为一旦不需要创建对象了,那么就不再需要分配堆内存了。
2、标量替换为栈上分配提供了很好的基础。
5、对象在栈上分配内存
-Xms4G -Xmx4G -XX:-DoEscapeAnalysis -XX:+PrintGCDetail
-XX:+HeapDumpOnOutOfMemory
# 命令
jps # 查看进程 jps -l 包含包名
jmap -histo 2809 # 查看对象个数与分布情况
jstack 2809 # 查看堆栈信息
# 工具
javac # 编译
javap -p # 对class文件进行反编译
java -sjava # 对class文件进行反编译,“更准确”
测试代码如下:
package com.realguo.escape;
import java.util.concurrent.TimeUnit;
/**
* 测试:
* 场景:方法中的对象不存在方法逃逸时
* 控制是否开启逃逸分析,观察栈、堆的变化
* 设置命令:jps jmap
*/
public class TestEscape {
public static void main(String[] args) throws InterruptedException {
long start = System.currentTimeMillis();
for (int i = 0; i < 1000000; i++) {
alloc();
}
long end = System.currentTimeMillis();
System.out.println("cost: " + (end-start) + " ms");
// 睡眠一下,让我们观察一下 堆 栈 中对象的数量
TimeUnit.MINUTES.sleep(2);
}
private static void alloc(){
User user = new User();
}
}
class User{
}
不开启逃逸分析及结果:
-Xms4G -Xmx4G -XX:+PrintGCDetails -XX:-DoEscapeAnalysis -XX:+HeapDumpOnOutOfMemoryError
全部都在堆里
开启逃逸分析及结果:
-Xms4G -Xmx4G -XX:+PrintGCDetails -XX:+DoEscapeAnalysis -XX:+HeapDumpOnOutOfMemoryError
不全部都在堆里
如果经过逃逸分析后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配。这样就无需在堆上分配内存,也无须进行垃圾回收了。
对象没有在堆上分配,其实是标量替换实现的。
除了以上通过jmap验证对象个数的方法以外,读者还可以尝试将堆内存调小,然后执行以上代码,根据GC的次数来分析,也能发现,开启了逃逸分析之后,在运行期间,GC次数会明显减少。正是因为很多堆上分配被优化成了栈上分配,所以GC次数有了明显的减少。
6、总结
是不是所有的对象和数组都会在堆内存分配空间?
1、不一定
2、随着JIT编译器的发展,在编译期间,如果JIT经过逃逸分析,发现有些对象没有逃逸出方法,那么有可能堆内存分配会被优化成栈内存分配。
3、但是这也并不是绝对的。就像我们前面看到的一样,在开启逃逸分析之后,也并不是所有User对象都没有在堆上分配。
逃逸分析并不成熟
1、关于逃逸分析的论文在1999年就已经发表了,但直到JDK 1.6才有实现,而且这项技术到如今也并不是十分成熟的。
2、其根本原因就是无法保证逃逸分析的性能消耗一定能高于他的消耗。虽然经过逃逸分析可以做标量替换、栈上分配、和锁消除。但是逃逸分析自身也是需要进行一系列复杂的分析的,这其实也是一个相对耗时的过程。
一个极端的例子,就是经过逃逸分析之后,发现没有一个对象是不逃逸的。那这个逃逸分析的过程就白白浪费掉了。