提前编译器
现在提前编译产品和对其的研究有着两条明显的分支,一条分支是做与传统C、C++编译器类似
的,在程序运行之前把程序代码编译成机器码的静态翻译工作;另外一条分支是把原本即时编译器在
运行时要做的编译工作提前做好并保存下来,下次运行到这些代码(譬如公共库代码在被同一台机器
其他Java进程使用)时直接把它加载进来使用。
我们先来说第一条,这是传统的提前编译应用形式,它在Java中存在的价值直指即时编译的最大
弱点:即时编译要占用程序运行时间和运算资源。即使现在先进的即时编译器已经足够快,以至于能
够容忍相当高的优化复杂度了(譬如Azul公司基于LLVM的Falcon JIT,就能够以相当于Clang-O3的优
化级别进行即时编译;又譬如OpenJ9的即时编译器Testarossa,它的静态版本同时也作为C、C++语言
的提前编译器使用,优化的复杂度自然也支持得非常高);即使现在先进的即时编译器架构有了分层
编译的支持,可以先用快速但低质量的即时编译器为高质量的即时编译器争取出更多编译时间,但
是,无论如何,即时编译消耗的时间都是原本可用于程序运行的时间,消耗的运算资源都是原本可用
于程序运行的资源,这个约束从未减弱,更不会消失,始终是悬在即时编译头顶的达摩克利斯之剑。
关于提前编译的第二条路径,本质是给即时编译器做缓存加速,去改善Java程序的启动时间,以
及需要一段时间预热后才能到达最高性能的问题。这种提前编译被称为动态提前编译(Dynamic
AOT)或者索性就大大方方地直接叫即时编译缓存(JIT Caching)。在目前的Java技术体系里,这条
路径的提前编译已经完全被主流的商用JDK支持。真正引起业界普遍关注的是OpenJDK/OracleJDK 9中所带的Jaotc提前编译器,这是一个基于Graal编译
器实现的新工具,目的是让用户可以针对目标机器,为应用程序进行提前编译。HotSpot运行时可以直
接加载这些编译的结果,实现加快程序启动速度,减少程序达到全速运行状态所需时间的目的。这里
面确实有比较大的优化价值,试想一下,各种Java应用最起码会用到Java的标准类库,如java.base等模
块,如果能够将这个类库提前编译好,并进行比较高质量的优化,显然能够节约不少应用运行时的编
译成本。但实际应用起来并不是那么容易,原因是这种提前编译方式不仅要和目标机器相关,甚至还必须
与HotSpot虚拟机的运行时参数绑定。譬如虚拟机运行时采用了不同的垃圾收集器,这原本就需要即时
编译子系统的配合(典型的如生成内存屏障代码,见第3章相关介绍)才能正确工作,要做提前编译的
话,自然也要把这些配合的工作平移过去。至于前面提到过的提前编译破坏平台中立性、字节膨胀等
缺点当然还存在,这里就不重复了。尽管还有许多困难,但提前编译无疑已经成为一种极限榨取性能
(启动、响应速度)的手段,且被官方JDK关注。
即时编译的优势:
首先,是性能分析制导优化(Profile-Guided Optimization,PGO)。上一节介绍HotSpot的即时编
译器时就多次提及在解释器或者客户端编译器运行过程中,会不断收集性能监控信息,譬如某个程序
点抽象类通常会是什么实际类型、条件判断通常会走哪条分支、方法调用通常会选择哪个版本、循环
通常会进行多少次等,这些数据一般在静态分析时是无法得到的,或者不可能存在确定且唯一的解,
最多只能依照一些启发性的条件去进行猜测。但在动态运行时却能看出它们具有非常明显的偏好性。
如果一个条件分支的某一条路径执行特别频繁,而其他路径鲜有问津,那就可以把热的代码集中放到
一起,集中优化和分配更好的资源(分支预测、寄存器、缓存等)给它。
其次,是激进预测性优化(Aggressive Speculative Optimization),这也已经成为很多即时编译优
化措施的基础。静态优化无论如何都必须保证优化后所有的程序外部可见影响(不仅仅是执行结果)
与优化前是等效的,不然优化之后会导致程序报错或者结果不对,若出现这种情况,则速度再快也是
没有价值的。然而,相对于提前编译来说,即时编译的策略就可以不必这样保守,如果性能监控信息
能够支持它做出一些正确的可能性很大但无法保证绝对正确的预测判断,就已经可以大胆地按照高概
率的假设进行优化,万一真的走到罕见分支上,大不了退回到低级编译器甚至解释器上去执行,并不
会出现无法挽救的后果。只要出错概率足够低,这样的优化往往能够大幅度降低目标程序的复杂度,
输出运行速度非常高的代码。譬如在Java语言中,默认方法都是虚方法调用,部分C、C++程序员(甚
至一些老旧教材)会说虚方法是不能内联的,但如果Java虚拟机真的遇到虚方法就去查虚表而不做内
联的话,Java技术可能就已经因性能问题而被淘汰很多年了。实际上虚拟机会通过类继承关系分析等
一系列激进的猜测去做去虚拟化(Devitalization),以保证绝大部分有内联价值的虚方法都可以顺利
内联。
编译器优化技术
优化前的原始代码
static class B {
int value;
final int get() {
return value;
}
}
public void foo() {
y = b.get();
// ...do stuff...
z = b.get();
sum = y + z;
}首先,第一个要进行的优
化是方法内联,它的主要目的有两个:一是去除方法调用的成本(如查找方法版本、建立栈帧等);
二是为其他优化建立良好的基础。方法内联膨胀之后可以便于在更大范围上进行后续的优化手段,可
以获取更好的优化效果。因此各种编译器一般都会把内联优化放在优化序列最靠前的位置。
内联后的代码
public void foo() {
y = b.value;
// ...do stuff...
z = b.value;
sum = y + z;
}第二步进行冗余访问消除(Redundant Loads Elimination),假设代码中间注释掉的“…dostuff…”所代表的操作不会改变b.value的值,那么就可以把“z=b.value”替换为“z=y”,因为上一句“y=b.value”已经保证了变量y与b.value是一致的,这样就可以不再去访问对象b的局部变量了。如果把b.value看作一个表达式,那么也可以把这项优化看作一种公共子表达式消除(Common SubexpressionElimination)
冗余存储消除的代码
public void foo() {
y = b.value;
// ...do stuff...
z = y;
sum = y + z;
}第三步进行复写传播(Copy Propagation),因为这段程序的逻辑之中没有必要使用一个额外的变
量z,它与变量y是完全相等的,因此我们可以使用y来代替z。复写传播之后的程序如代码清单11-9所
示
复写传播的代码
public void foo() {
y = b.value;
// ...do stuff...
y = y;
sum = y + y;
}第四步进行无用代码消除(Dead Code Elimination),无用代码可能是永远不会被执行的代码,也
可能是完全没有意义的代码。因此它又被很形象地称为“Dead Code”,在代码清单11-9中,“y=y”是没
有意义的
进行无用代码消除的代码
public void foo() {
y = b.value;
// ...do stuff...
sum = y + y;
}经过四次优化之后,代码清单11-10所示代码与代码清单11-6所示代码所达到的效果是一致的,但
是前者比后者省略了许多语句,体现在字节码和机器码指令上的差距会更大,执行效率的差距也会更
高。
方法内联
方法内联,说它是编译器最重要的优化手段,甚至都可以不加
上“之一”。内联被业内戏称为优化之母,因为除了消除方法调用的成本之外,它更重要的意义是为其
他优化手段建立良好的基础,代码清单11-11所示的简单例子就揭示了内联对其他优化手段的巨大价
值:没有内联,多数其他优化都无法有效进行。例子里testInline()方法的内部全部是无用的代码,但如
果不做内联,后续即使进行了无用代码消除的优化,也无法发现任何“Dead Code”的存在。如果分开来
看,foo()和testInline()两个方法里面的操作都有可能是有意义的。
如果不是即时编译器做了一些特殊的努力,按照经典编译原理的优化理论,大多
数的Java方法都无法进行内联。因为多态的原因,无法编译器确定具体的方法。简
而言之,Java语言中默认的实例方法是虚方法。
对于一个虚方法,编译器静态地去做内联的时候很难确定应该使用哪个方法版本,以将代码清单
11-7中所示b.get()直接内联为b.value为例,如果不依赖上下文,是无法确定b的实际类型是什么的。假
如有ParentB和SubB是两个具有继承关系的父子类型,并且子类重写了父类的get()方法,那么b.get()是
执行父类的get()方法还是子类的get()方法,这应该是根据实际类型动态分派的,而实际类型必须在实
际运行到这一行代码时才能确定,编译器很难在编译时得出绝对准确的结论。
由于Java提倡使用面向对象的方式进行编程,而Java对象的方法默认就是虚方
法,可以说Java间接鼓励了程序员使用大量的虚方法来实现程序逻辑。根据上面的分析可知,内联与
虚方法之间会产生“矛盾”,那是不是为了提高执行性能,就应该默认给每个方法都使用final关键字去
修饰呢?C和C++语言的确是这样做的,默认的方法是非虚方法,如果需要用到多态,就用virtual关键
字来修饰,但Java选择了在虚拟机中解决这个问题。
为了解决虚方法的内联问题,Java虚拟机首先引入了一种名为类型继承关系分析(Class Hierarchy
Analysis,CHA)的技术,这是整个应用程序范围内的类型分析技术,用于确定在目前已加载的类
中,某个接口是否有多于一种的实现、某个类是否存在子类、某个子类是否覆盖了父类的某个虚方法
等信息。这样,编译器在进行内联时就会分不同情况采取不同的处理:如果是非虚方法,那么直接进
行内联就可以了,这种的内联是有百分百安全保障的;如果遇到虚方法,则会向CHA查询此方法在当
前程序状态下是否真的有多个目标版本可供选择,如果查询到只有一个版本,那就可以假设“应用程序
的全貌就是现在运行的这个样子”来进行内联,这种内联被称为守护内联(Guarded Inlining)。不过由
于Java程序是动态连接的,说不准什么时候就会加载到新的类型从而改变CHA结论,因此这种内联属
于激进预测性优化,必须预留好“逃生门”,即当假设条件不成立时的“退路”(Slow Path)。假如在程
序的后续执行过程中,虚拟机一直没有加载到会令这个方法的接收者的继承关系发生变化的类,那这
个内联优化的代码就可以一直使用下去。如果加载了导致继承关系发生变化的新类,那么就必须抛弃
已经编译的代码,退回到解释状态进行执行,或者重新进行编译。
假如向CHA查询出来的结果是该方法确实有多个版本的目标方法可供选择,那即时编译器还将进
行最后一次努力,使用内联缓存(Inline Cache)的方式来缩减方法调用的开销。这种状态下方法调用
是真正发生了的,但是比起直接查虚方法表还是要快一些。内联缓存是一个建立在目标方法正常入口
之前的缓存,它的工作原理大致为:在未发生方法调用之前,内联缓存状态为空,当第一次调用发生
后,缓存记录下方法接收者的版本信息,并且每次进行方法调用时都比较接收者的版本。如果以后进
来的每次调用的方法接收者版本都是一样的,那么这时它就是一种单态内联缓存(Monomorphic Inline
Cache)。通过该缓存来调用,比用不内联的非虚方法调用,仅多了一次类型判断的开销而已。但如果
真的出现方法接收者不一致的情况,就说明程序用到了虚方法的多态特性,这时候会退化成超多态内
联缓存(Megamorphic Inline Cache),其开销相当于真正查找虚方法表来进行方法分派。
所以说,在多数情况下Java虚拟机进行的方法内联都是一种激进优化。事实上,激进优化的应用
在高性能的Java虚拟机中比比皆是,极为常见。除了方法内联之外,对于出现概率很小(通过经验数
据或解释器收集到的性能监控信息确定概率大小)的隐式异常、使用概率很小的分支等都可以被激进
优化“移除”,如果真的出现了小概率事件,这时才会从“逃生门”回到解释状态重新执行。
逃逸分析
逃逸分析(Escape Analysis)是目前Java虚拟机中比较前沿的优化技术,它与类型继承关系分析一
样,并不是直接优化代码的手段,而是为其他优化措施提供依据的分析技术。
逃逸分析的基本原理是:分析对象动态作用域,当一个对象在方法里面被定义后,它可能被外部
方法所引用,例如作为调用参数传递到其他方法中,这种称为方法逃逸;甚至还有可能被外部线程访
问到,譬如赋值给可以在其他线程中访问的实例变量,这种称为线程逃逸;从不逃逸、方法逃逸到线
程逃逸,称为对象由低到高的不同逃逸程度。
如果能证明一个对象不会逃逸到方法或线程之外(换句话说是别的方法或线程无法通过任何途径
访问到这个对象),或者逃逸程度比较低(只逃逸出方法而不会逃逸出线程),则可能为这个对象实
例采取不同程度的优化,如:
·栈上分配[1](Stack Allocations):在Java虚拟机中,Java堆上分配创建对象的内存空间几乎是
Java程序员都知道的常识,Java堆中的对象对于各个线程都是共享和可见的,只要持有这个对象的引
用,就可以访问到堆中存储的对象数据。虚拟机的垃圾收集子系统会回收堆中不再使用的对象,但回
收动作无论是标记筛选出可回收对象,还是回收和整理内存,都需要耗费大量资源。如果确定一个对
象不会逃逸出线程之外,那让这个对象在栈上分配内存将会是一个很不错的主意,对象所占用的内存
空间就可以随栈帧出栈而销毁。在一般应用中,完全不会逃逸的局部对象和不会逃逸出线程的对象所
占的比例是很大的,如果能使用栈上分配,那大量的对象就会随着方法的结束而自动销毁了,垃圾收
集子系统的压力将会下降很多。栈上分配可以支持方法逃逸,但不能支持线程逃逸。
·标量替换(Scalar Replacement):若一个数据已经无法再分解成更小的数据来表示了,Java虚拟
机中的原始数据类型(int、long等数值类型及reference类型等)都不能再进一步分解了,那么这些数据
就可以被称为标量。相对的,如果一个数据可以继续分解,那它就被称为聚合量(Aggregate),Java
中的对象就是典型的聚合量。如果把一个Java对象拆散,根据程序访问的情况,将其用到的成员变量
恢复为原始类型来访问,这个过程就称为标量替换。假如逃逸分析能够证明一个对象不会被方法外部
访问,并且这个对象可以被拆散,那么程序真正执行的时候将可能不去创建这个对象,而改为直接创
建它的若干个被这个方法使用的成员变量来代替。将对象拆分后,除了可以让对象的成员变量在栈上
(栈上存储的数据,很大机会被虚拟机分配至物理机器的高速寄存器中存储)分配和读写之外,还可
以为后续进一步的优化手段创建条件。标量替换可以视作栈上分配的一种特例,实现更简单(不用考
虑整个对象完整结构的分配),但对逃逸程度的要求更高,它不允许对象逃逸出方法范围内。
·同步消除(Synchronization Elimination):线程同步本身是一个相对耗时的过程,如果逃逸分析
能够确定一个变量不会逃逸出线程,无法被其他线程访问,那么这个变量的读写肯定就不会有竞争,
对这个变量实施的同步措施也就可以安全地消除掉。
到现在这项优化技术尚未足够成熟,仍有很大的改进余地。不成熟的原因主要是逃逸分析
的计算成本非常高,甚至不能保证逃逸分析带来的性能收益会高于它的消耗。如果要百分之百准确地
判断一个对象是否会逃逸,需要进行一系列复杂的数据流敏感的过程间分析,才能确定程序各个分支执行时对此对象的影响。前面介绍即时编译、提前编译优劣势时提到了过程间分析这种大压力的分析
算法正是即时编译的弱项。可以试想一下,如果逃逸分析完毕后发现几乎找不到几个不逃逸的对象,
那这些运行期耗用的时间就白白浪费了,所以目前虚拟机只能采用不那么准确,但时间压力相对较小
的算法来完成分析。
完全未优化的代码
public int test(int x) {
int xx = x + 2;
Point p = new Point(xx, 42);
return p.getX();
}第一步,将Point的构造函数和getX()方法进行内联优化:
// 步骤1:构造函数内联后的样子
public int test(int x) {
int xx = x + 2;
Point p = point_memory_alloc(); // 在堆中分配P对象的示意方法
p.x = xx; // Point构造函数被内联后的样子
p.y = 42
return p.x; // Point::getX()被内联后的样子
}第二步,经过逃逸分析,发现在整个test()方法的范围内Point对象实例不会发生任何程度的逃逸,
这样可以对它进行标量替换优化,把其内部的x和y直接置换出来,分解为test()方法内的局部变量,从
而避免Point对象实例被实际创建,优化后的结果如下所示:
// 步骤2:标量替换后的样子
public int test(int x) {
int xx = x + 2;
int px = xx;
int py = 42
return px;
}第三步,通过数据流分析,发现py的值其实对方法不会造成任何影响,那就可以放心地去做无效
代码消除得到最终优化结果,如下所示:
// 步骤3:做无效代码消除后的样子
public int test(int x) {
return x + 2;
}从测试结果来看,实施逃逸分析后的程序在MicroBenchmarks中往往能得到不错的成绩,但是在实
际的应用程序中,尤其是大型程序中反而发现实施逃逸分析可能出现效果不稳定的情况,或分析过程
耗时但却无法有效判别出非逃逸对象而导致性能(即时编译的收益)下降,所以曾经在很长的一段时
间里,即使是服务端编译器,也默认不开启逃逸分析[2],甚至在某些版本(如JDK 6 Update 18)中还
曾经完全禁止了这项优化,一直到JDK 7时这项优化才成为服务端编译器默认开启的选项。如果有需
要,或者确认对程序运行有益,用户也可以使用参数-XX:+DoEscapeAnalysis来手动开启逃逸分析,
开启之后可以通过参数-XX:+PrintEscapeAnalysis来查看分析结果。有了逃逸分析支持之后,用户可
以使用参数-XX:+EliminateAllocations来开启标量替换,使用+XX:+EliminateLocks来开启同步消
除,使用参数-XX:+PrintEliminateAllocations查看标量的替换情况。
尽管目前逃逸分析技术仍在发展之中,未完全成熟,但它是即时编译器优化技术的一个重要前进
方向,在日后的Java虚拟机中,逃逸分析技术肯定会支撑起一系列更实用、有效的优化技术。
公共子表达式消除
公共子表达式消除是一项非常经典的、普遍应用于各种编译器的优化技术,它的含义是:如果一
个表达式E之前已经被计算过了,并且从先前的计算到现在E中所有变量的值都没有发生变化,那么E
的这次出现就称为公共子表达式。对于这种表达式,没有必要花时间再对它重新进行计算,只需要直
接用前面计算过的表达式结果代替E。如果这种优化仅限于程序基本块内,便可称为局部公共子表达
式消除(Local Common Subexpression Elimination),如果这种优化的范围涵盖了多个基本块,那就称
为全局公共子表达式消除(Global Common Subexpression Elimination)。
int d = (c * b) * 12 + a + (a + b * c);当这段代码进入虚拟机即时编译器后,它将进行如下优化:编译器检测到c*b与b*c是一样的表达
式,而且在计算期间b与c的值是不变的。
因此这条表达式就可能被视为:
int d = E * 12 + a + (a + E);这时候,编译器还可能(取决于哪种虚拟机的编译器以及具体的上下文而定)进行另外一种优化
——代数化简(Algebraic Simplification),在E本来就有乘法运算的前提下,把表达式变为:
int d = E * 13 + a + a;表达式进行变换之后,再计算起来就可以节省一些时间了。
数组边界检查消除
数组边界检查消除(Array Bounds Checking Elimination)是即时编译器中的一项语言相关的经典优
化技术。我们知道Java语言是一门动态安全的语言,对数组的读写访问也不像C、C++那样实质上就是
裸指针操作。如果有一个数组foo[],在Java语言中访问数组元素foo[i]的时候系统将会自动进行上下界
的范围检查,即i必须满足“i>=0&&i<foo.length”的访问条件,否则将抛出一个运行时异常:
java.lang.ArrayIndexOutOfBoundsException。这对软件开发者来说是一件很友好的事情,即使程序员没
有专门编写防御代码,也能够避免大多数的溢出攻击。但是对于虚拟机的执行子系统来说,每次数组
元素的读写都带有一次隐含的条件判定操作,对于拥有大量数组访问的程序代码,这必定是一种性能
负担。
无论如何,为了安全,数组边界检查肯定是要做的,但数组边界检查是不是必须在运行期间一次
不漏地进行则是可以“商量”的事情。例如下面这个简单的情况:数组下标是一个常量,如foo[3],只要
在编译期根据数据流分析来确定foo.length的值,并判断下标“3”没有越界,执行的时候就无须判断了。
更加常见的情况是,数组访问发生在循环之中,并且使用循环变量来进行数组的访问。如果编译器只
要通过数据流分析就可以判定循环变量的取值范围永远在区间[0,foo.length)之内,那么在循环中就可
以把整个数组的上下界检查消除掉,这可以节省很多次的条件判断操作。
把这个数组边界检查的例子放在更高的视角来看,大量的安全检查使编写Java程序比编写C和
C++程序容易了很多,比如:数组越界会得到ArrayIndexOutOfBoundsException异常;空指针访问会得
到NullPointException异常;除数为零会得到ArithmeticException异常……在C和C++程序中出现类似的
问题,一个不小心就会出现Segment Fault信号或者Windows编程中常见的“XXX内存不能为
Read/Write”之类的提示,处理不好程序就直接崩溃退出了。但这些安全检查也导致出现相同的程序,
从而使Java比C和C++要做更多的事情(各种检查判断),这些事情就会导致一些隐式开销,如果不处
理好它们,就很可能成为一项“Java语言天生就比较慢”的原罪。为了消除这些隐式开销,除了如数组
边界检查优化这种尽可能把运行期检查提前到编译期完成的思路之外,还有一种避开的处理思路——
隐式异常处理,Java中空指针检查和算术运算中除数为零的检查都采用了这种方案。举个例子,程序
中访问一个对象(假设对象叫foo)的某个属性(假设属性叫value),那以Java伪代码来表示虚拟机访
问foo.value的过程为:
if (foo != null) {
return foo.value;
}else{
throw new NullPointException();
}在使用隐式异常优化之后,虚拟机会把上面的伪代码所表示的访问过程变为如下伪代码:
try {
return foo.value;
} catch (segment_fault) {
uncommon_trap();
}虚拟机会注册一个Segment Fault信号的异常处理器(伪代码中的uncommon_trap(),务必注意这里
是指进程层面的异常处理器,并非真的Java的try-catch语句的异常处理器),这样当foo不为空的时
候,对value的访问是不会有任何额外对foo判空的开销的,而代价就是当foo真的为空时,必须转到异
常处理器中恢复中断并抛出NullPointException异常。进入异常处理器的过程涉及进程从用户态转到内
核态中处理的过程,结束后会再回到用户态,速度远比一次判空检查要慢得多。当foo极少为空的时
候,隐式异常优化是值得的,但假如foo经常为空,这样的优化反而会让程序更慢。幸好HotSpot虚拟
机足够聪明,它会根据运行期收集到的性能监控信息自动选择最合适的方案。
与语言相关的其他消除操作还有不少,如自动装箱消除(Autobox Elimination)、安全点消除
(Safepoint Elimination)、消除反射(Dereflection)等
代码中间表示
将字节码优化成机器码之前需要将字节码转换成理想图(Ideal
Graph,在代码中称为Structured Graph)的程序依赖图(Program Dependence Graph,PDG)形式。从编译器内部来看即:字节码→理想图→优化→机器码(以Mach Node
Graph表示)的转变过程。
理想图是一种有向图,用节点来表示程序中的元素,譬如变量、操作符、方法、字段等,而用边
来表示数据或者控制流。我们先从最简单的例子出发。譬如有一个表达式:x+y,在理想图中可以表
示为x、y两个节点的数据流流入加法操作符,表示相加操作读取了x、y的值,流出的便则表示数据流
的流向,即相加的结果会在哪里被使用。

