首先讲下对象的定位。
啥叫对象的定位呢?比如:T t = new T(); 定位的意思就是说如何通过引用变量t 去 找到T的实例。咋找呢?通俗点就是:t 的指针不就是指向T对象实例,cpu通过指针里面存放的地址,找到T对象实例的首地址,然后通过对象的大小在内存里面截取呗,比如对象大小是24个字节,那么就从首地址开始截取24个字节呗。没错,是这么干的。但是jvm的执行过程,其实是有两种方式的。
先说结果,再说细节,最后分析比较。java对象的定位主要会分为两种方式:直接和间接。
直接方式,也就是直接指针,t直接指向T对象。如下图:
准确的说,类型数据指针指向的应该是类元对象(C++对象),而不是Class对象。
现在的jvm基本上都是采用这种方式,直接指针。
间接方式,也就是句柄方式:
咱们接触的jvm里面,大多是直接的方式。句柄方式虽然用的少,但是它也有优点,比较下优缺点:直接方式的优点,那就是寻址快,我找一次指针就可以了,但是句柄方式需要找两次,找一次指针之后还要再找一次指针,所以比较慢。但是直接方式也有缺点,比如当GC之下垃圾回收的时候,句柄方式 t 里面存放的指针地址是不用变的,如果用直接指针的方式,每次垃圾回收的时候都要修改t里面的地址值。为啥要修改啊?你拷贝来拷贝去的,不用改地址吗?你挪动地址,那就需要改指针。这样会比较麻烦些。但是句柄方式就不用改吗? 它要改,但是它改的不是t里面值,而是改二元组里面的实例数据指针里面的值。疑问? 那不都是一样的,都要改嘛,其实不一样,句柄方式只改二元组里面的实例数据指针,只改一个,直接方式就不一样了,假如t, m, q等等10个引用指向同一对象,那是不是要改10个啊。
再讲下对象的分配,就是刚刚new出来的对象,是分配到内存的哪个地方。
我们知道,jdk默认的分代模型垃圾回收:PS + PO,就是将堆内存分成年轻代和老年代,通常我们说刚产生的对象放在年轻代,伴随这垃圾回收,年轻代里面的对象的年龄到达15之后(CMS默认是6)就会被挪到老年代,但是其实对象的分配不是这么简单的,我们new一个对象其实也不是一定就会在堆内存里面创建一个实例,这里面设计到逃逸分析和标量替换。
通常我们说,man方法执行,main方法栈帧里面存放一个局部变量t,然后t = new T(), 然后在堆内存T对象就被创建了,其实不是这样的,jvm首先啊尝试着在main方法栈帧里面去创建T对象,如果条件不满足,才会去堆内存里面创建。这个有点颠覆我们的观念了。
为什么要这么做呢?为什么要先尝试着在栈里面去创建对象呢?
不光是java,就是其他语言也一样,它也会将内存分为栈和堆。栈内存它是被操作系统直接管理的,方法运行完了,直接就弹出这个方法的栈帧,它里面的数据就直接烟消云散了,栈指针直接往下偏移,它不用像堆内存那样需要垃圾回收,所以效率高。既然它效率高,那我们干嘛不尝试着直接在栈里面创建对象呢,我们天天讲着要优化代码,天天嚷嚷着jvm调优对不对,直接优先考虑在栈里面对象,这不也是一种优化吗? 对的,这就回答了上面的问题,为什么要这么做。
通常在java中创建一个对象,大家都认为是在堆中创建。 在jdk6开始有逃逸分析,标量替换等技术,关于在堆中创建对象不再绝对。
也就是要满足逃逸分析和标量替换两个条件,才能在栈里面创建对象。
逃逸分析是一种分析技术,分析对象的动态作用域,比如分析一个对象不会逃逸到方法之外或线程之外。通俗点就是分析:指向这个对象的指针有没有逃出对象所在栈帧的范围,如果没有逃出,那么就满足逃逸分析法的条件,或者可以说:我这个方法里面new的对象,别的方法里面有没有用到?在我这个方法外面的地方有没有人用到?如果有,那么就不满足条件,那就只能在堆内存里面创建了。试想一下,如果直接放在栈里面,你这个方法都出栈了,数据都干掉了,别的地方指针还指向它,那就成空指针了。所以只有少部分才能在栈上分配。
标量替换是什么呢,阅读《深入理解Java虚拟机》的过程中,原文是这么解释的:但即使只考虑现在,由于即时编译技术的进步,尤其是逃逸分析技术的日渐强大,栈上分配、标量替换优化手段已经导致一些微妙的变化悄然发生,所以说Java对象实例都分配在堆上也渐渐变得不是那么绝对了。这是原文的解释,我们可以简单点说,就是即时编译器会对方法中的对象动态作用域进行分析,如果这个对象只是在该方法中使用,可以认为这个对象是没有逃逸出该方法的,当遇到这种情况时,JVM会针对这个对象做一些栈自动分配的事情。那么也就不需要占用堆内存也不需要被垃圾回收器管理,当栈帧压出(方法执行完返回了)该对象的内存会自动释放。
标量可以理解成一种不可分解的变量,如java内部的基本数据类型、引用类型等。 与之对应的聚合量是可以被拆解的,如对象。如果在条件允许的情况下,只需要在栈中使用标量来简化整个逻辑,甚至不需要创建这个对象的实例。
当通过逃逸分析一个对象只会作用于方法内部,虚拟机可以通过使用标量替换来进行优化。这些优化的手段,不仅可以减少运行时的堆内存消耗,也能够有效减少GC的次数,对于整体性能的提升是很显著的。下面用代码测试下:
/** * @author wangyong */ public class Person { public int age; public String name; public Person(int age, String name){ this.age = age; this.name = name; } }
/** * @author wangyong */ public class EscapeAnalysis { public Person p; /** * 发生逃逸,对象被返回到方法作用域以外,被方法外部,线程外部都可以访问 */ public void escape(){ p = new Person(26, "TomCoding escape"); } /** * 不会逃逸,对象在方法内部 * @return */ public String noEscape(){ Person person = new Person(26, "TomCoding noEscape"); return person.name; } }
/** * @author wangyong */ public class EscapeAnalysisRunTest { public static void main( String[] args ) { // noEscape()不会发生逃逸,分别测试关闭标量替换优化 和 开启标量替换优化 // testEliminateAllocationsWithNoEscape(); // escape()发生逃逸,分别测试关闭标量替换优化 和 开启标量替换优化 // testEliminateAllocationsWithEscape(); } private static void testEliminateAllocationsWithNoEscape() { int n = 100000000; long start = System.currentTimeMillis(); EscapeAnalysis escapeAnalysis = new EscapeAnalysis(); for (int i = 0; i < n; i++) { escapeAnalysis.noEscape(); } System.out.println("耗时:" + (System.currentTimeMillis() - start)); } private static void testEliminateAllocationsWithEscape() { int n = 100000000; long start = System.currentTimeMillis(); EscapeAnalysis escapeAnalysis = new EscapeAnalysis(); for (int i = 0; i < n; i++) { escapeAnalysis.escape(); } System.out.println("耗时:" + (System.currentTimeMillis() - start)); } }
接下来我们通过对noEscape()方法进行测试,测试是在jdk8中运行(注jdk8默认是开启逃逸分析,标量替换技术的)。
主要测试两种场景:不使用标量替换 和 使用标量替换。
1.0情形:不逃逸,main方法里面调用testEliminateAllocationsWithNoEscape()方法。
-XX:+PrintGC 打印gc日志
-XX:-EliminateAllocations 关闭标量替换优化
1.1情形:不逃逸,main方法里面调用testEliminateAllocationsWithNoEscape()方法。
-XX:+PrintGC 打印gc日志
-XX:+EliminateAllocations 启用标量替换优化
这个毫秒9毫秒,GC只有0次,首先不发生逃逸,那标量替换是怎么优化的呢?
当通过逃逸分析一个对象只会作用于方法内部,虚拟机可以通过使用标量替换来进行优化。
比如上述noEscape()方法中person对象只会在方法内部,通过标量替换技术得到如下伪代码:
/** * 不会逃逸,对象在方法内部 * @return */ public String noEscape(){ int age = 26; String name = "TomCoding noEscape"; return name; }
此时只需要在栈中使用标量来简化整个逻辑,甚至不需要创建这个对象的实例。jvm进行了优化,优化之后就代替了下面的代码:这个就叫标量替换技术
/** * 不会逃逸,对象在方法内部 * @return */ public String noEscape(){ Person person = new Person(26, "TomCoding noEscape"); return person.name; }
1.2情形:逃逸,main方法里面调用testEliminateAllocationsWithEscape()方法。
-XX:+PrintGC 打印gc日志
-XX:+EliminateAllocations 启用标量替换优化
1.3情形:逃逸,main方法里面调用testEliminateAllocationsWithEscape()方法。
-XX:+PrintGC 打印gc日志
-XX:-EliminateAllocations 关闭标量替换优化
总结:
标量替换只是利用逃逸分析其中的一种优化措施, 它首先是不能发生逃逸,发生逃逸就不会用了。
可以看到通过逃逸分析与标量替换技术有效的减少了gc次数(减少了对象在堆中创建的数量)。
实际编码过程中避免对象逃逸情况是一种理想的情况。可以形成一种编码意识,尽量去减少对象逃逸。