公司动态
java中的即时编译(JIT)简介

Java发展这么多年一直长青,很大一部分得益于开发人员长期对其坚持不懈的优化:写得更少,跑得更快!JIT就是其中一项十分重要的优化。

JIT全程Java Intime Compiler,即Java即时编译器。咦为啥Java的编译器是一项优化呢?Java本来不就是编译型语言吗?听我细细道来。

从我们最早接触Java编程开始,学习到的就是手写java文件,然后javac编译、java运行主方法。

如果这里都看不懂可能不适合阅读本文

javac会把.java文件编译成.class文件,所以我们说Java是编译型语言。当然Java是强类型的语言,通常我们说强类型的是编译型的,弱类型的脚本语言(也叫动态语言,相对应的强类型语言叫静态语言)。.class文件格式就是“字节码”。编译的过程见《Java文件的编译》

为了实现“一次编写,随处运行”的目标,字节码会被jvm运行。而这里的运行就是解释执行,jvm是一行一行阅读字节码文件中的jvm指令,并把它翻译成机器的cpu指令。这个过程就比较慢了(相对中低级语言而言)。

Java为了提高开发和运行效率,已经对语言和jvm在多方面做了优先,包括垃圾回收器、各种锁机制,甚至最简单的分支预测都大力优化。解释执行的效率自然也被纳入优化范围。在1996年10月25号,当时的Java东家Sun发布了第一款JIT编译器。那时还是java 2刚出来(Java1 和Java2差异较大,我们现在使用的jdk都是Java2上的迭代),离现在已经20多年了。目前JIT已经是默认开启的,因为它带来的效果明显。除非通过参数指定不使用。

JIT的动机基于“二八定律”,20%的热点代码占据了程序80%的执行时间

即使开启了JIT,也少不了代码编译和字节码解释的过程。JIT处理的是热点代码(hotspot code,或叫热门代码)。热点代码就是频繁执行的代码块,比如循环里面的代码。JIT有一套逻辑判断是否热点代码。

既然JIT处理后的是机器能够快速执行的代码,为啥还要解释执行呢,干嘛不把全部代码编译成机器代码呢?这是由于编译本地代码比较费时间,而且编译后还要进行进一步的优化导致耗时更久;而解释器是能够立即解释字节码文件的,毕竟我们的应用放到服务器上的时候就已经是字节码文件了,解释器可以拿来直接用。而且解释器执行的时候占用的内存更小,在内存受限的场景难以使用编译器(比如手机上)。编译器会概率性地选择多数时候都能提升运行效率的手段进行优化,如果“优化”后发现还不如不优化(甚至执行有问题)就得“逆优化”,回退到解释执行状态。

我们可以通过最简单的查看Java版本的命令查看Java是否使用了编译器:


最后输出的“mixed mode”代表是混合模式,也就是先解释执行,并逐步将热点代码代替为机器代码。不使用编译器的模式叫“interpreted mode”;优先使用编译器的模式叫“compiled mode”,compiled mode会优先采用编译方式执行程序,如果编译执行有问题就回退到解释执行。

谷歌的V8是没有解释器的(没错,就是那个执行JS的)。V8的原理可以参阅这个 Quora问答

理论上讲,经过JIT的Java程序运行效率要高于C++。因为C++是静态编译,而JIT在运行中可以参考运行时数据。

HotSpot虚拟机中有两个编译器,一个是给客户端用的叫client Compiler,另一个是服务器用的叫Server Compiler。一般的,把Client Compiler也叫C1编译器,Server Compiler叫C2编译器或Opto编译器。虚拟机会根据自身版本与宿主机的硬件性能自动选择运行模式,也可以使用 “-client”或“-server”参数去强制指定虚拟机运行在Client模式或Server模式。

热点代码有两类:

  • 被多次执行的方法
  • 多次执行的循环体

怎么统计“多次”呢?虚拟机为每个代码块和方法设置了计数器,执行一次就加1。超过限定次数就认为是热点代码,开始JIT处理。给JIT去处理只是一个请求,并不会立即同步等待结果。因为JIT编译比较耗时,在编译完成前会继续解释执行。编译器处理都是以方法为单位,所以第一类热点代码是标准的JIT编译方式;对于第二种热点代码,JIT编译器会处理包含该循环的方法。流程很简单,细节很复杂。下图来自极客学院:javac 编译与 JIT 编译

jit
考虑这个问题:方法在执行时会被放到栈上,对于计算密集型的方法,大量计算任务都在一个方法内循环。这满足第二类热点代码,会被编译。但是方法并没有退出重新执行,编译后的代码怎么能够执行呢?

这个对于早期的JIT的确是个问题,不过现在JVM用到了”栈上替换“的技术:在执行过程中如果编译版本可用了,虚拟机会暂停,把编译版本的方法替换到栈上。反之亦然,上面说过逆优化。

那到底是超过多少次?
HotSpot虚拟机有两种计数器(方法会同时记录这两个计数),它们的阈值并不同。

  • 调用次数计数器,可以通过-XX:CompileThreadhold参数指定阈值,不指定默认C1是1500次,C2是1万次。
  • 字节码中向之前跳转的指令叫“回边”,回边次数是回边计数器。明显这个针对的是第二类热点代码。它的阈值是算出来的,公式如下

第一个参数CompileThreshold就是调用计数器,后面两个也都可以通过-XX指定。默认InterpreterProfilePercentage是33,而OnStackReplacePercentage的默认值在客户端和服务器模式不一样,分别是933和140,所以阈值分别是13500和10700。

Tiered Compilation是Java7中出现的,目的是整合C1的快速编译和C2的快速执行。因为C2使用了“激进”的优化手段,编译较慢。Java7以前,一般要求快速启动的GUI程序会选择C1,偏好性能的服务器程序使用C2。

Tiered Compilation将编译分为0到4五级,怎么区分呢?看图吧,我并不太懂(出处见水印,侵删):

Tiered Compilation
好吧其实图中并没他们的区别,只是有无profiling而已。

java 10中引入了编译更慢的Graal代替C2成为了第五级编译器。C2是用C++编写的,Graal是Java编写的。只是两种语言而已,为什么要用Java重写一个编译器呢?

因为C2中的全部优化能力已经全部移植到了Graal上,而Graal上面有一些算法(比如inlining算法及partial escape analysis)并不能用C++实现。

Inlining被称为优化之母,因为它能引发更深的优化,能将对getter、setter的访问优化成单一内存访问。

常见的逃逸分析针对的就是锁去除。如果对象被单一线程访问,则可去除锁;如果对象是堆分配且仅被单一方法访问,则可转化成栈分配,并伴随将对字段的访问替换成对操作数的访问,从而进一步将栈分配转换成虚拟分配。另外一大逃逸分析场景是for-loop。

Java10默认激进优化器依然是C2,要使用Graal需要使用参数-XX:+UnlockExperimentalVMOptions -XX:+UseJVMCICompiler来开启。

参考文献:

动态编译与性能测量

Java 即时编译器JIT机制以及编译优化

平台注册入口