11.2 即时编译器

目前主流的两款商用Java虚拟机(HotSpot、OpenJ9)里,Java程序最初都是通过解释器 (Interpreter)进行解释执行的,当虚拟机发现某个方法或代码块的运行特别频繁,就会把这些代码认定为“热点代码”(Hot Spot Code),为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成本地机器码,并以各种手段尽可能地进行代码优化,运行时完成这个任务的后端编译器被称为即时编译器。本节我们将会了解HotSpot虚拟机内的即时编译器的运作过程,此外,我们还将解决以下几个问题:

  • 为何HotSpot虚拟机要使用解释器与即时编译器并存的架构?
  • 为何HotSpot虚拟机要实现两个(或三个)不同的即时编译器?
  • 程序何时使用解释器执行?何时使用编译器执行?
  • 哪些程序代码会被编译为本地代码?如何编译本地代码?
  • 如何从外部观察到即时编译器的编译过程和编译结果?

11.2.1 解释器与编译器

尽管并不是所有的Java虚拟机都采用解释器与编译器并存的运行架构,但目前主流的商用Java虚拟机,譬如HotSpot、OpenJ9等,内部都同时包含解释器与编译器[^1],解释器与编译器两者各有优势: 当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即运行。当程序启动后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码,这样可以减少解释器的中间损耗,获得更高的执行效率。当程序运行环境中内存资源限制较大,可以使用解释执行节约内存(如部分嵌入式系统中和大部分的JavaCard应用中就只有解释器的存在),反之可以使用编译执行来提升效率。同时,解释器还可以作为编译器激进优化时后备的“逃生门”(如果情况允许, HotSpot虚拟机中也会采用不进行激进优化的客户端编译器充当“逃生门”的角色),让编译器根据概率选择一些不能保证所有情况都正确,但大多数时候都能提升运行速度的优化手段,当激进优化的假设不成立,如加载了新类以后,类型继承结构出现变化、出现“罕见陷阱”(Uncommon Trap)时可以通过逆优化(Deoptimization)退回到解释状态继续执行,因此在整个Java虚拟机执行架构里,解释器与编译器经常是相辅相成地配合工作,其交互关系如图11-1所示。

image-20211126110919008

图11-1 解释器与编译器的交互

HotSpot虚拟机中内置了两个(或三个)即时编译器,其中有两个编译器存在已久,分别被称为“客户端编译器”(Client Compiler)和“服务端编译器”(Server Compiler),或者简称为C1编译器和C2编译器(部分资料和JDK源码中C2也叫Opto编译器),第三个是在JDK 10时才出现的、长期目标是代替C2的Graal编译器。Graal编译器目前还处于实验状态,本章将安排出专门的小节对它讲解与实战,在本节里,我们将重点关注传统的C1、C2编译器的工作过程。

在分层编译(Tiered Compilation)的工作模式出现以前,HotSpot虚拟机通常是采用解释器与其中一个编译器直接搭配的方式工作,程序使用哪个编译器,只取决于虚拟机运行的模式,HotSpot虚拟机会根据自身版本与宿主机器的硬件性能自动选择运行模式,用户也可以使用“-client”或“-server”参数去强制指定虚拟机运行在客户端模式还是服务端模式。

无论采用的编译器是客户端编译器还是服务端编译器,解释器与编译器搭配使用的方式在虚拟机中被称为“混合模式”(Mixed Mode),用户也可以使用参数“-Xint”强制虚拟机运行于“解释模式”(Interpreted Mode),这时候编译器完全不介入工作,全部代码都使用解释方式执行。另外,也可以使用参数“-Xcomp”强制虚拟机运行于“编译模式”(Compiled Mode),这时候将优先采用编译方式执行程序,但是解释器仍然要在编译无法进行的情况下介入执行过程。可以通过虚拟机的“- version”命令的输出结果显示出这三种模式,内容如代码清单11-1所示,请读者注意黑体字部分。

代码清单11-1 虚拟机执行模式
1
2
3
4
5
6
7
8
9
10
11
12
13
14
$java -version 
java version "11.0.3" 2019-04-16 LTS
Java(TM) SE Runtime Environment 18.9 (build 11.0.3+12-LTS)
Java HotSpot(TM) 64-Bit Server VM 18.9 (build 11.0.3+12-LTS, mixed mode)

$java -Xint -version
java version "11.0.3" 2019-04-16 LTS
Java(TM) SE Runtime Environment 18.9 (build 11.0.3+12-LTS)
Java HotSpot(TM) 64-Bit Server VM 18.9 (build 11.0.3+12-LTS, interpreted mode)

$java -Xcomp -version
java version "11.0.3" 2019-04-16 LTS
Java(TM) SE Runtime Environment 18.9 (build 11.0.3+12-LTS)
Java HotSpot(TM) 64-Bit Server VM 18.9 (build 11.0.3+12-LTS, compiled mode)

由于即时编译器编译本地代码需要占用程序运行时间,通常要编译出优化程度越高的代码,所花费的时间便会越长;而且想要编译出优化程度更高的代码,解释器可能还要替编译器收集性能监控信息,这对解释执行阶段的速度也有所影响。为了在程序启动响应速度与运行效率之间达到最佳平衡, HotSpot虚拟机在编译子系统中加入了分层编译的功能[^2],分层编译的概念其实很早就已经提出,但直到JDK 6时期才被初步实现,后来一直处于改进阶段,最终在JDK 7的服务端模式虚拟机中作为默认编译策略被开启。分层编译根据编译器编译、优化的规模与耗时,划分出不同的编译层次,其中包括:

  • 第0层。程序纯解释执行,并且解释器不开启性能监控功能(Profiling)。
  • 第1层。使用客户端编译器将字节码编译为本地代码来运行,进行简单可靠的稳定优化,不开启 性能监控功能。
  • 第2层。仍然使用客户端编译器执行,仅开启方法及回边次数统计等有限的性能监控功能。
  • 第3层。仍然使用客户端编译器执行,开启全部性能监控,除了第2层的统计信息外,还会收集如 分支跳转、虚方法调用版本等全部的统计信息。
  • 第4层。使用服务端编译器将字节码编译为本地代码,相比起客户端编译器,服务端编译器会启 用更多编译耗时更长的优化,还会根据性能监控信息进行一些不可靠的激进优化。

以上层次并不是固定不变的,根据不同的运行参数和版本,虚拟机可以调整分层的数量。各层次编译之间的交互、转换关系如图11-2所示。

实施分层编译后,解释器、客户端编译器和服务端编译器就会同时工作,热点代码都可能会被多次编译,用客户端编译器获取更高的编译速度,用服务端编译器来获取更好的编译质量,在解释执行的时候也无须额外承担收集性能监控信息的任务,而在服务端编译器采用高复杂度的优化算法时,客户端编译器可先采用简单优化来为它争取更多的编译时间。