这很容易接受吧?那我们把例子稍微复杂化一些,把表达式x+y变为getX()+getY(),仍是用理想图
表达其计算过程,这时候除了数据流向之外,还必须要考虑方法调用的顺序。在理想图中用另外一条
边来表示方法的调用(为了便于区分,数据流笔者使用蓝色线(以虚线表示),控制流使用红色线
(以实线表示)),说明代码的执行顺序是先调用getX()方法,再调用getY()方法。

以上这些简单的前置知识就已经足以支撑我们本次实战的进行了,理想图本质上就是这种将数据
流图和控制流图以某种方式合并到一起,用一种边来表示数据流向,另一种边来表示控制流向的图形
表示。
int average(int a, int b) {
return (a + b) / 2;
}
// 以下代码的公共子表达式能够被消除
int workload(int a, int b) {
return (a + b) * (a + b);
}
// 以下代码的公共子表达式是不可以被消除的
int workload() {
return (getA() + getB()) * (getA() + getB());
}对于第一段代码,a+b是公共子表达式,可以通过优化使其只计算一次而不会有任何的副作用。但
是对于第二段代码,由于getA()和getB()方法内部所蕴含的操作是不确定的,它是否被调用、调用次数
的不同都可能会产生不同返回值或者其他影响程序状态的副作用(譬如改变某个全局的状态变量),
这种代码只能内联了getA()和getB()方法之后才能考虑更进一步的优化措施,仍然保持函数调用的情况
下是无法做公共子表达式消除的。
从图11-16所示中可以看到,参数1、2的加法操作只进行了一次,然后同时流出了两条数据流指向
乘法操作的输入中。
代码优化与生成

第一是理想图本身的数据结构。它是一组不为空的节点的集合,它的节点都是用ValueNode的不同
类型的子类节点来表示的。仍然以x+y表达式为例,譬如其中的加法操作,就由AddNode节点来表示
第二就是如何从字节码转换到理想图。该过程被封装在BytecodeParser类中,这个解析器我们可以
按照字节码解释器的思路去理解它。
每一个理想图的节点都有两个共同的主要操作,一个是规范化(Canonicalisation),另一个是生
成机器码(Generation)。生成机器码顾名思义,就不必解释了,规范化则是指如何缩减理想图的规
模,也即在理想图的基础上优化代码所要采取的措施。这两个操作对应了编译器两项最根本的任务:代码优化与代码翻译。
对于代码生成,Graal并不是直接由理想图转换到机器码,而是和其他编译器一样,会先生成低级
中间表示(LIR,与具体机器指令集相关的中间表示),然后再由HotSpot统一后端来产生机器码。