1、逃逸分析观看堆空间分配策略
前面的文章提到,实例化对象和数组基本都在堆上进行内存的分配,但也有一种例外,那就是如果经过逃逸分析后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配。这样就无需在堆上分配内存,也无须进行垃圾回收了。这也是最常见的堆外存储技术。
逃逸分析的基本原则:
1)一个对象在方法内被定义,对象只能在该方法中使用,没有被方法外调用,则认为它没发生逃逸。
2)一个对象在方法内部被定义,但被方法外部引用,例如作为参数传递到其他地方,则认为他发生了逃逸。
3)快速判断一个对象是否逃逸,就看它是不是可能存在方法外的调用
没有逃逸的代码示例:
//这里我们在方法内部定义了一个变量v,new 了一个对象放在了堆空间中,这个对象在方法体内部使用
//最后我们把这个变量赋值为null,堆空间这个对象就没有任何引用指向它,这个对象就只能在该方法中使用,
//因此我们认为他没有发生逃逸,此时这个new V的对象就可以放在栈空间
//没有发生逃逸的对象,则可以分配到栈上:原因如下:
//1.随着方法执行的结束,栈空间就被移除。
//2.虚拟机栈空间是线程私有的,不会被共享。
public void method(){
V v = new V();
//use V
//......
v = null;
}
逃逸的例子:
//我们new了一个对象,然后往这个对象添加了两个字符串,最后返回这个对象
//此时如果这个方法被调用,那么这个对象就不是作用在方法体内部的,我们认为它发生了逃逸
public static StringBuffer createStringBuffer(String s1,String s2){
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb;
}
为了让StringBuffer对象不发生逃逸,我们可以这样修改,加上toString后,返回的就是对象的字符串类型,而对象本身没有被调用,这样StringBuffer对象就没有发生逃逸,可以在栈上分配。
public static String createStringBuffer(String s1,String s2){
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb.toString();
}
补充:较为详细的逃逸分析例子
/**
* 逃逸分析
*
* 如何快速的判断是否发生了逃逸分析,就看“new的对象实体”是否有可能在方法外被调用。
*/
public class EscapeAnalysis {
public EscapeAnalysis obj;
/*
方法返回EscapeAnalysis对象,发生逃逸
*/
public EscapeAnalysis getInstance(){
return obj == null? new EscapeAnalysis() : obj;
}
/*
为成员属性赋值,发生逃逸
*/
public void setObj(){
this.obj = new EscapeAnalysis();
}
//思考:如果当前的obj引用声明为static的?仍然会发生逃逸。
/*
对象的作用域仅在当前方法中有效,没有发生逃逸
*/
public void useEscapeAnalysis(){
EscapeAnalysis e = new EscapeAnalysis();
}
/*
引用成员变量的值,发生逃逸
*/
public void useEscapeAnalysis1(){
EscapeAnalysis e = getInstance();
//getInstance().xxx()同样会发生逃逸
}
}
2、代码优化之栈上分配示例
JIT编译器在编译期间根据逃逸分析的结果,发现如果一个对象并没有逃逸出方法的话,就可能被优化成栈上分配。分配完成之后,继续在调用栈内执行,最后线程结束,栈空间被回收,局部变量对象也被回收。这样就无须垃圾回收了。
事实上,认为通过逃逸分析,JVM会在栈上分配那些不会逃逸的对象,这在理论上是可行的,但是取决于JVM设计者的选择。但Oracle HotspotJVM中并未这么做,所以可以明确所有的对象实例都是创建在堆上,因此在这里的示例里面还是会存在GC。我们在这里看到的效果,其实主要原因还是基于标量替换,因此提升了性能。
/**
*
*-Xmx1G -Xms1G -XX:-DoEscapeAnalysis -XX:+PrintGCDetails
* -号表示未开启逃逸分析,此时对象分配在堆上完成,有多少个对象则维护多少个对象
* +号表示开启逃逸分析,只维护少量对象
* */
public class taoyi {
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");
// 为了方便查看堆内存中对象个数,休眠线程1000s
try {
TimeUnit.SECONDS.sleep(1000);
} catch (InterruptedException e1) {
e1.printStackTrace();
}
}
private static void alloc() {
User user = new User();//未发生逃逸,在当前方法体内执行
}
static class User {
}
}
3、代码优化之同步省略
简单来说就是在使用synchronized锁的时候,如果其所在的同步代码块的对象只会被一个线程访问,而不会被另外一个线程访问,即只有一个线程会访问这个对象,那么JIT编译器会在编译期间就会取消对这部分代码的同步。这样就能大大提高并发性和性能。这个取消同步的过程就叫同步省略,也叫 锁消除。
代码示例:
/**
* 同步省略说明:
* 同步代码需要加锁的目的是保证多个线程操作一个资源的时候保证安全性
* 但是下面的同步代码块每次的锁对象hollos都是新new出来的,这意味着每次锁的是一个新的对象,如果存在多线程,操作的不会是一个对象
* 因此这段代码是错误展示,但能说明同步省略的问题。
*/
public class SynchronizedTest {
public void f() {
Object hollis = new Object();
synchronized(hollis) {
System.out.println(hollis);
}
}
//代码中对hollis这个对象进行加锁,但是hollis对象的生命周期只在f()方法中
//并不会被其他线程所访问控制,所以在JIT编译阶段就会被优化掉。
//优化为 ↓
public void f2() {
Object hollis = new Object();
System.out.println(hollis);
}
}
4、代码优化之标量替换
有的对象可能不需要作为一个连续的内存结构也可以被访问到,那么对象的部分(或全部)可以不储存到内存,而是储存到CPU寄存器中。
标量:是指一个无法在分解成更小的数据的数据。Java中的原始数据类型就是标量。
聚合量:相对的,哪些可以继续分解的数据叫聚合量,如java中的对象就是聚合量,可以分解为更小的聚合量和标量。
在JIT阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过JIT优化,就会把这个对象拆解成若干个其成员变量来替代。这个过程就是标量替换。
public class ScalarTest {
public static void main(String[] args) {
alloc();
}
public static void alloc(){
//为标量替换之前
Point point = new Point(1,2);
}
}
class Point{
private int x;
private int y;
public Point(int x,int y){
this.x = x;
this.y = y;
}
}
经过标量替换之后:
public static void alloc(){
int x = 1;
int y = 2;
}
不难看出,Point类对象在本方法内调用,没有发生逃逸,可以继续通过标量替换替换成更小的标量,这就省去了在堆上创建对象的过程,节省了内存空间,标量替换为栈上分配提供了很好的基础。
标量替换测试:
/**
* 标量替换测试
* -Xmx100m -Xms100m -XX:+DoEscapeAnalysis -XX:+PrintGC
* -XX:-EliminateAllocations//是否允许标量替换,测试的时候主要改变这里
*/
public class ScalarReplace {
public static class User {
public int id;//标量(无法再分解成更小的数据)
public String name;//聚合量(String还可以分解为char数组)
}
public static void alloc() {
User u = new User();//未发生逃逸
u.id = 5;
u.name = "www.atguigu.com";
}
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");
}
}
5、逃逸分析小结
1、关于逃逸分析的论文在1999年就已经发表了,但直到JDK1.6才有实现,而且这项技术到如今也并不是十分成熟的。
2、其根本原因就是无法保证逃逸分析的性能消耗一定能高于他的消耗。虽然经过逃逸分析可以做标量替换、栈上分配、和锁消除。但是逃逸分析自身也是需要进行一系列复杂的分析的,这其实也是一个相对耗时的过程。
3、一个极端的例子,就是经过逃逸分析之后,发现没有一个对象是不逃逸的。那这个逃逸分析的过程就白白浪费掉了。
4、虽然这项技术并不十分成熟,但是它也是即时编译器优化技术中一个十分重要的手段。