image-20211126111436186

图11-2 分层编译的交互关系[^3]

[^1]: 作为曾经的三大商用虚拟机之一的JRockit是个例外,它内部没有解释器,因此会存在本书中所说 的“启动响应时间长”之类的缺点,但它主要是面向服务端的应用,这类应用一般不会重点关注启动时 间,而且JRockit目前已经不再发展了。
[^2]: 分层编译在JDK 6时期出现,到JDK 7之前都需要使用-XX:+TieredCompilation参数来手动开启, 如果不开启分层编译策略,而虚拟机又运行在服务端模式,服务端编译器需要性能监控信息提供编译 依据,则是由解释器收集性能监控信息供服务端编译器使用。分层编译的相关资料可参见: http://weblogs.java.net/blog/forax/archive/2010/09/04/tiered-compilation。
[^3]: 图片来源:https://www.infoq.cn/article/java-10-jit-compiler-graal/。

11.2.2 编译对象与触发条件

在本章概述中提到了在运行过程中会被即时编译器编译的目标是“热点代码”,这里所指的热点代码主要有两类,包括:

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

前者很好理解,一个方法被调用得多了,方法体内代码执行的次数自然就多,它成为“热点代码”是理所当然的。而后者则是为了解决当一个方法只被调用过一次或少量的几次,但是方法体内部存在循环次数较多的循环体,这样循环体的代码也被重复执行多次,因此这些代码也应该认为是“热点代码”[^1]。

对于这两种情况,编译的目标对象都是整个方法体,而不会是单独的循环体。第一种情况,由于是依靠方法调用触发的编译,那编译器理所当然地会以整个方法作为编译对象,这种编译也是虚拟机中标准的即时编译方式。而对于后一种情况,尽管编译动作是由循环体所触发的,热点只是方法的一部分,但编译器依然必须以整个方法作为编译对象,只是执行入口(从方法第几条字节码指令开始执行)会稍有不同,编译时会传入执行入口点字节码序号(Byte Code Index,BCI)。这种编译方式因为编译发生在方法执行的过程中,因此被很形象地称为“栈上替换”(On Stack Replacement,OSR),即方法的栈帧还在栈上,方法就被替换了。

读者可能还会有疑问,在上面的描述里,无论是“多次执行的方法”,还是“多次执行的代码块”, 所谓“多次”只定性不定量,并不是一个具体严谨的用语,那到底多少次才算“多次”呢?还有一个问题,就是Java虚拟机是如何统计某个方法或某段代码被执行过多少次的呢?解决了这两个问题,也就解答了即时编译被触发的条件。

要知道某段代码是不是热点代码,是不是需要触发即时编译,这个行为称为“热点探测”(Hot Spot Code Detection),其实进行热点探测并不一定要知道方法具体被调用了多少次,目前主流的热点探测判定方式有两种[^2],分别是:

  • 基于采样的热点探测(Sample Based Hot Spot Code Detection)。采用这种方法的虚拟机会周期性 地检查各个线程的调用栈顶,如果发现某个(或某些)方法经常出现在栈顶,那这个方法就是“热点方 法”。基于采样的热点探测的好处是实现简单高效,还可以很容易地获取方法调用关系(将调用堆栈展 开即可),缺点是很难精确地确认一个方法的热度,容易因为受到线程阻塞或别的外界因素的影响而 扰乱热点探测。
  • 基于计数器的热点探测(Counter Based Hot Spot Code Detection)。采用这种方法的虚拟机会为 每个方法(甚至是代码块)建立计数器,统计方法的执行次数,如果执行次数超过一定的阈值就认为 它是“热点方法”。这种统计方法实现起来要麻烦一些,需要为每个方法建立并维护计数器,而且不能 直接获取到方法的调用关系。但是它的统计结果相对来说更加精确严谨。

这两种探测手段在商用Java虚拟机中都有使用到,譬如J9用过第一种采样热点探测,而在HotSpot虚拟机中使用的是第二种基于计数器的热点探测方法,为了实现热点计数,HotSpot为每个方法准备了两类计数器:方法调用计数器(Invocation Counter)和回边计数器(Back Edge Counter,“回边”的意思就是指在循环边界往回跳转)。当虚拟机运行参数确定的前提下,这两个计数器都有一个明确的阈值,计数器阈值一旦溢出,就会触发即时编译。

我们首先来看看方法调用计数器。顾名思义,这个计数器就是用于统计方法被调用的次数,它的默认阈值在客户端模式下是1500次,在服务端模式下是10000次,这个阈值可以通过虚拟机参数-XX: CompileThreshold来人为设定。当一个方法被调用时,虚拟机会先检查该方法是否存在被即时编译过的版本,如果存在,则优先使用编译后的本地代码来执行。如果不存在已被编译过的版本,则将该方法的调用计数器值加一,然后判断方法调用计数器与回边计数器值之和是否超过方法调用计数器的阈值。一旦已超过阈值的话,将会向即时编译器提交一个该方法的代码编译请求。

如果没有做过任何设置,执行引擎默认不会同步等待编译请求完成,而是继续进入解释器按照解释方式执行字节码,直到提交的请求被即时编译器编译完成。当编译工作完成后,这个方法的调用入口地址就会被系统自动改写成新值,下一次调用该方法时就会使用已编译的版本了,整个即时编译的交互过程如图11-3所示。

在默认设置下,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间之内方法被调用的次数。当超过一定的时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器编译,那该方法的调用计数器就会被减少一半,这个过程被称为方法调用计数器热度的衰减(Counter Decay),而这段时间就称为此方法统计的半衰周期(Counter Half Life Time), 进行热度衰减的动作是在虚拟机进行垃圾收集时顺便进行的,可以使用虚拟机参数-XX:- UseCounterDecay来关闭热度衰减,让方法计数器统计方法调用的绝对次数,这样只要系统运行时间足够长,程序中绝大部分方法都会被编译成本地代码。另外还可以使用-XX:CounterHalfLifeTime参数设置半衰周期的时间,单位是秒。

image-20211126112538847

图11-3 方法调用计数器触发即时编译

现在我们再来看看另外一个计数器——回边计数器,它的作用是统计一个方法中循环体代码执行的次数[^3],在字节码中遇到控制流向后跳转的指令就称为“回边(Back Edge)”,很显然建立回边计数器统计的目的是为了触发栈上的替换编译。

