JVM 与编译优化
Java 的编译分期,至少可以分为两个阶段(有些情况下还有额外的第三种编译过程):
- 编译前端(前端编译):把 .java 变成 .class 文件的过程。也就是把源语言文件变成中间语言文件的过程。典型的例子有:javac、Eclipse 的 ECJ的工作过程。
- 编译后端(后端编译):由 JIT(Just In Time Compiler。我认为应该还要把 Interpreter包括在内)把中间语言(字节码)转换成二进制目标体系结构机器码的过程。典型的例子有,HotSpot 的 C1,C2编译器的工作过程。
- AOT(Ahead Of Time) 编译器直接把源代码编译成转换成二进制目标体系结构机器码的过程。
早期(编译)优化
javac 自从1.3版本已经不再支持什么 -O 的优化了。所有的优化策略集中到后端编译里。这样没有经过 javac 编译的 JRuby、Jython程序,也可以享受到 JVM 的优化福利。
javac的编译过程,大致上是:
- 解析和填充符号表(Parse and Enter)。
- 注解处理(Annotation Processing,Java 5以后加入的过程)。
- 分析与字节码生成(Analyze and Generate)
它们的流程图大致上是:
1 |
|
解析与词法分析的过程包括两个阶段:
- 词法、语法分析。通过 Parser 把字符流,转变为 Token 集合,把 Token 集合又转成 AST 的过程。抽象语法树的每一个节点是一个 construct。可以使用 Eclipse AST View 来查看抽象语法树的内容。
- 填充符号表,把 AST 里的 Construct 变成地址和符号信息构成的表格。
在 Java 6 以后的 JST-269 实现里,有一组插入式注解处理器(Plugable Annotations Processing API)可供编译器处理。这些处理器在运行时,可以读取、修改和添加 AST 的元素。每次修改完成,都会回到“解析与填充”的阶段冲走一个循环,这每一个循环实际上是一个 Round。
语义分析有标注检查和数据及控制流分析两个步骤:
- 标注检查的内容有变量使用前是否已经被生命、变量与复制之间的数据类型是否能够匹配等等。我们常说的常量折叠,是标注检查的一部分。
- 数据及控制流分析则检查诸如局部变量是否有赋值、方法的每条路径是否都有返回值、是否所有的受检异常都被正确处理了等问题。注意,我们都知道 JVM 里面没有checked exception,实际上 JVM 里面也是没有 final local variable的,这些都是由编译期保证的。
接下来字节码生成的部分,分为解糖(desugar)和字节码生成。
解糖就是把语法糖转换成非语法糖的代码,比如把泛型转换为非泛型,把拆装箱换成普通方法。其实我认为 checked exception 和局部变量 final 都是语法糖。因为无类型优化,所以 Java 的泛型比 C#、C++ 的泛型要慢一些。关于泛型还是要专门说一点,运行时擦除到边界的类型,总是会在 .Class 的地方 equals 成功的,这是因为 .Code 属性里面没有类型信息,但其他元数据区(如LocalVariableTypeTable的表里)还能拿到类型信息,所以我们的反射才能正常运行下去。
字节码生成阶段会生成我们的
还有一种特殊的语法糖,条件编译。即方法内的 if 加上布尔常量可以消除无法到达的死代码( 不同于后面提到的 Dead Code Elmination)。
晚期(运行)优化
mixed mode 指的是解释器和 JIT 一起运行。在没有打开分层编译的情况下,C1(客户端虚拟机默认编译器) 和 C2(服务器端虚拟机默认编译器) 只有一个会与解释器一起工作,特别地:
- -Xint 关掉 JIT,强制用解释器执行。
- -Xcomp 关掉解释器,强制编译执行(实际上解释器仍然会在不能编译的极端情况下介入,作为兜底方案)。
JIT会根据概率统计才去一些激进的优化措施,但遇到一些优化失败的场景时(比如 Uncommon Trap),则可能发生 Deoptimization。
分层编译将代码的执行看做三层内容:
- 第0层:解释执行,不开启 Profiling,触发第1层编译。
- 第1层:C1 编译,简单可靠。可能加入监控逻辑。更高的编译速度。
- 第2层:C2 编译,激进,深度编译,可能编译耗时较长。更好的编译质量。
不管是 C1 还是 C2,都有一个编译器队列。也有异步编译模式可以减少编译线程对代码执行的影响。
编译对象和触发条件
热点代码有两类:
- 被多次调用的方法。
- 被多次执行的循环体。
这两种编译目标,最终都是以方法为单位执行编译。而这种编译方法因为发生在方法执行时,因此称为栈上替换(On Stack Replacement, OSR)。JVM 会试图用 JIT 的本地代码栈帧代替解释器栈帧。
热点代码的侦测方式叫做热点侦测(Hot Spot Detection),有两种具体形式:
- 基于采样的(Sample Based Hot Spot Detection):定期查看栈顶的方法,统计最常出现的方法名。
- 基于计数器的热点方法。每个方法使用一个计数器,超出阈值就成为热点方法。
Hotspot 就是采用两种计数器,调用计数器(Invocation Counter) 和回边计数器(Back Edge Counter)。
-XX:CompileThreshold 可以设定JIT 的编译阈值。不过这个阈值是相对阈值,会根据半衰期(Counter Half Life Time)直接减掉一半的计数器。所以可以使用 -XX:-UseCounterDecay 关掉热度衰减。 ,可以使用 -XX:CounterHalfLifeTime来设置半衰期,单位是秒。
JIT 的工作流程如图:
1 |
|
在图中我们可以看到
所谓回边,就是字节码中,控制流向后跳转的指令。顾名思义,回边计数器就是对方法中循环体代码的执行次数进行统计的。有一个 -XX:BackEdgeThreshold 这样的参数可以可以设置这个回边阈值,但现实中的 JVM 并没有直接采用这一参数。而是使用了 OnStackReplacePercentage这一参数来配置。
与方法计数器不同,回边计数器没有热度半衰期,因此它统计的时候方法执行的绝对次数。而且如果回边计数器溢出,方法计数器也就溢出了,方法执行标准编译过程。
回边计数器的执行过程如图:
1 |
|
在缺省的情况下,后台的编译线程和解释器线程是并发执行的,但也可以用 -XX:-BackgroundCompilation 来禁止后台编译。
我们常见的编译动作(如同 gcc 的 -O2的编译器那样做的):死代码消除(Dead Code Elimination)、循环展开(Loop Unrolling)、循环表达式外提(Loop Epression Hoisting)、公共子表达式消除(Common Subexpression Elimination)、常量传播(Constant Propagation)、基本块重排序(Basic Block Reordering)。还有一些 Java语言特有的优化,如范围检查消除(Range Check Elimination)、空间插消除(Null Check Elimination)。还有一些激进的优化,如守护内联(Guarded Inlining)、分支频率预测(Branch Frequency Prediction)。
可以通过 -XX:+PrintCompilation 查看到底有哪几个方法被编译了。还可以用-XX:PrintInlining要求虚拟机输出内联信息。
可以使用各种hsdis反汇编适配器(如hsdis-i386)与虚拟机结合在一起看待 JIT 出的汇编指令。或者使用 -XX:printOptoAssembly(C2)或者-XX:+printLIR(C1)。
常见的编译优化技术
公共子表达式消除(Common Subexpression Elimination)
如果 a + b 已经计算过了,则接下来的 a + b 不再需要通过字节码计算。这项技术是语言无关的。
数组边界检查消除(Array Bounds Checking Elimination)
这项技术是与 Java 的数组实现相关的。Java 会对每次的数组下标访问做一个是否越界的检查,这也是越界异常抛出的根源。但如果能够在数据流检查的阶段,提前确认常量访问数组下标的情况,这种检查可以被去掉,开销也就消失了。
方法内联(Method Inlining)
这是对性能提升最大的技术。
逃逸分析(Escape Analysis)
逃逸分析就是考察一个对象是不是会被传递到方法或者线程之外。如果没有逃逸成功,则有特别的优化措施:
- 栈上分配对象,而不再在堆上(Hotspot上没有这项优化,哪里有呢?)。
- 同步消除,不再同步这个变量(如何做到?)。
- 标量替换。对象是聚合量(Aggregate),基本的数据类型是标量(Scalar)。可以直接不生成对象而生成对象的成员变量,再配合栈上分配,可以极大提高性能。
逃逸分析对于不正确的同步代码,可能会引入意想不到的bug。