另一条对象创建流程
另一条流水线并没有生产出任何Java语言层意义上的对象,而是回归到创建对象要解决的问题本身去解决问题。这就像马斯克提到的第一性原理:回归本质,然后重构。之所以这样做,还是离不开时代的影响。
当Java刚刚崛起的时候,作为面向对象语言,Java的那句“everything is object”,就像标签一样深深烙印在每个人的心中,也烙印在Java的身上。但就是这句给Java带来无限荣耀的Slogan,如今却成为了Java低效的代名词。相比于其他新出世的编程语言,人们开始觉得如果解决任何问题都要先创建对象,而创建对象又要经历这么复杂的流程的话,成本似乎太高了。
本着与其被别人革命,不如自己先革自己命的原则,从JDK 1.8开始,JVM搭建起它的第二条对象创建的流水线,或者说从对象创建要解决的问题出发,绕过了之前的对象创建环节,直接在JVM层面去解决问题。
在Java虛拟机中,对象是在Java堆中分配内存的,这是一个普遍的常识。但是,有一种特殊情况,那就是如果经过逃逸分析(Escape Analysis)后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配。这样就无需在堆上分配内存,也无须进行垃圾回收了。这也是最常见的堆外存储技术。
如果对象数量较多的时候, GC 压力较大,也间接影响了应用的性能 。
为了减少临时对象在堆内分配的数量,JVM通过逃逸分析确定该对象不会被外部访问 . 如果不会逃逸可以将该对象在栈上分配内存,这样该对象所占用的内存空间就可以随栈帧出栈而销毁,从而减轻GC的压力。
此外,基于openJDK深度定制的TaoBaoVM,其中创新的GCIH(GC invisible heap)技术实现off-heap,将生命周期较长的Java对象从heap中移至heap外,并且Gc不能管理GCIH内部的Java对象,以此达到降低GC的回收频率和提升GC的回收效率的目的。
JVM 是如何做到的呢?我们来看一下。
这条全新的流水线主要由四个节点构成。
逃逸分析
逃逸分析,是Java虚拟机中的一种优化技术,但它并不是直接优化代码,而是为其他优化手段提供优化依据的分析技术。
逃逸分析的基本行为就是分析对象的动态作用域:
- 当一个对象在方法中被定义后,它可能被外部方法引用,例如作为参数传递到了其他方法中,这称之为方法逃逸;
- 如果被外部线程访问到,比如赋值给类变量或者可以在其他线程中访问的实例变量,这称之为线程逃逸。
以下面的方法为例,在这种情况下新创建的这个对象就属于逃逸了,因为方法中创建的对象的使用范围超过了这个方法的范畴。
public Book EscapeExample() {
return new Book("Java Book");
}
但是实际我们需要的可能只是Book这个对象的某一个属性,比如书名这个属性,所以我们完全可以把方法改成下面这种写法。
public String EscapeExample() {
Book book = new Book("Java Book");
return book.name;
}
经过这种改造,Book这个对象的使用范畴不再超出EscapeExample()的范畴了。这就为后面的对象栈上分配打下了一个很好的基础。JVM中关于逃逸分析的相关参数主要有三个。
- 开启逃逸分析:-XX:+DoEscapeAnalysis
- 关闭逃逸分析:-XX:-DoEscapeAnalysis
- 显示分析结果:-XX:+PrintEscapeAnalysis
-XX:+PrintEscapeAnalysis 只有在debug 版本有效
public User doSomething1() {
Artisan artisan1= new Artisan();
artisan1.setId(1);
artisan1.setDesc("artisan1");
// ......
return user;
}
public void doSomething2() {
Artisan artisan2= new Artisan();
artisan2.setId(2);
artisan2.setDesc("artisan2");
// ......
}
- doSomething1返回对象,在方法结束之后被返回了,那这个对象的作用域范围是不确定的。
- doSomething2方法可以非常确定的是当方法结束以后,artisan2这个对象就失效了,因为它的作用域范围是当前方法。 那对于这样的对象,JVM会把这样的对象分配到线程栈中,让它随着方法结束时跟随线程栈内存一起被回收掉。
如果可以证明一个对象不会发生方法逃逸和线程逃逸(这意味着,别的方法和线程无法通过任何途径访问到这个对象),那么,虚拟机可能会为这个变量进行一些高效的优化,优化手段有以下几种。
标量替换
通过逃逸分析确定该对象不会被外部访问,并且对象可以被进一步分解时,JVM不会创建该对象,而是将该对象成员变量分解若干个被这个方法使用的成员变量所代替,这些代替的成员变量在栈帧或寄存器上分配空间,这样就不会因为没有一大块连续空间导致对象内存不够分配。
经过上面的优化和逃逸分析,我们可以确认方法内的局部变量对象未发生逃逸,也就是说方法内的这个对象的使用没有超过该方法,同时也不会超出当前线程。这种情况下,JVM实际创建这个对象的时候,可以不在堆上,而是改为直接创建这个方法所使用的成员变量来直接替代。那么经过JIT优化,就会把这个对象拆解成若干个其中包含的若干个成员变量来代替。这个过程就是标量替换。
标量 VS 聚合量
标量替换 ? 那什么是标量 ?
- 标量: 不可被进一步分解的量,而JAVA的基本数据类型就是标量(标量就是Java中的8种基本类型以及reference类型等) 。
- 聚合量: 标量的对立就是可以被进一步分解的量,称之为聚合量。 在JAVA中对象就是可以被进一步分解的聚合量。
我们把一个对象拆散,将其成员变量恢复到基本类型来访问就称为标量替换。
public String EscapeExample() {
Book book = new Book("Book");
return book.name;
}
比如上面的代码,经过编译后,就可能变成下面的代码。
public char[] EscapeExample() {
char[] charArray = {'B', 'o', 'o', 'k'};
return charArray;
}
我们可以通过 -XX:+EliminateAllocations 启动标量替换,通过 -XX:+PrintEliminateAllocations(VM option 'PrintEliminateAllocations' is notproduct and is available only in debug version of VM,只有在debug 版本有效) 查看标量替换情况。
开启标量替换参数(-XX:+EliminateAllocations),JDK7之后默认开启
标量替换Demo
public class ScalarReplace {
public static class User {
public int id;
public String name;
}
public static void alloc() {
User u = new User();//未发生逃逸
u.id = 5;
u.name = "evan";
}
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");
}
}
以上代码,经过标量替换后,就会变成:
private static void alloc() {
int x=1;
int y=2;
System.out.println("point.x="+x+"; point.y="+y);
}
public static void main(String[] args) {
long start = System.currentTimeMillis();
for (int i = 0; i < 10000000; i++) {
int x=5;
int name= "evan";
}
long end = System.currentTimeMillis();
System.out.println("花费的时间为: " + (end - start) + " ms");
}
可以看到,Point这个聚合量经过逃逸分析后,发现他并没有逃逸,就被替换成两个聚合量了。那么标量替换有什么好处呢?就是可以大大减少堆内存的占用。因为一旦不需要创建对象了,那么就不再需要分配堆内存了。
标量替换为栈上分配提供了很好的基础。
标量替换参数设置:参数-XX:+EliminateAllocations:开启了标量替换(默认打开),允许将对象打散分配在栈上。
-server -XX:+PrintGCDetails -XX:-EliminateAllocations
花费的时间为: 135 ms
Heap
PSYoungGen total 305664K, used 251659K [0x000000066ab00000, 0x0000000680000000, 0x00000007c0000000)
eden space 262144K, 96% used [0x000000066ab00000,0x000000067a0c2dc0,0x000000067ab00000)
from space 43520K, 0% used [0x000000067d580000,0x000000067d580000,0x0000000680000000)
to space 43520K, 0% used [0x000000067ab00000,0x000000067ab00000,0x000000067d580000)
ParOldGen total 699392K, used 0K [0x00000003c0000000, 0x00000003eab00000, 0x000000066ab00000)
object space 699392K, 0% used [0x00000003c0000000,0x00000003c0000000,0x00000003eab00000)
Metaspace used 2839K, capacity 4486K, committed 4864K, reserved 1056768K
class space used 303K, capacity 386K, committed 512K, reserved 1048576K
Disconnected from the target VM, address: '127.0.0.1:52113', transport: 'socket'
-server -XX:+PrintGCDetails -XX:+EliminateAllocations
花费的时间为: 4 ms
Heap
PSYoungGen total 305664K, used 20971K [0x000000066ab00000, 0x0000000680000000, 0x00000007c0000000)
eden space 262144K, 8% used [0x000000066ab00000,0x000000066bf7afb8,0x000000067ab00000)
from space 43520K, 0% used [0x000000067d580000,0x000000067d580000,0x0000000680000000)
to space 43520K, 0% used [0x000000067ab00000,0x000000067ab00000,0x000000067d580000)
ParOldGen total 699392K, used 0K [0x00000003c0000000, 0x00000003eab00000, 0x000000066ab00000)
object space 699392K, 0% used [0x00000003c0000000,0x00000003c0000000,0x00000003eab00000)
Metaspace used 3134K, capacity 4500K, committed 4864K, reserved 1056768K
class space used 344K, capacity 388K, committed 512K, reserved 1048576K
栈上分配
JIT编译器在编译期间根据逃逸分析的结果,发现如果一个对象并没有逃逸出方法的话,就可能被优化成栈上分配。分配完成后,继续在调用栈内执行,最后线程结束,栈空间被回收,局部变量对象也被回收。这样就无须进行垃圾回收了。
public String getBookName(){
Book book = new Book("Java Book");
return book.name;
}
栈上分配指方法中声明的变量和创建的对象,直接从当前线程所使用的栈上分配空间,而不在堆上创建对象。HotSpot的栈上分配实际指的就是上面提到的标量替换。栈上分配不仅更加快速而且避免了对创建对象的GC,一举多得。这也是JVM在标准对象生产流水线以外再创建这条快捷流水线的原因。
常见的栈上分配的场景
在逃逸分析中,已经说明了。分别是给成员变量赋值、方法返回值、实例引用传递。
public class StackAllocation {
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");
// 为了方便查看堆内存中对象个数,线程sleep
try {
Thread.sleep(1000000);
} catch (InterruptedException e1) {
e1.printStackTrace();
}
}
private static void alloc() {
User user = new User();//未发生逃逸
}
static class User {
}
}
同步消除
线程同步的代价是相当高的,同步的后果是降低并发性和性能。
在动态编译同步块的时候,JIT编译器可以借助逃逸分析来判断同步块所使用的锁对象是否只能够被一个线程访问而没有被发布到其他线程。如果没有,那么JIT编译器在编译这个同步块的时候就会取消对这部分代码的同步。这样就能大大提高并发性和性能。这个取消同步的过程就叫同步省略,也叫锁消除。
当一个对象经过了逃逸分析、标量替换以及栈上分配后,说明这个对象不会逃逸出当前线程。因此针对这个对象的所有同步措施都可以被删除掉,避免了加锁、去锁等一系列耗时的操作。
public String getBookName(){
Book book = new Book("Book");
synchronized (book){
book.name = "Book1";
}
return book.name;
}
比如上面的代码经过编译器优化后会变成这样。
public char[] EscapeExample() {
char[] charArray = {'B', 'o', 'o','k'};
charArray = {'B', 'o', 'o','k','1'};
return charArray
}