关于回边计数器的阈值,虽然HotSpot虚拟机也提供了一个类似于方法调用计数器阈值-XX: CompileThreshold的参数-XX:BackEdgeThreshold供用户设置,但是当前的HotSpot虚拟机实际上并未使用此参数,我们必须设置另外一个参数-XX:OnStackReplacePercentage来间接调整回边计数器的阈值,其计算公式有如下两种。

  • 虚拟机运行在客户端模式下,回边计数器阈值计算公式为:方法调用计数器阈值(-XX: CompileThreshold)乘以OSR比率(-XX:OnStackReplacePercentage)除以100。其中-XX: OnStackReplacePercentage默认值为933,如果都取默认值,那客户端模式虚拟机的回边计数器的阈值为 13995。
  • 虚拟机运行在服务端模式下,回边计数器阈值的计算公式为:方法调用计数器阈值(-XX: CompileThreshold)乘以(OSR比率(-XX:OnStackReplacePercentage)减去解释器监控比率(-XX: InterpreterProfilePercentage)的差值)除以100。其中-XX:OnStack ReplacePercentage默认值为140,- XX:InterpreterProfilePercentage默认值为33,如果都取默认值,那服务端模式虚拟机回边计数器的阈 值为10700。

当解释器遇到一条回边指令时,会先查找将要执行的代码片段是否有已经编译好的版本,如果有的话,它将会优先执行已编译的代码,否则就把回边计数器的值加一,然后判断方法调用计数器与回边计数器值之和是否超过回边计数器的阈值。当超过阈值的时候,将会提交一个栈上替换编译请求, 并且把回边计数器的值稍微降低一些,以便继续在解释器中执行循环,等待编译器输出编译结果,整个执行过程如图11-4所示。

image-20211126112647569

图11-4 回边计数器触发即时编译

与方法计数器不同,回边计数器没有计数热度衰减的过程,因此这个计数器统计的就是该方法循环执行的绝对次数。当计数器溢出的时候,它还会把方法计数器的值也调整到溢出状态,这样下次再进入该方法的时候就会执行标准编译过程。

最后还要提醒一点,图11-2和图11-3都仅仅是描述了客户端模式虚拟机的即时编译方式,对于服务端模式虚拟机来说,执行情况会比上面描述还要复杂一些。从理论上了解过编译对象和编译触发条件后,我们还可以从HotSpot虚拟机的源码中简单观察一下这两个计数器,在MehtodOop.hpp(一个methodOop对象代表了一个Java方法)中,定义了Java方法在虚拟机中的内存布局,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// |------------------------------------------------------| 
// | header |
// | klass |
// |------------------------------------------------------|
// | constMethodOop (oop) |
// | constants (oop) |
// |------------------------------------------------------|
// | methodData (oop) |
// | interp_invocation_count |
// |------------------------------------------------------|
// | access_flags |
// | vtable_index |
// |------------------------------------------------------|
// | result_index (C++ interpreter only) |
// |------------------------------------------------------|
// | method_size | max_stack |
// | max_locals | size_of_parameters |
// |------------------------------------------------------|
// |intrinsic_id| flags | throwout_count |
// |------------------------------------------------------|
// | num_breakpoints | (unused) |
// |------------------------------------------------------|
// | invocation_counter |
// | backedge_counter |
// |------------------------------------------------------|
// | prev_time (tiered only, 64 bit wide) |
// | |
// |------------------------------------------------------|
// | rate (tiered) |
// |------------------------------------------------------|
// | code (pointer) |
// | i2i (pointer) |
// | adapter (pointer) |
// | from_compiled_entry (pointer) |
// | from_interpreted_entry (pointer) |
// |------------------------------------------------------|
// | native_function (present only if native) |
// | signature_handler (present only if native) |
// |------------------------------------------------------|

在这段注释所描述的方法内存布局里,每一行表示占用32个比特,从中我们可以清楚看到方法调用计数器和回边计数器所在的位置和数据宽度,另外还有from_compiled_entry和from_interpreted_entry 两个方法入口所处的位置。

[^1]: 还有一个不太上台面但其实是Java虚拟机必须支持循环体触发编译的理由,是诸多跑分软件的测试 用力通常都属于第二种,如果不去支持跑分会显得成绩很不好看。
[^2]: 除这两种方式外,还有其他热点代码的探测方式,如基于“踪迹”(Trace)的热点探测在最近相当流 行,像FireFox里的TraceMonkey和Dalvik里新的即时编译器都是用了这种热点探测方式。
[^3]: 准确地说,应当是回边的次数而不是循环次数,因为并非所有的循环都是回边,如空循环实际上就 可以视为自己跳转到自己的过程,因此并不算作控制流向后跳转,也不会被回边计数器统计。

11.2.3 编译过程

在默认条件下,无论是方法调用产生的标准编译请求,还是栈上替换编译请求,虚拟机在编译器还未完成编译之前,都仍然将按照解释方式继续执行代码,而编译动作则在后台的编译线程中进行。 用户可以通过参数-XX:-BackgroundCompilation来禁止后台编译,后台编译被禁止后,当达到触发即时编译的条件时,执行线程向虚拟机提交编译请求以后将会一直阻塞等待,直到编译过程完成再开始执行编译器输出的本地代码。

那在后台执行编译的过程中,编译器具体会做什么事情呢?服务端编译器和客户端编译器的编译过程是有所差别的。对于客户端编译器来说,它是一个相对简单快速的三段式编译器,主要的关注点在于局部性的优化,而放弃了许多耗时较长的全局优化手段。

在第一个阶段,一个平台独立的前端将字节码构造成一种高级中间代码表示(High-Level Intermediate Representation,HIR,即与目标机器指令集无关的中间表示)。HIR使用静态单分配 (Static Single Assignment,SSA)的形式来代表代码值,这可以使得一些在HIR的构造过程之中和之后进行的优化动作更容易实现。在此之前编译器已经会在字节码上完成一部分基础优化,如方法内联、 常量传播等优化将会在字节码被构造成HIR之前完成。

在第二个阶段,一个平台相关的后端从HIR中产生低级中间代码表示(Low-Level Intermediate Representation,LIR,即与目标机器指令集相关的中间表示),而在此之前会在HIR上完成另外一些优化,如空值检查消除、范围检查消除等,以便让HIR达到更高效的代码表示形式。

最后的阶段是在平台相关的后端使用线性扫描算法(Linear Scan Register Allocation)在LIR上分配寄存器,并在LIR上做窥孔(Peephole)优化,然后产生机器代码。客户端编译器大致的执行过程如图11-5所示。

image-20211126113221162

图11-5 Client Compiler架构

