手记

JVM运行期优化

一、前言

JVM运行期的优化主要是指程序在编译成字节码之后,JVM通过解释器去解释执行,再针对程序运行的资源占用等情况进行分析然后做出的一系列的优化。Java程序的效率之所以较高(即使是和接近底层的c/c++语言相比较,在Java内部的即时编译器优化的情况下,很多应用场景下效率也毫不逊色),是离不开JVM对程序进行的优化的,这篇博客就来总结一下虚拟机在背后给我们做的工作(针对的是目前市面上主流的HotSpot虚拟机而言)。

二、JVM的即时编译器

1.解释器与编译器

JVM虚拟机采用的是解释器与编译器共存的架构,这样的搭配平均情况下能最好的发挥程序的性能。解释器与编译器各有优势:当程序需要迅速启动和快速执行的时候,解释器可以首先发挥作用,省去编译的时间,能够立即执行;而程序运行一段时间后,即时编译器(JIT)开始发挥作用,会根据程序代码的运行状况,把越来越多的代码编译成本地代码,以提高执行的效率。当程序在运行环境中占用内存资源较多时,可以使用解释器执行来减少内存的占用率。同时,当编译器进行过早优化时,解释器可以让编译器根据概率来选择一些大多数情况下都能提高运行速度的优化手段,但如果这次优化后,导致后续程序的运行出现特殊状况,这时即时编译器又会通过逆优化来回退到解释器执行。整个过程也就是下图所示

20170601114304750.png


从上图也可以看出,编译器里面内置了Client和Server两个编译器(通常也被称为c1和c2编译器),JVM采用了分层编译的方式来使程序启动的响应速度与运行效率之间达到平衡:


1.第0层,程序解释执行,解释器不开启性能监控,可触发第一层编译。
2.第1层,c1编译,将字节码编译为本地代码,进行简单可靠的优化。
3.第2层,c2编译,将字节码编译为本地代码,但是会进行一些编译耗时较长的优化。

2.编译对象与触发条件

在JVM运行过程中会被即使编译器编译的主要有被多次调用的方法和被多次执行的循环体这些热点代码。这里需要注意,对于第一种情况,直接理所当然的以整个方法作为JIT编译的对象,而对于后面一种情况,尽管编译动作是由循环体触发的,编译器还是会以它所在的整个方法为编译对象。这种编译是发生在方法执行的过程中,因此也被称为栈上替换(OSR,方法栈帧还在栈上,方法就被替换了)

热点代码的判定方式

在现在的虚拟机中,主要有以下几种方式:

1.基于采样的热点探测:采用这种方法的虚拟机会周期性地检查各个线程的栈顶,如果发现某个方法经常出现在栈顶,这个方法就是热点方法。这种方法实现起来较为简单,可以很容易的获取方法调用的关系,缺点是由于有线程阻塞或别的因素影响,无法精确的对热点进行探测。
2.基于计数器的热点探测:采用这种方法的虚拟机会为每个方法(甚至是代码块)建立并维护计数器,统计方法的执行次数,执行次数超过一定的阀值就会认为它是热点方法,这种方式更加精确和严谨,但统计时需要为每个方法建立并维护计数器,而且不能获取方法的调用关系,实现起来较为麻烦。
3.基于踪迹(Trace)的热点探测:采用这种方式的虚拟机是将一段频繁执行的代码作为一个编译单元,并仅对该代码片段进行编译,该代码片段由一个线性且连续的指令序列组成,仅有一个入口,但有多个出口。也就是说,基于踪迹而编译的热点代码不仅仅局限在一个单独的方法或者代码快中,一条Trace可能对应多个方法,代码中频繁执行的路径就可能被识别成不同的踪迹。因此这种方法有着更高的精度,并且能够避免编译不是频繁执行的代码,减少不必要的编译开销,但这种方法的实现就更为的复杂。在Android早期的Dalvik虚拟机的JIT编译器就是使用的这种方式(从Android4.4开始,Google就把Android中的Dalvik虚拟机无缝切换到了ART虚拟机,这里简单的说一下,Android上面的虚拟机是按照JVM的部分规范去实现的一种类似的东西,并不属于Java虚拟机,并且与JVM最大的不同是就是Dalvik/ART基于寄存器,而JVM基于栈,Android里面是每个程序都对应着一个单独的虚拟机,也是一个单独的进程)。


而在JVM虚拟机中使用的是基于计数器的热点探测,至于为什么不使用基于踪迹的热点探测,我想一个是实现上的困难,并且基于栈的JVM与寄存器的执行速度无法相比,繁琐的优化反而会造成得不偿失的局面。这种方式又包含了两类计数器:方法调用计数器和回边计数器

