2017年,JEP317中提到了Graal编译器。

1

Graal编译器来自Oracle Labs更早的实验项目,随后并入GraalVM。

Graal的商业目标是啥呢?Oracle借助Stack Overflow委婉地做了表达:

2

虽然Stack Overflow随后无情删帖,我们依然记住了关键词——ultimate programming language

什么语言有勇气宣称自己是"宇宙无敌终极编程语言”?“宇宙无敌终极编程语言"又是如何实现的呢?

如果想深入探讨上述问题,我们必须先理解一个重要的知识——JVM的执行引擎

0x01.执行引擎的3大流派

  • 解释型流派:随机应变

Java的早期版本就属于解释型流派,从此也就留下了"Java性能不如C++"的口实。

如下图红线所示,源代码通过前端编译器转换成字节码。在运行态,解释器实时理解字节码,将字节码翻译成CPU可以执行的机器码。

3_1

  • 提前编译流派:有备而来

如下图蓝线所示,如果我们暂不纠结"dll/so不等于机器码"的细节,提前编译器直接将源代码转换为抽象的机器码。

接受这个流派,就意味着要承认**“Compile Once, Run Anywhere”(一次编译,到处去浪)**只是个美好的愿望。

因此,IBM于1996年发布了提前编译器(IBM High Performance Compiler for Java)之后很长一段时间,提前编译流派就没有太多的故事和发展。

3_2

  • 即时编译流派:随机应变+有备而来

即时编译流派(JIT)的思路是解释器编译器协同工作。

如下图红色线和紫色线所示,解释器先工作,过程中会发现某些字节码被频繁解释,于是即时编译器开始工作,即时编译器将这些热点代码转换为机器码,当热点代码需要再次执行时就不用解释了。

3_3

0x02.为什么历史选择了即时编译流派?

解释型流派有点像吃铁板烧,顾客根据当时的心情,跟厨师说:“来一份马粪胆搭配牡丹虾、北海道带子、鳕场蟹”,厨师理解了顾客意图,现场制作美味。

4

随机应变的执行过程,给我们带来一些好处:

  • 减少准备工作的等待时间

如果是订酒席,我们就需要提前点菜、厨师提前做好。现场的就餐过程,只是把做好的菜端上来。

稍大型的C++项目,将源代码编译成机器码,就是个漫长的过程。这个过程就像提前点菜、厨师提前把菜做好。

  • 具备运行时修正的可能性

吃铁板烧的顾客点完菜,可能随后改了主意:“北海道带子换黑毛和牛吧”。如果是订酒席,厨师已经把菜做好了,顾客显然不能修改了。

边解释边执行,就具备了**“动态性”**,这也是Java、Python、Ruby等语言的动态性的理论基础。

随机应变的执行过程,也带来了弊端:

现场点菜,厨师越慢逼格越高,因为吃铁板烧不是个赶时间的事。但程序执行截然相反,执行效率堪比压榨CPU的性能极限。

从宏观上看,随机应变相较于有备而来,少了准备过程,应该会慢。

从微观上看,为什么解释型相比于提前编译型慢呢?关键点在于编译优化。

例如:源代码中有一段空循环,编译器会发现这段无用功,输出机器码时进行抹除,然而解释器只是无脑地循环10万次。

1
2
3
for(int i=0;i<100000;i++){
  ;
}

本文无法展开编译优化这个更大的课题,但至少我们可以获得如下认知:

解释型流派与提前编译流派是天平的两端

即时编译流派本质是,在"边解释边执行"的基础上,实现了动态编译

0x03.为什么即时编译器不止一种?

前文,我们提到了编译优化,优化手段有的很常规,有的却很激进。本文摘录了一些激进的优化:

Dead Code Elimination:无用代码消除

Loop Unrolling:循环展开

Loop Expression Hoisting:循环表达式外提

Common Subexpression Elimination:消除公共子表达式

Constant Propagation:常量传播

Basic Block Reordering:基本块重排序

Range Check Elimination:范围检查消除

Null Check Elimination:空值检查消除

Guarding Inlining:守护内联

Branch Frequency Prediction:分支频率预测

如下图所示,Hotspot虚拟机的即时编译器就包含了C1和C2,同时在Java10加入了C2的替代者Graal。

C1的职责只是常规的编译优化,C2则承担了更多激进优化的任务。

5

0x04.解释器与多种即时编译器的配合关系

即时编译流派,首先还是解释器工作,热点代码触发即时编译器,即时编译一旦出现激进优化失败,就把执行权还给解释器。

6

以Hotspot为例,我们进一步打开解释器与即时编译器的配合过程:

  • Java 6以前,1个解释器+1个即时编译器

通过JVM的参数配置,我们只有2种选择:要么用**“解释器+C1即时编译器”**,要么用**“解释器+C2即时编译器”**。

这种做法的缺点就是把选择困难症留给了Java程序员——我怎么知道这段代码适合激进优化还是保守优化呢?

实际上,大部分情况是适合激进优化和保守优化的代码是纠缠在一起的。

  • Java 7以后,1个解释器+动态选择即时编译器

Java7以后,引入了”分层编译“的理念,进而支持了动态选择即时编译器。

理解"分层编译”,先看"层”:

第0层:程序解释运行。解释器不开启性能监控功能。

第1层:C1编译。进行简单和可靠的优化,不开启性能监控。

第2层:C1编译。仅开启方法及回边次数统计等有限的性能监控。

第3层:C1编译。开启全部性能监控,进一步收集分支跳转、虚方法调用版本等信息。

第4层:C2编译。启用更多耗时较长的优化,还会依据性能监控信息进行不可靠的激进优化。

再看"分”——分是指**“分场景”**:

普通场景:此场景下,热点代码触发C1,C1逐步开启全量性能监控,就会触发C2。

简单代码场景:此场景下,热点代码触发C1,C1逐步开启全量监控,发现没必要C2,则关闭全量监控。

C1繁忙场景:此场景下,C1繁忙,热点代码直接触发了C2。

C2繁忙场景:此场景下,C2繁忙,热点代码触发C1,先开启有限的监控,随后进行全量监控,最后进入C2。

分场景触发的过程,周全且精细化地逐级开启性能监控,充分体现了JVM设计的精巧。这种精巧的设计其实也可以移植到性能敏感的产品中。

7

参考:https://www.infoq.cn/article/java-10-jit-compiler-graal

0x05.总结

本文的目标是宏观阐述JVM执行引擎的全貌:

1.JVM执行引擎的3大流派

2.如何看待即时编译流派的利与弊

3.解释器与即时编译器的配合方式——分层编译

结合笔者另外两篇文章《【类加载机制】从一道面试题开始》、《【运行时数据区】用仓库管理员的视角理解运行时数据区》,我们已经从宏观上看到了JVM的三大构成,笔者将在后续文章中,与读者们继续深入与探索个中细节。

image-20200916175340096