而服务端编译器则是专门面向服务端的典型应用场景,并为服务端的性能配置针对性调整过的编译器,也是一个能容忍很高优化复杂度的高级编译器,几乎能达到GNU C++编译器使用-O2参数时的优化强度。它会执行大部分经典的优化动作,如:无用代码消除(Dead Code Elimination)、循环展开 (Loop Unrolling)、循环表达式外提(Loop Expression Hoisting)、消除公共子表达式(Common Subexpression Elimination)、常量传播(Constant Propagation)、基本块重排序(Basic Block Reordering)等,还会实施一些与Java语言特性密切相关的优化技术,如范围检查消除(Range Check Elimination)、空值检查消除(Null Check Elimination,不过并非所有的空值检查消除都是依赖编译器优化的,有一些是代码运行过程中自动优化了)等。另外,还可能根据解释器或客户端编译器提供的性能监控信息,进行一些不稳定的预测性激进优化,如守护内联(Guarded Inlining)、分支频率预测 (Branch Frequency Prediction)等,本章的下半部分将会挑选上述的一部分优化手段进行分析讲解, 在此就先不做展开。

服务端编译采用的寄存器分配器是一个全局图着色分配器,它可以充分利用某些处理器架构(如RISC)上的大寄存器集合。以即时编译的标准来看,服务端编译器无疑是比较缓慢的,但它的编译速度依然远远超过传统的静态优化编译器,而且它相对于客户端编译器编译输出的代码质量有很大提高,可以大幅减少本地代码的执行时间,从而抵消掉额外的编译时间开销,所以也有很多非服务端的应用选择使用服务端模式的HotSpot虚拟机来运行。

在本节中出现了许多编译原理和代码优化中的概念名词,没有这方面基础的读者,可能阅读起来会感觉到很抽象、很理论化。有这种感觉并不奇怪,一方面,即时编译过程本来就是一个虚拟机中最能体现技术水平也是最复杂的部分,很难在几页纸的篇幅中介绍得面面俱到;另一方面,这个过程对Java开发者来说是完全透明的,程序员平时无法感知它的存在。所幸,HotSpot虚拟机提供了两个可视化的工具,让我们可以“看见”即时编译器的优化过程。下面笔者将实践演示这个过程。

11.2.4 实战:查看及分析即时编译结果

一般来说,Java虚拟机的即时编译过程对用户和程序都是完全透明的,虚拟机是通过解释来执行代码还是通过编译来执行代码,对于用户来说并没有什么影响(对执行结果没有影响,速度上会有显著差别),大多数情况下用户也没有必要知道。但是HotSpot虚拟机还是提供了一些参数用来输出即时编译和某些优化措施的运行状况,以满足调试和调优的需要。本节将通过实战说明如何从外部观察Java虚拟机的即时编译行为。

本节中提到的部分运行参数需要FastDebug或SlowDebug优化级别的HotSpot虚拟机才能够支持, Product级别的虚拟机无法使用这部分参数。如果读者使用的是根据第1章的教程自己编译的JDK,请注意将“–with-debug-level”参数设置为“fastdebug”或者“slowdebug”。现在Oracle和OpenJDK网站上都已经不再直接提供FastDebug的JDK下载了(从JDK 6 Update 25之后官网上就没有再提供下载),所以要完成本节全部测试内容,读者除了自己动手编译外,就只能到网上搜索非官方编译的版本了。本次实战中所有的测试都基于代码清单11-2所示的Java代码来进行。

代码清单11-2 测试代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public static final int NUM = 15000;
public static int doubleValue(int i) {
// 这个空循环用于后面演示JIT代码优化过程
for(int j=0;j<100000;j++);
return i * 2;
}
public static long calcSum() {
long sum = 0;
for (int i = 1;i <= 100;i++) {
sum += doubleValue(i);
}
return sum;
}
public static void main(String[] args) {
for (int i = 0;i < NUM;i++) {
calcSum();
}
}

我们首先来运行这段代码,并且确认这段代码是否触发了即时编译。要知道某个方法是否被编译过,可以使用参数-XX:+PrintCompilation要求虚拟机在即时编译时将被编译成本地代码的方法名称打印出来,如代码清单11-3所示(其中带有“%”的输出说明是由回边计数器触发的栈上替换编译)。

代码清单11-3 被即时编译的代码
1
2
3
4
5
VM option '+PrintCompilation' 
310 1 java.lang.String::charAt (33 bytes)
329 2 org.fenixsoft.jit.Test::calcSum (26 bytes)
329 3 org.fenixsoft.jit.Test::doubleValue (4 bytes)
332 1% org.fenixsoft.jit.Test::main @ 5 (20 bytes)

从代码清单11-3输出的信息中可以确认,main()、calcSum()和doubleValue()方法已经被编译,我们还可以加上参数-XX:+PrintInlining要求虚拟机输出方法内联信息,如代码清单11-4所示。

代码清单11-4 内联信息
1
2
3
4
5
6
7
8
9
VM option '+PrintCompilation' 
VM option '+PrintInlining'
273 1 java.lang.String::charAt (33 bytes)
291 2 org.fenixsoft.jit.Test::calcSum (26 bytes)
@ 9 org.fenixsoft.jit.Test::doubleValue inline (hot)
294 3 org.fenixsoft.jit.Test::doubleValue (4 bytes)
295 1% org.fenixsoft.jit.Test::main @ 5 (20 bytes)
@ 5 org.fenixsoft.jit.Test::calcSum inline (hot)
@ 9 org.fenixsoft.jit.Test::doubleValue inline (hot)

从代码清单11-4的输出日志中可以看到,doubleValue()方法已被内联编译到calcSum()方法中,而calcSum()方法又被内联编译到main()方法里面,所以虚拟机再次执行main()方法的时候(举例而已, main()方法当然不会运行两次),calcSum()和doubleValue()方法是不会再被实际调用的,没有任何方法分派的开销,它们的代码逻辑都被直接内联到main()方法里面了。

除了查看哪些方法被编译之外,我们还可以更进一步看到即时编译器生成的机器码内容。不过如果得到的是即时编译器输出一串0和1,对于我们人类来说是没法阅读的,机器码至少要反汇编成基本的汇编语言才可能被人类阅读。虚拟机提供了一组通用的反汇编接口^1,可以接入各种平台下的反汇编适配器,如使用32位x86平台应选用hsdis-i386适配器,64位则需要选用hsdis-amd64[^2],其余平台的适配器还有如hsdis-sparc、hsdis-sparcv9和hsdis-aarch64等,读者可以下载或自己编译出与自己机器相符合的反汇编适配器,之后将其放置在JAVA_HOME/lib/amd64/server下[^3],只要与jvm.dll或libjvm.so的路径相同即可被虚拟机调用。为虚拟机安装了反汇编适配器之后,我们就可以使用-XX: +PrintAssembly参数要求虚拟机打印编译方法的汇编代码了,关于HSDIS插件更多的操作介绍,可以参考第4章的相关内容。