方法掉用计数器:统计一段时间内方法被调用的次数,当超过一个时间限度,它的调用次数仍然不足以给JIT编器编译,这个方法的调用计数久会减半。
回边计数器:主要是统计循环体内的代码执行的次数,在字节码遇到控制流后向后跳转的指令称为回边,建立回边计数器也是为了触发OSR。因为有些情况下,比如空的循环,照样会执行对应的次数,但它是直接跳转到自己,所以JIT编译器去编译这种代码是没有任何意义的。

3.编译过程

第一阶段,首先在字节码层做一些系列的优化,如方法内联、常量传播等,然后将字节码构造成一种高级中间代码(HIR),HIR使用静态单分配(根据调用的方法和它接收的参数)的形式来代表代码值,使得构造HIR时的优化更加容易实现。
第二阶段,对传来的HIR进行空值检查、范围检查消除等优化,然后从HIR中产生低级中间代码(LIR)。
最后阶段:使用线性扫描算法,在LIR上分配寄存器,并在LIR上做窥孔优化(局部的优化方式,编译器仅仅在一个或者多个基本块中,针对已经生成的代码,结合CPU自己指令的特点,通过一些认为可能带来性能提升的转换规则,或者通过整体的分析,通过指令转换,提升代码性能),然后产生机器代码。

整个大致的过程如下图所示


1688022-2ca3e27ef583f275.png

三、编译优化技术

JVM团队几乎把所有代码优化的措施集中在了即时编译器中,所以即时编译器产生的本地代码要比原来的解释器解释执行字节码所产生的本地代码要更优。优化的技术非常的多,这里就不做过多的描述了。重点整理一下JVM中比较前沿的逃逸分析
逃逸分析并不是直接优化代码的手段,而是为其它优化方式提供的分析技术。逃逸分析的基本行为就是分析对象的动态作用域(当一个对象在方法中被定义之后,可能被外部方法所引用,例如作为参数传递到其他方法中,称为方法逃逸;可能被外部线程访问到,比如赋值给类变量或可以在其它线程中访问的实例变量,称为线程逃逸)。如果别的方法或线程无法通过任何途径访问到这个对象,就能为这个对象下列高效的优化。

1.栈上分配

在JVM中,创建对象所需的内存是从Java堆上分配出来的,而Java堆中的对象对各个线程都是共享和可见的,只要持有这个对象的引用,就能获取Java堆中所储存的该对象的数据,JVM中的垃圾收集器可以回收堆中不再使用的对象,但是筛选可回收对象以及回收和整理内存都需要比较大的时间开销。如果能确定一个对象不会出现方法逃逸的情况,就可以直接在栈上分配对象的内存,对象随着栈帧出栈而消耗,这样一来可以很大程度的减少垃圾收集系统的压力。

2.同步消除

如果确定一个对象不会出现线程逃逸,也就是不会被其它线程访问到,那么对这个对象的的读写操作肯定不会出现竞争,就可以直接去除同步的措施,线程的同步操作也是一个比较耗时的操作,

3.标量替换

标量是指一个数据无法被分解成更小的数据来表示了,例如Java中的原始数据类型就是这样。相反,聚合量就表示一个能继续分解的数据,Java中的对象就是如此。所以如果逃逸分析证明了一个对象不会被外部访问,那么就可以根据程序访问的情况,把对象拆分,然后把里面用到的成员变量替换成原始类型,这种方式就被称为标量替换。因为标量也是直接存储在栈上面的,而栈上储存的数据有大概率会被分配到CPU的寄存器上,并且存储到栈上也方便后续的优化操作。
逃逸分析也是一种比较重要的间接优化手段,但是由于逃逸分析也会有过多的消耗,和前面提到的基于踪迹的热点代码探测的手段一样,可能会获得的利益小于自身的消耗,所以虚拟机也去权衡是否需要完全准确的逃逸分析。

四、总结

主体内容还是来自《深入理解Java虚拟机》,也穿插了书中没有细讲的一些内容,这篇博客主要内容虽然是总结的JVM运行期的优化,但其实基本上都是在描述JIT编译器在背后所做的工作,了解这些能对Java为何会如此高效有一个大体的认识,也能增加对写出的代码更加深层次的思考,这篇博客就先到这里了。



作者:丶legend
链接:https://www.jianshu.com/p/edbe216920a1


0人推荐
随时随地看视频
慕课网APP