Java虚拟机(七)(编译及优化)

1 什么是编译 “编译”这个词汇在各种关于编程语言的资料中都能看到,那究竟什么是编译呢?简单地说,编译是一个行为,是一个将一种语言翻译成另一种语言的行为,而实现这个行为的东西就是“编译器”。例如,C语言编译器会将C语言源代码翻译成汇编代码,然后再由汇编器将汇编代码翻译成机器可以直接识别的机器代码。
如果按照是否有编译这个过程将编程语言分类的话,大致可以分为两大类:编译型语言和解释型语言。编译型语言的典型代表就是C和C++,解释型语言的典型代表是Python和JavaScript。那么Java属于哪一种类型呢?说实话,我不确定,因为Java先经过javac编译后形成字节码,然后JVM再解释执行字节码,从这个角度看,好像Java可以归到解释型语言,但由于Java中也存在JIT即时编译机制,将字节码编译成机器码,然后机器再执行机器码,从这个角度看,好像又可以认为Java是编译型的。但我个人更倾向于“Java”是编译型语言,因为javac将Java源代码编译成了字节码,字节码和源代码已经有非常大的不同了,换句话说,编译的程度很深(而且javac编译器也会包括编译器该有的功能,例如词法分析、语法分析、语义分析等),故我认为Java是编译型语言。

在网上看到有一条启发性原则用来判断语言的类型:如果翻译器对程序进行了彻底的分析而非某种机械的变换,而且生成的中间程序与源程序之间没有很强的相似性,我们就认为这个语言是编译的。彻底的分析和非平凡的变换,是编译方式的标志性特征。
2 Java编译器 Java大概有三类编译器:前端编译器、后端编译器、静态提前编译器。
  • 前端编译器。典型代表是javac,作用是将Java源代码编译成虚拟机可以识别的字节码,通俗的说就是将.java文件转换成.class文件(这个说法有些狭隘了,实际上,不一定就是.class文件,只要是虚拟机可执行的字节码即可)。
  • 后端编译器。即我们经常看到的“JIT”编译器,典型代表是HotSpot的Client编译器和Server编译器,简称为C1和C2,作用就是将字节码转换成机器可识别的机器码。
  • 静态提前编译器。典型代表是GNU Compiler for the Java,作用是在前端编译器编译之前,直接将java源代码编译成机器可识别的机器码。
2.1 前端编译器
我们通常所说的“编译”大多少数时候都是指的“前端编译”(仅限于Java领域)。前端编译的主要工作是将java源码编译成字节码,供虚拟机解释执行,但虚拟机规范并没有对具体的编译过程做严格要求,这就导致了各个编译器可能大相径庭,对于Javac来说,大致可以分为3个过程,分别是:
  • 解析与填充符号表的过程。即解析Java源代码,并将其中内容转换成符合表示并插入符号表里,详细的过程会在下面说到。
  • 插入式注解处理器的注解处理过程。Java5之后提供了注解,注解有可能会导致原代码的部分逻辑发生改变,所以在这个过程会对注解做处理,并回到第一个过程,再次做解析与符号表填充。
  • 分析与字节码的生成。即生成最终可被虚拟机识别并解释执行的字节码。
2.1.1 解析与符号表填充 解析包括词法分析和语法分析,几乎所有的编译器都至少有这么两步,通过这两个步骤,会生成抽象语法树,后续的过程都是基于抽象语法树的,不会再直接对源代码进行操作。
词法分析及即将源代码的字符流转换成一个一个的Token,Token被定义成一个不可再拆分的元素。例如对于int a = 5; 来说,int、a、=、5、;等都是Token,且Token并不一定是一个字符。词法分析是基于Java语言的规范来进行的,这也就是为什么三个字符的int被认为是一个Token的原因。
语法分析会根据词法分析得到的Token序列来构造抽象语法树,抽象语法树是一种树形的数据结构,树中的每个节点都代表程序代码中的一个语法结构,例如包、类、修饰符、运算符等。
完成词法和语法分析后就算是完成了解析过程,接下来就是符号表的填充了。符号表是一组符号和符号地址组成的表,可以理解成Hash表,但实际上不一定就是Hash表的格式,也有可能是树形或者其他形式,只要能表示符号和符号地址的映射关系即可,符号表的信息在后面的各个阶段都有可能用到,例如在语义分析中,这些信息会被用来做语法检查和生成中间代码。
2.1.2 注解处理器 在编译期对注解进行处理的过程中,可能会修改抽象语法树的节点,所以在完成对注解的处理之后,还需要回到解析和符号表填充的过程,再次生成新的抽象语法树,这样一个循环可能会发生多次,直到注解处理器不会在修改抽象语法树。
2.1.3 语义分析和字节码生成 完成了上面的步骤之后,抽象语法树的信息就不会再发生改变了,即此时的抽象语法树是一个最终版本的抽象语法树,但抽象语法树只能表示程序是正确的,是符合语法要求的,但并不表示程序是符合逻辑的,所以还需要对其进行语义分析来确定程序是否符合逻辑,分析的项目大概有标注检查、数据流和控制流分析等。这些检查完成后会着手“解语法糖”,最后才会生成字节码。
上面提到了“语法糖”这个东西,语法糖是用来方便程序员的,提高程序员的开发效率的。但本质上对程序性能上没有什么增益。例如For-each循环在编译后展开成以迭代器的方式遍历,基本类型的自动装箱和拆箱操作也会在编译后展开成valueOf(),或者xxxValue()的方法调用。
2.1.4 前端编译器的优化操作 前端编译器也会做一些优化操作,但比起后端编译器来说,优化的力度比较小。在这里简单说一下两个优化操作:常量折叠和条件编译。
常量折叠是一个将常量简化的优化操作。例如现在有如下代码:
int a = 2 + 3;