如果没有HSDIS插件支持,也可以使用-XX:+PrintOptoAssembly(用于服务端模式的虚拟机) 或-XX:+PrintLIR(用于客户端模式的虚拟机)来输出比较接近最终结果的中间代码表示,代码清单11-2所示代码被编译后部分反汇编(使用-XX:+PrintOptoAssembly)的输出结果如代码清单11-5所示。对于阅读来说,使用-XX:+PrintOptoAssembly参数输出的伪汇编结果包含了更多的信息(主要是注释),有利于人们阅读、理解虚拟机即时编译器的优化结果。

代码清单11-5 本地机器码反汇编信息(部分)
1
2
3
4
5
6
7
8
9
10
11
…… …… 
000 B1: # N1 <- BLOCK HEAD IS JUNK Freq: 1
000 pushq rbp
subq rsp, #16 # Create frame
nop # nop for patch_verified_entry
006 movl RAX, RDX # spill
008 sall RAX, #1
00a addq rsp, 16 # Destroy frame
popq rbp
testl rax, [rip + #offset_to_poll_page] # Safepoint: poll for GC
…… ……

前面提到的使用-XX:+PrintAssembly参数输出反汇编信息需要FastDebug或SlowDebug优化级别的HotSpot虚拟机才能直接支持,如果使用Product版的虚拟机,则需要加入参数-XX: +UnlockDiagnosticVMOptions打开虚拟机诊断模式。

如果除了本地代码的生成结果外,还想再进一步跟踪本地代码生成的具体过程,那可以使用参数- XX:+PrintCFGToFile(用于客户端编译器)或-XX:PrintIdealGraphFile(用于服务端编译器)要求Java虚拟机将编译过程中各个阶段的数据(譬如对客户端编译器来说包括字节码、HIR生成、LIR生成、寄存器分配过程、本地代码生成等数据)输出到文件中。然后使用Java HotSpot Client Compiler Visualizer[^4](用于分析客户端编译器)或Ideal Graph Visualizer[^5](用于分析服务端编译器)打开这些数据文件进行分析。接下来将以使用服务端编译器为例,讲解如何分析即时编译的代码生成过程。这里先把重点放在编译整体过程阶段及Ideal Graph Visualizer功能介绍上,在稍后在介绍Graal编译器的实战小节里,我们会使用Ideal Graph Visualizer来详细分析虚拟机进行代码优化和生成时的执行细节,届时我们将重点关注编译器是如何实现这些优化的。

服务端编译器的中间代码表示是一种名为理想图(Ideal Graph)的程序依赖图(Program Dependence Graph,PDG),在运行Java程序的FastDebug或SlowDebug优化级别的虚拟机上的参数中加入“-XX:PrintIdealGraphLevel=2-XX:PrintIdeal-GraphFile=ideal.xml”,即时编译后将会产生一个名为ideal.xml的文件,它包含了服务端编译器编译代码的全过程信息,可以使用Ideal Graph Visualizer对这些信息进行分析。

image-20211126114437962

图11-6 编译过的方法列表

Ideal Graph Visualizer加载ideal.xml文件后,在Outline面板上将显示程序运行过程中编译过的方法列表,如图11-6所示。这里列出的方法是代码清单11-2中所示的测试代码,其中doubleValue()方法出现了两次,这是由于该方法的编译结果存在标准编译和栈上替换编译两个版本。在代码清单11-2中,专门为doubleValue()方法增加了一个空循环,这个循环对方法的运算结果不会产生影响,但如果没有任何优化,执行该循环就会耗费处理器时间。直到今天还有不少程序设计的入门教程会把空循环当作程序延时的手段来介绍,下面我们就来看看在Java语言中这样的做法是否真的能起到延时的作用。

展开方法根节点,可以看到下面罗列了方法优化过程的各个阶段(根据优化措施的不同,每个方法所经过的阶段也会有所差别)的理想图,我们先打开“After Parsing”这个阶段。前面提到,即时编译器编译一个Java方法时,首先要把字节码解析成某种中间表示形式,然后才可以继续做分析和优化, 最终生成代码。“After Parsing”就是服务端编译器刚完成解析,还没有做任何优化时的理想图表示。打开这个图后,读者会看到其中有很多有颜色的方块,如图11-7所示。每一个方块代表了一个程序的基本块(Basic Block)。基本块是指程序按照控制流分割出来的最小代码块,它的特点是只有唯一的一个入口和唯一的一个出口,只要基本块中第一条指令被执行了,那么基本块内所有指令都会按照顺序全部执行一次。

image-20211126114606819

图11-7 基本块图示(1)

代码清单11-2所示的doubleValue()方法虽然只有简单的两行字,但是按基本块划分后,形成的图形结构却要比想象中复杂得多,这是因为一方面要满足Java语言所定义的安全需要(如类型安全、空指针检查)和Java虚拟机的运作需要(如Safepoint轮询),另一方面有些程序代码中一行语句就可能形成几个基本块(例如循环语句)。对于例子中的doubleValue()方法,如果忽略语言安全检查的基本块, 可以简单理解为按顺序执行了以下几件事情:

可以简单理解为按顺序执行了以下几件事情:
1)程序入口,建立栈帧。
2)设置j=0,进行安全点(Safepoint)轮询,跳转到4的条件检查。
3)执行j++。
4)条件检查,如果j<100000,跳转到3。
5)设置i=i*2,进行安全点轮询,函数返回。

以上几个步骤反映到Ideal Graph Visualizer生成的图形上,就是图11-8所示的内容。这样我们若想看空循环是否被优化掉,或者何时被优化掉,只要观察代表循环的基本块是否被消除掉,以及何时被优化掉就可以了。

image-20211126114717078

图11-8 基本块图示(2)

要观察这一点,可以在Outline面板上右击“Difference to current graph”,让软件自动分析指定阶段与当前打开的理想图之间的差异,如果基本块被消除了,将会以红色显示。对“After Parsing”和“PhaseIdealLoop 1”阶段的理想图进行差异分析,会发现在“PhaseIdealLoop 1”阶段循环操作就被消除了,如图11-9所示,这也就说明空循环在最终的本地代码里实际上是不会被执行的。

image-20211126114751677

图11-9 基本块图示(3)

从“After Parsing”阶段开始,一直到最后的“Final Code”阶段都可以看到doubleValue()方法的理想图从繁到简的变迁过程,这也反映了Java虚拟机即时编译器尽力优化代码的过程。到了最后的“Final Code”阶段,不仅空循环的开销被消除了,许多语言安全保障措施和GC安全点的轮询操作也被一起消除了,因为编译器判断到即使不做这些保障措施,程序也能得到相同的结果,不会有可观察到的副作用产生,虚拟机的运行安全也不会受到威胁。

[^2]: HSDIS的源码可以从HotSpot虚拟机源码仓库中获取(路径为:src\utils\hsdis),具体可以参见第1 章。此源码需要执行编译,对于HSDIS的编译,读者可以参考AdoptOpenJDK的官方GitHub: https://github.com/AdoptOpenJDK/jitwatch/wiki/Building-hsdis/。如果不想自己编译,在GitHub上搜 索“hsdis-i386.so/dll”“hsdis-amd64.so/dll”这样的关键词也可以找到不少编译好的Linux或Windows的 HSDIS插件。
[^3]: 如果使用JDK 8或之前版本,应放在JRE_HOME/bin/server目录下。
[^4]: 官方站点:http://ssw.jku.at/Research/Projects/JVM/CCVis.html。
[^5]: 官方站点:http://ssw.jku.at/General/Staff/TW/igv.html。

10.5 本章小结

在本章中,我们从Javac编译器源码实现的层次上学习了Java源代码编译为字节码的过程,分析了Java语言中泛型、主动装箱拆箱、条件编译等多种语法糖的前因后果,并实战练习了如何使用插入式注解处理器来完成一个检查程序命名规范的编译器插件。如本章概述中所说的,在前端编译器中,“优化”手段主要用于提升程序的编码效率,之所以把Javac这类将Java代码转变为字节码的编译器称作“前端编译器”,是因为它只完成了从程序到抽象语法树或中间字节码的生成,而在此之后,还有一组内置于Java虚拟机内部的“后端编译器”来完成代码优化以及从字节码生成本地机器码的过程,即前面多次提到的即时编译器或提前编译器,这个后端编译器的编译速度及编译结果质量高低,是衡量Java虚拟机性能最重要的一个指标。在第11章中,我们将会一探后端编译器的运作和优化过程。

10.4.4 其他应用案例

NameCheckProcessor的实战例子只演示了JSR-269嵌入式注解处理API其中的一部分功能,基于这组API支持的比较有名的项目还有用于校验Hibernate标签使用正确性的Hibernate Validator Annotation Processor^1(本质上与NameCheckProcessor所做的事情差不多)、自动为字段生成getter和setter方法等辅助内容的Lombok^2(根据已有元素生成新的语法树元素)等,读者有兴趣的话可以参考它们官方站点的相关内容。

10.4.3 运行与测试

我们可以通过Javac命令的“-processor”参数来执行编译时需要附带的注解处理器,如果有多个注解处理器的话,用逗号分隔。还可以使用-XprintRounds和-XprintProcessorInfo参数来查看注解处理器运作的详细信息,本次实战中的NameCheckProcessor的编译及执行过程如代码清单10-19所示。