编译器如果有常量折叠这项优化的话,会将这条语句优化成下面这样:
int a = 5;

这样就可以让JVM少执行一次加法指令,提高执行效率。
条件编译即将一些条件判断的步骤省略,让JVM少执行一次条件判断指令。例如:
public static void main(String[] args) { if (true) { System.out.println("block1"); } else { System.out.println("block2") } }

如果编译器又这么一项优化的话,可能就会将代码编译成这样:
public static void main(String[] args) { System.out.println("block1"); }

这样就少了一个条件判断的指令,执行效率就提高了。这种优化只会发生在条件变量是常量的时候才行,如果条件变量可能发生改变,那么编译器就不会做这项优化。
2.2 后端编译器 在Java中,我们通常所说的后端编译就是说的JIT(即时编译)。在HotSpot虚拟机中,有两个即时编译器,即Client Compiler和Server Compiler,简称为C1和C2,这两种编译器对应着虚拟机的运行模式,如果虚拟机的运行模式是Client,那么就使用C1,如果是Server模式,就使用C2,虚拟机的运行模式可以通过如下命令看到。
> java -v

2.2.1 编译器和解释器 自从有了JIT,Java代码就不再全是又虚拟机解释执行的了,而是一部分代码继续使用虚拟机解释器解释执行,一部分代码被编译为机器码,机器直接执行机器码。这样可以发挥解释器和编译器的优势,当程序需要快速启动的时候,解释器可以省去编译过程(但之前讲到的前端编译生成字节码的步骤仍然是必须的),直接解释执行程序,当程序运行后,编译器可以将一些“热点代码”编译成机器码,以提高运行时的执行效率。
解释器还能作为编译器的“逃生门”,当编译失败或者编译后的代码出现运行问题(这通常是因为编译器“激进”的优化操作导致的)时可以进行“逆优化”操作,此时虚拟机将继续以解释的模式运行这部分代码,这使得即使编译失败也不会突然导致运行中的应用程序崩溃。下面是解释器和编译器的交互示意图:

Java虚拟机(七)(编译及优化)
文章图片
il87lQ.png
由于即时编译需要在程序运行时执行,必然会占用程序的资源,要编译出优化程度高的代码,需要的资源可能会很多。HotSpot虚拟机提供了分层编译的策略来缓解这个问题,大致可分为3层:程序解释执行,C1编译、C2编译。3层的优化程度以此递增,需要占用的资源也以此递增,但将原来所需要的更大的资源分为了三个部分,虚拟机完全可以在不同的时间段里执行三个过程,最终生成优化程度很高的代码。
2.2.2 JIT的触发条件 上面的讨论中提到过一个“热点代码”的概念,虚拟机不会将所有的字节码都编译成机器码(因为有些代码可能仅仅会执行那么一次两次,编译这部分代码有点得不偿失),而仅仅将部分经常被使用的代码编译成机器码,这部分代码就称作“热点代码”。
判断一段代码是不是热点代码,是不是需要进行即使编译,这样的过程称作“热点探测”。目前主流的热点探测方法有两种:
  • 基于采样的热点探测。使用这种方式的虚拟机会周期性的检查栈顶,如果发现某个方法经常出现在栈顶,那这个方法就被判断为热点代码。这种方式的好处是实现简单、高效,还可以通过展开栈来获得调用关系,缺点就是结果可能不准确,容易受到例如线程阻塞或者外部因素的干扰。
  • 基于计数器的热点探测。使用这种方式的虚拟机会为每个方法创建计数器,当方法被调用的时候,对应的计数器就加1,当计数器的值达到某个阈值的时候,就会判断该方法为热点方代码,可以对其触发即使编译。这样的好处是结果准确,但无法获得方法的调用关系。