代码清单10-19 注解处理器的运行过程
1
2
3
4
5
6
7
8
9
10
11
12
13
D:\src>javac org/fenixsoft/compile/NameChecker.java 
D:\src>javac org/fenixsoft/compile/NameCheckProcessor.java
D:\src>javac -processor org.fenixsoft.compile.NameCheckProcessor
org/fenixsoft/compile/BADLY_NAMED_CODE.java
org\fenixsoft\compile\BADLY_NAMED_CODE.java:3: 警告:名称“BADLY_NAMED_CODE”应当符合驼式命名法(Camel Case Names) public class BADLY_NAMED_CODE { ^
org\fenixsoft\compile\BADLY_NAMED_CODE.java:5: 警告:名称“colors”应当以大写字母开头 enum colors { ^
org\fenixsoft\compile\BADLY_NAMED_CODE.java:6: 警告:常量“red”应当全部以大写字母或下划线命名,并且以字母开头 red, blue, green; ^
org\fenixsoft\compile\BADLY_NAMED_CODE.java:6: 警告:常量“blue”应当全部以大写字母或下划线命名,并且以字母开头 red, blue, green; ^
org\fenixsoft\compile\BADLY_NAMED_CODE.java:6: 警告:常量“green”应当全部以大写字母或下划线命名,并且以字母开头 red, blue, green; ^
org\fenixsoft\compile\BADLY_NAMED_CODE.java:9: 警告:常量“_FORTY_TWO”应当全部以大写字母或下划线命名,并且以字母开头 static final int _FORTY_TWO = 42; ^
org\fenixsoft\compile\BADLY_NAMED_CODE.java:11: 警告:名称“NOT_A_CONSTANT”应当以小写字母开头 public static int NOT_A_CONSTANT = _FORTY_TWO; ^
org\fenixsoft\compile\BADLY_NAMED_CODE.java:13: 警告:名称“Test”应当以小写字母开头 protected void Test() { ^
org\fenixsoft\compile\BADLY_NAMED_CODE.java:17: 警告:名称“NOTcamelCASEmethodNAME”应当以小写字母开头 public void NOTcamelCASEmethodNAME() { ^

10.4.2 代码实现

要通过注解处理器API实现一个编译器插件,首先需要了解这组API的一些基本知识。我们实现注解处理器的代码需要继承抽象类javax.annotation.processing.AbstractProcessor,这个抽象类中只有一个子类必须实现的抽象方法:“process()”,它是Javac编译器在执行注解处理器代码时要调用的过程,我们可以从这个方法的第一个参数“annotations”中获取到此注解处理器所要处理的注解集合,从第二个参数“roundEnv”中访问到当前这个轮次(Round)中的抽象语法树节点,每个语法树节点在这里都表示为一个Element。在javax.lang.model.ElementKind中定义了18类Element,已经包括了Java代码中可能出现的全部元素,如:“包(PACKAGE)、枚举(ENUM)、类(CLASS)、注解 (ANNOTATION_TYPE)、接口(INTERFACE)、枚举值(ENUM_CONSTANT)、字段 (FIELD)、参数(PARAMETER)、本地变量(LOCAL_VARIABLE)、异常 (EXCEPTION_PARAMETER)、方法(METHOD)、构造函数(CONSTRUCTOR)、静态语句块 (STATIC_INIT,即static{}块)、实例语句块(INSTANCE_INIT,即{}块)、参数化类型 (TYPE_PARAMETER,泛型尖括号内的类型)、资源变量(RESOURCE_VARIABLE,try-resource 中定义的变量)、模块(MODULE)和未定义的其他语法树节点(OTHER)”。除了process()方法的传入参数之外,还有一个很重要的实例变量“processingEnv”,它是AbstractProcessor中的一个protected 变量,在注解处理器初始化的时候(init()方法执行的时候)创建,继承了AbstractProcessor的注解处理器代码可以直接访问它。它代表了注解处理器框架提供的一个上下文环境,要创建新的代码、向编译器输出信息、获取其他工具类等都需要用到这个实例变量。

注解处理器除了process()方法及其参数之外,还有两个经常配合着使用的注解,分别是: @SupportedAnnotationTypes和@SupportedSourceVersion,前者代表了这个注解处理器对哪些注解感兴趣,可以使用星号“*”作为通配符代表对所有的注解都感兴趣,后者指出这个注解处理器可以处理哪些版本的Java代码。

每一个注解处理器在运行时都是单例的,如果不需要改变或添加抽象语法树中的内容,process() 方法就可以返回一个值为false的布尔值,通知编译器这个轮次中的代码未发生变化,无须构造新的JavaCompiler实例,在这次实战的注解处理器中只对程序命名进行检查,不需要改变语法树的内容,因此process()方法的返回值一律都是false。

关于注解处理器的API,笔者就简单介绍这些,对这个领域有兴趣的读者可以阅读相关的帮助文档。我们来看看注解处理器NameCheckProcessor的具体代码,如代码清单10-16所示。

代码清单10-16 注解处理器NameCheckProcessor
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 可以用"*"表示支持所有Annotations
@SupportedAnnotationTypes("*")// 只支持JDK6的Java代码
@SupportedSourceVersion(SourceVersion.RELEASE_6)
public class NameCheckProcessor extends AbstractProcessor {
private NameChecker nameChecker;
/**
* 初始化名称检查插件
*/
@Override
public void init(ProcessingEnvironment processingEnv) {
super.init(processingEnv);
nameChecker = new NameChecker(processingEnv);
}
/**
* 对输入的语法树的各个节点进行名称检查
*/
@Override
public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
if (!roundEnv.processingOver()) {
for (Element element : roundEnv.getRootElements()) nameChecker.checkNames(element);
}
return false;
}
}

从代码清单10-16中可以看到NameCheckProcessor能处理基于JDK 6的源码,它不限于特定的注解,对任何代码都“感兴趣”,而在process()方法中是把当前轮次中的每一个RootElement传递到一个名为NameChecker的检查器中执行名称检查逻辑,NameChecker的代码如代码清单10-17所示。

代码清单10-17 命名检查器NameChecker
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
/**
* 程序名称规范的编译器插件:<br>
* 如果程序命名不合规范,将会输出一个编译器的WARNING信息
*/
public class NameChecker {
private final Messager messager;
NameCheckScanner nameCheckScanner = new NameCheckScanner();
NameChecker(ProcessingEnvironment processsingEnv) {
this.messager = processsingEnv.getMessager();
}
/**
* 对Java程序命名进行检查,根据《Java语言规范》第三版第6.8节的要求,Java程序命名应当符合下列格式:
*
* <ul>
* <li>类或接口:符合驼式命名法,首字母大写。
* <li>方法:符合驼式命名法,首字母小写。
* <li>字段:
* <ul>
* <li>类、实例变量: 符合驼式命名法,首字母小写。
* <li>常量: 要求全部大写。
* </ul>
* </ul>
*/
public void checkNames(Element element) {
nameCheckScanner.scan(element);
}
/**
* 名称检查器实现类,继承了JDK 6中新提供的ElementScanner6<br>
* 将会以Visitor模式访问抽象语法树中的元素
*/
private class NameCheckScanner extends ElementScanner6<Void, Void> {
/**
* 此方法用于检查Java类
*/
@Override
public Void visitType(TypeElement e, Void p) {
scan(e.getTypeParameters(), p);
checkCamelCase(e, true);
super.visitType(e, p);

return null;
}
/**
* 检查方法命名是否合法
*/
@Override
public Void visitExecutable(ExecutableElement e, Void p) {
if (e.getKind() == METHOD) {
Name name = e.getSimpleName();
if (name.contentEquals(e.getEnclosingElement().getSimpleName())) messager.printMessage(WARNING, "一个普通方法 “" + name + "”不应当与类名重复,避免与构造函数产生混淆", e);
checkCamelCase(e, false);
}
super.visitExecutable(e, p);
return null;
}
/**
* 检查变量命名是否合法
*/
@Override
public Void visitVariable(VariableElement e, Void p) {
// 如果这个Variable是枚举或常量,则按大写命名检查,否则按照驼式命名法规则检查
if (e.getKind() == ENUM_CONSTANT || e.getConstantValue() != null || heuristicallyConstant(e)) checkAllCaps(e);
elsecheckCamelCase(e, false);
return null;
}
/**
* 判断一个变量是否是常量
*/
private boolean heuristicallyConstant(VariableElement e) {
if (e.getEnclosingElement().getKind() == INTERFACE) return true;
else if (e.getKind() == FIELD && e.getModifiers().containsAll(EnumSet.of(PUBLIC, STATIC, FINAL))) return true;
else {
return false;
}
}
/**
* 检查传入的Element是否符合驼式命名法,如果不符合,则输出警告信息
*/
private void checkCamelCase(Element e, boolean initialCaps) {
String name = e.getSimpleName().toString();
boolean previousUpper = false;
boolean conventional = true;
int firstCodePoint = name.codePointAt(0);
if (Character.isUpperCase(firstCodePoint)) {
previousUpper = true;
if (!initialCaps) {
messager.printMessage(WARNING, "名称“" + name + "”应当以小写字母开头", e);
return;
}
}
else if (Character.isLowerCase(firstCodePoint)) {
if (initialCaps) {
messager.printMessage(WARNING, "名称“" + name + "”应当以大写字母开头", e);
return;
}
}
elseconventional = false;
if (conventional) {
int cp = firstCodePoint;
for (int i = Character.charCount(cp);i < name.length();i += Character.charCount(cp)) {
cp = name.codePointAt(i);
if (Character.isUpperCase(cp)) {
if (previousUpper) {
conventional = false;
break;
}

previousUpper = true;
}
elsepreviousUpper = false;
}
}
if (!conventional) messager.printMessage(WARNING, "名称“" + name + "”应当符合驼式命名法(Camel Case Names)", e);
}
/**
* 大写命名检查,要求第一个字母必须是大写的英文字母,其余部分可以是下划线或大写字母
*/
private void checkAllCaps(Element e) {
String name = e.getSimpleName().toString();
boolean conventional = true;
int firstCodePoint = name.codePointAt(0);
if (!Character.isUpperCase(firstCodePoint)) conventional = false;
else {
boolean previousUnderscore = false;
int cp = firstCodePoint;
for (int i = Character.charCount(cp);i < name.length();i += Character.charCount(cp)) {
cp = name.codePointAt(i);
if (cp == (int) '_') {
if (previousUnderscore) {
conventional = false;
break;
}
previousUnderscore = true;
}
else {
previousUnderscore = false;
if (!Character.isUpperCase(cp) && !Character.isDigit(cp)) {
conventional = false;
break;
}
}
}
}
if (!conventional) messager.printMessage(WARNING, "常量“" + name + "”应当全部以大写字母或下划线命名,并且以字母开头", e);
}
}
}

NameChecker的代码看起来有点长,但实际上注释占了很大一部分,而且即使算上注释也不到190 行。它通过一个继承于javax.lang.model.util.ElementScanner6^1的NameCheckScanner类,以Visitor模式来完成对语法树的遍历,分别执行visitType()、visitVariable()和visitExecutable()方法来访问类、字段和方法,这3个visit*()方法对各自的命名规则做相应的检查,checkCamelCase()与checkAllCaps()方法则用于实现驼式命名法和全大写命名规则的检查。

整个注解处理器只需NameCheckProcessor和NameChecker两个类就可以全部完成,为了验证我们的实战成果,代码清单10-18中提供了一段命名规范的“反面教材”代码,其中的每一个类、方法及字段的命名都存在问题,但是使用普通的Javac编译这段代码时不会提示任意一条警告信息。

代码清单10-18 包含了多处不规范命名的代码样例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class BADLY_NAMED_CODE {
enum colors {
red, blue, green;
}

static final int _FORTY_TWO = 42;
public static int NOT_A_CONSTANT = _FORTY_TWO;
protected void BADLY_NAMED_CODE() {
return;
}
public void NOTcamelCASEmethodNAME() {
return;
}
}

10.4 实战:插入式注解处理器

Java的编译优化部分在本书中并没有像前面两部分那样设置独立的、整章篇幅的实战,因为我们开发程序,考虑的主要还是程序会如何运行,较少会涉及针对程序编译的特殊需求。也正因如此,在JDK的编译子系统里面,暴露给用户直接控制的功能相对很少,除了第11章会介绍的虚拟机即时编译的若干相关参数以外,我们就只有使用JSR-296中定义的插入式注解处理器API来对Java编译子系统的行为施加影响。

但是笔者丝毫不认为相对于前两部分介绍的内存管理子系统和字节码执行子系统,编译子系统就不那么重要了。一套编程语言中编译子系统的优劣,很大程度上决定了程序运行性能的好坏和编码效率的高低,尤其在Java语言中,运行期即时编译与虚拟机执行子系统非常紧密地互相依赖、配合运作 (第11章我们将主要讲解这方面的内容)。了解JDK如何编译和优化代码,有助于我们写出适合Java 虚拟机自优化的程序。话题说远了,下面我们回到本章的实战中来,看看插入式注解处理器API能为我们实现什么功能。

10.4.1 实战目标

通过阅读Javac编译器的源码,我们知道前端编译器在把Java程序源码编译为字节码的时候,会对Java程序源码做各方面的检查校验。这些校验主要是以程序“写得对不对”为出发点,虽然也会产生一些警告和提示类的信息,但总体来讲还是较少去校验程序“写得好不好”。有鉴于此,业界出现了许多针对程序“写得好不好”的辅助校验工具,如CheckStyle、FindBug、Klocwork等。这些代码校验工具有一些是基于Java的源码进行校验,有一些是通过扫描字节码来完成,在本节的实战中,我们将会使用注解处理器API来编写一款拥有自己编码风格的校验工具:NameCheckProcessor。

当然,由于我们的实战都是为了学习和演示技术原理,而且篇幅所限,不可能做出一款能媲美CheckStyle等工具的产品来,所以NameCheckProcessor的目标也仅定为对Java程序命名进行检查。根据 《Java语言规范》中6.8节的要求,Java程序命名推荐(而不是强制)应当符合下列格式的书写规范。

  • 类(或接口):符合驼式命名法,首字母大写。
  • 方法:符合驼式命名法,首字母小写。
  • 字段:
    • 类或实例变量。符合驼式命名法,首字母小写。
    • 常量。要求全部由大写字母或下划线构成,并且第一个字符不能是下划线。

上文提到的驼式命名法(Camel Case Name),正如它的名称所表示的那样,是指混合使用大小写字母来分割构成变量或函数的名字,犹如驼峰一般,这是当前Java语言中主流的命名规范,我们的实战目标就是为Javac编译器添加一个额外的功能,在编译程序时检查程序名是否符合上述对类(或接口)、方法、字段的命名要求。

10.3.3 条件编译

许多程序设计语言都提供了条件编译的途径,如C、C++中使用预处理器指示符(#ifdef)来完成条件编译。C、C++的预处理器最初的任务是解决编译时的代码依赖关系(如极为常用的#include预处理命令),而在Java语言之中并没有使用预处理器,因为Java语言天然的编译方式(编译器并非一个个地编译Java文件,而是将所有编译单元的语法树顶级节点输入到待处理列表后再进行编译,因此各个文件之间能够互相提供符号信息)就无须使用到预处理器。那Java语言是否有办法实现条件编译呢?

Java语言当然也可以进行条件编译,方法就是使用条件为常量的if语句。如代码清单10-14所示, 该代码中的if语句不同于其他Java代码,它在编译阶段就会被“运行”,生成的字节码之中只包括“System.out.println(“block 1”);”一条语句,并不会包含if语句及另外一个分子中的“System.out.println(“block 2”);”

代码清单10-14 Java语言的条件编译
1
2
3
4
5
6
7
8
public static void main(String[] args) {
if (true) {
System.out.println("block 1");
}
else {
System.out.println("block 2");
}
}

该代码编译后Class文件的反编译结果:

1
2
3
public static void main(String[] args) {
System.out.println("block 1");
}

只能使用条件为常量的if语句才能达到上述效果,如果使用常量与其他带有条件判断能力的语句搭配,则可能在控制流分析中提示错误,被拒绝编译,如代码清单10-15所示的代码就会被编译器拒绝编译。

代码清单10-15 不能使用其他条件语句来完成条件编译
1
2
3
4
5
6
public static void main(String[] args) {
// 编译器将会提示“Unreachable code”
while (false) {
System.out.println("");
}
}

Java语言中条件编译的实现,也是Java语言的一颗语法糖,根据布尔常量值的真假,编译器将会把分支中不成立的代码块消除掉,这一工作将在编译器解除语法糖阶段(com.sun.tools.javac.comp.Lower 类中)完成。由于这种条件编译的实现方式使用了if语句,所以它必须遵循最基本的Java语法,只能写在方法体内部,因此它只能实现语句基本块(Block)级别的条件编译,而没有办法实现根据条件调整整个Java类的结构。

除了本节中介绍的泛型、自动装箱、自动拆箱、遍历循环、变长参数和条件编译之外,Java语言还有不少其他的语法糖,如内部类、枚举类、断言语句、数值字面量、对枚举和字符串的switch支持、try语句中定义和关闭资源(这3个从JDK 7开始支持)、Lambda表达式(从JDK 8开始支持, Lambda不能算是单纯的语法糖,但在前端编译器中做了大量的转换工作),等等,读者可以通过跟踪Javac源码、反编译Class文件等方式了解它们的本质实现,囿于篇幅,笔者就不再一一介绍了。