HotSpot虚拟机采用的是第二种方法,它为每个方法准备了两个计数器:方法调用计数器和回边计数器。
  • 方法调用计数器。每当方法被调用的时候,就加+1。
  • 回边计数器。作用是统计一个方法体里的循环体的执行次数。
这两个计数器的阈值并不一样,只要有一个计数器达到阈值,就会触发即时编译,而且都会编译整个方法,即使仅仅因为里面的循环体是热点代码。
即时编译和解释执行时可以并发执行的,即编译还没完成的时候,解释器仍然以解释执行的方式执行代码,当编译完成后再选择运行编译后的代码。下图是方法调用计数器触发即时编译的流程图(回边计数器触发的流程也相差不多):

Java虚拟机(七)(编译及优化)
文章图片
ilGN9S.png
关于JIT具体编译的过程和细节就不多说了,书上写得很详细(但也比较晦涩),建议看看书上的第11章。
3 编译优化技术 HotSpot虚拟机在即时编译方面有很多优化技术,其中也有不少经典的优化技术,例如常量折叠、条件编译等,也有一些针对Java的优化技术,例如栈上替换,逃逸分析等。下面介绍几种比较具有代表性的优化技术。
3.1 公共子表达式消除
这是一种普遍的优化技术,他的描述是这样的:如果一个表达式E已经计算过了,并且从先前到现在E都没有发生过改变,那么E的这次出现就成为了公共子表达式,对于这种表达式就没必要花费时间再次计算了,只需要用先前计算的结果替代即可。假设有如下代码:
int d = (c * b) * 12 + a + (a + b * c);

其中cxb和bxc是等效的,故将其看做E,编译器就可以做出类似下面的优化。
int d = E * 12 + a + (a + E);

甚至如果编译器还有“代数化简”的优化项目的话,可能会变成下面这样:
int d = E * 13 + a * 2;

这样一来,原来至少有6个算数运算以及若干个括号相关、若干个算法运算法则相关的压栈和出栈操作变成了只有3个算数运算操作,最终效率就变高。
3.2 数组边界检查消除
Java在对数组访问的时候会对数组边界进行检查,如果发生数组越界了,会抛出java.lang.ArrayIndexOutOfBoudnsException异常,而不会像C/C++那样要么出现Segment Falut,要么就会出现乱码,这一点对程序员来说是一件很好的事情,即使程序员没有专门编写防御性代码,也可以避免很多内存溢出攻击,但正是由于多了边界检查,所以程序的运行效率肯定也会受到影响。
【Java虚拟机(七)(编译及优化)】为了降低数组边界检查的影响,编译器可能会进行“数组边界检查消除”的优化,注意,这个优化并不是完全将数组边界检查这个特性抛弃,而是对没有必要进行数组边界检查的数组访问操作进行检查消除。例如对于数组A进行A[3]的访问操作,如果能在编译期根据数据流分析得到A.length的值,并判断出3小于A.length,那么在执行访问操作的时候就不需要对数组边界进行检查了。再举个例子,例如我们现在有如下代码遍历数组A:
for (int i = 0; i < A.length; i++) { System.out.println(A[i]); }

这段代码中,循环遍历i的值完全可以在编译期确定就在[0,A.lenght)这个范围里,而这个范围没有发生数组越界,故编译器可以对这段代码做数组边界检查消除的优化,从而提高执行效率。
除了数组边界优化之外,还有一种技术也可以降低隐式开销:隐式异常处理。我们在编写Java代码的时候,经常需要处理异常,Java程序在运行时对异常的处理要做更多的事情(各种的检查、判断),所以,为了降低这种开销,编译器可能会对异常做一些“特殊处理”。假设有下面这样的代码:
public static void main(String[] args) { User user = getUser("yenono"); System.out.println(user.getName()); }

虚拟机在执行的时候会对user对象做空值判断,Java伪代码如下所示:
if (user != null) { System.out.println(user.getName()); } else { throw new NullPointException(); }

如果编译器有“隐式异常处理”这项优化的话,可能就会变成下面这样(Java伪代码):
try { System.out.println(user.getName()); } catch (segment_fault) { uncommon_trap(); }

比起原来的代码,少了判断过程,直接就对对象进行操作了。当确实发生异常的时候,就不得不从用户态陷入到内核态去处理该异常,处理完成之后再回到用户态继续执行,这个过程的效率远远比一次空值判断低得多。所以,当很少发生异常的情况下,应用程序能从这项优化中获益,但如果经常发生异常,这样的优化反而会使得应用程序的效率更低,不过好在虚拟机足够智能,可以通过运行时的各种信息来自动的选择最好的方案。
这里提到了异常会陷入内核态,这是因为虚拟机会在操作系统中注册一个segment_fault异常,注册完毕后,该异常就属于操作系统异常了,当虚拟机捕获到这个异常的时候,就会发生中断陷入到内核态中对异常进行处理,处理完毕后又从内核态切换回用户态继续执行后面的逻辑。
3.3 方法内联
学过C++的朋友应该都接触过“内联函数”,即那些有inline关键字标识的函数。在C++中,函数内联可以简单理解成将整个函数当做一个代码块,当其他函数调用的时候,就直接把这个代码块复制到调用该函数的地方,最终的效果就是没有发生函数调用,也就是少了一次函数的入栈和出栈操作,这样的做法对性能的增益确实挺大的,尤其是对C++这种静态编译的语言,方法内联的过程完全可以在编译期就完成了,在运行时就不会再做复制代码的操作了。
在Java中,无法进行如此直接的方法内联,因为Java的多态实在运行时实现的,不像C++那样在编译期就确定了虚函数表以及C++对象的虚函数指针。所以java在编译期进行方法内联几乎无法完成,因为根本无法确定最终调用的方法是哪一个版本,不过对于有fianl修饰的方法(无法被重写),倒是可以进行方法内联,但总不可能为了性能,到处使用final修饰方法吧。那Java究竟是如何实现方法内联优化的呢?虚拟机设计团队引入了一种“类型继承关系分析(Class Hierarchy Analysis,CHA)”的技术,说实话,这个技术我完全没弄明白,所以在这里就不多说了,要想细致了解的,建议看看《深入理解Java虚拟机》中11.3.4节的内容。
3.4 逃逸分析
逃逸分析不是直接的优化技术,而是为其他优化提供依据的分析技术。那什么是逃逸呢?当在一个方法里创建了一个对象,然后再在该方法里调用其他方法并将对象作为参数传递到被调用方法里,这个就是对象“逃逸”了,这种方式的逃逸称作方法逃逸。在多线并发的环境中,还有线程逃逸的说法,那指的是某个对象被其他线程访问到,详细的可以看看《Java并发编程》中线程安全那一章节。
如果用逃逸分析技术分析某个对象不会发生逃逸,那么就可以不将对象分配到堆里,而是在栈上分配对象,反正这个对象又不会被其他方法调用到,仅在本方法里使用。在栈上分配的好处是线程安全、不需要垃圾回收和可以进行标量替换:
  • 线程安全。因为栈是线程私有的,对象分配在栈上了,自然就不可能被其他线程共享了,也就不会有线程安全问题了。
  • 不需要垃圾回收。当方法栈帧出栈以后,这部分栈内存就自动释放了,自然不需要进行垃圾回收了,这就减轻了垃圾回收的压力。
  • 标量替换。标量是指一个数据已经无法再分解成更小的数据类型来表示了,例如基本数据类型int,long以及引用类型等,相应的,一个数据如果能继续分解成更小的数据类型,那就称作聚合量,例如Java对象。根据对象的访问状况将其成员变量恢复原始类型的访问就叫做标量替换。例如某方法里用到一个user对象,而且方法里仅仅方法了user对象的name,和age字段,那么编译器就可以对其做一个标量替换,直接为name和age创建两个局部变量,而不需要再创建user对象了,减少了创建对象的开销。
逃逸分析是一个比较前沿的技术,在JDK1.6中才有实现,到现如今也并不是很成熟。原因就是因为无法保证逃逸分析带来的性能提升大于逃逸分析本身的消耗,毕竟逃逸分析是一个很复杂的过程,也是非常耗时的。一种比较极端的情况就是,经过一顿复杂的分析,最后发现该方法里的所有对象都会逃逸,这样基于逃逸分析的优化操作就无法做了,白白浪费时间来做这这些分析。
4 小结 本文简单介绍了前端编译器和后端编译器,最后还说了几个具有代表性的编译优化技术。编译器和编译优化技术看起来距离我们很远(对于普通的应用开发者),但理解它们是绝对有益处的,例如我知道了编译器会帮我将公共子表达式消除了,我在编写代码的时候就不需要老关注是否存在公共子表达式了(因为如果逻辑比较复杂的话,这个过程是非常烦人的),提升了开发效率并且对程序执行效率没有影响。记得知乎上曾经有过一个问题:C/C++的i++和++i的写法性能上有什么差别?看过《CSAPP》的朋友应该知道++i的写法效率上会比较好(具体原因在这里就不多说了),但实际上呢?编译器会给我们优化!所以编译后这两种写法没有区别!如果了解编译器的优化技术的话,在实际开发中,就不用纠结这样的问题了,根据公司的代码风格使用其中一种就行了(最好不要混搭,否则太混乱了)。
5 参考资料 《深入理解Java虚拟机》

    推荐阅读