JVM 与编译优化
Java 的编译分期,至少可以分为两个阶段(有些情况下还有额外的第三种编译过程):
- 编译前端(前端编译):把
*.java变成*.class文件的过程。也就是把源语言文件变成中间语言文件的过程。典型的例子有:javac、Eclipse 的 ECJ 的工作过程。 - 编译后端(后端编译):由 JIT(Just In Time Compiler)把中间语言(字节码)转换成二进制目标体系结构机器码的过程。典型的例子有 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)
它们的流程图大致上是:
graph LR
A[解析和填充符号表] --> B(注解处理)
B --> A
B --> C{分析与字节码生成}
解析与词法分析的过程包括两个阶段:
- 词法、语法分析。通过 Parser 把字符流,转变为 Token 集合,把 Token 集合又转成 AST 的过程。抽象语法树的每一个节点是一个 construct。可以使用 Eclipse AST View 来查看抽象语法树的内容。
- 填充符号表,把 AST 里的 Construct 变成地址和符号信息构成的表格。
在 Java 6 以后的 JSR-269 实现里,有一组插入式注解处理器(Pluggable Annotation Processing API)可供编译器处理。这些处理器在运行时,可以读取、修改和添加 AST 的元素。每次修改完成,都会回到"解析与填充"的阶段重走一个循环,这每一个循环实际上是一个 Round。
语义分析有标注检查和数据及控制流分析两个步骤:
- 标注检查的内容有变量使用前是否已经被声明、变量与赋值之间的数据类型是否能够匹配等等。常量折叠(Constant Folding)是标注检查的一部分。
- 数据及控制流分析则检查诸如局部变量是否有赋值、方法的每条路径是否都有返回值、是否所有的受检异常都被正确处理了等问题。注意,我们都知道 JVM 里面没有checked exception,实际上 JVM 里面也是没有 final local variable的,这些都是由编译期保证的。
接下来字节码生成的部分,分为解糖(desugar)和字节码生成。
解糖就是把语法糖转换成非语法糖的代码,比如把泛型转换为非泛型,把拆装箱换成普通方法。checked exception 和局部变量 final 实际上也可以视为语法糖,因为它们都是由编译器保证而非 JVM 层面强制的。由于类型擦除(Type Erasure,JLS §4.6),Java 的泛型无法像 C# 的具化泛型(Reified Generics)那样进行类型特化优化,在涉及装箱/拆箱的场景下性能会有差异。关于泛型还需要专门说一点:运行时擦除到边界的类型,总是会在 .getClass() 的地方 equals 成功的,这是因为 .Code 属性里面没有类型信息,但其他元数据区(如 LocalVariableTypeTable 的表里)还能拿到类型信息,所以反射才能正常运行下去。
字节码生成阶段会生成 <init>(实例构造器)和 <clinit>(类构造器)。
还有一种特殊的语法糖——条件编译。即方法内的 if 加上布尔常量可以消除无法到达的死代码(不同于后面提到的 Dead Code Elimination)。
晚期(运行)优化
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 来设置半衰期,单位是秒。
-XX:TieredStopAtLevel=3 可以关闭 C2 编译,可选值是 0-4。
-XX:ReservedCodeCacheSize=512m 可以调大编译缓存。这个值如果很小,低版本的虚拟机会触发 JIT 的 bug,系统会卡顿甚至停机,CPU 也会飙升。
JIT 的工作流程如图:
1 | |
所谓回边,就是字节码中控制流向后跳转的指令。顾名思义,回边计数器就是对方法中循环体代码的执行次数进行统计的。有一个 -XX:BackEdgeThreshold 参数可以设置这个回边阈值,但现实中的 JVM 并没有直接采用这一参数,而是使用了 OnStackReplacePercentage 这一参数来配置。
与方法计数器不同,回边计数器没有热度半衰期,因此它统计的是方法执行的绝对次数。而且如果回边计数器溢出,方法计数器也就溢出了,方法执行标准编译过程。
回边计数器的执行过程如图:
1 | |
在缺省的情况下,后台的编译线程和解释器线程是并发执行的,但也可以用 -XX:-BackgroundCompilation 来禁止后台编译。
常见的编译动作(如同 gcc 的 -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)。
可以通过 -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)
这是对性能提升最大的技术,也是其他优化的基础。方法内联的核心思想是:将被调用方法的代码直接复制到调用者的方法体中,消除方法调用的开销(参数压栈、跳转、返回值传递等)。
方法内联的价值不仅仅在于消除调用开销本身(这个开销其实很小),更重要的是它为后续优化打开了大门。内联之后,原本分散在不同方法中的代码被合并到同一个编译单元中,编译器就能看到更大的优化窗口,从而执行常量传播、死代码消除、公共子表达式消除等优化。
HotSpot JVM 对方法内联有一些限制条件:
- 方法体大小限制:默认情况下,字节码大小超过 325 字节(
-XX:MaxInlineSize)的方法不会被内联。对于热点方法,这个阈值会放宽到 35 字节(-XX:FreqInlineSize)。 - 调用深度限制:内联的嵌套深度默认不超过 9 层(
-XX:MaxInlineLevel)。 - 虚方法的处理:Java 中大部分方法调用都是虚调用(
invokevirtual),编译器无法在编译时确定实际调用的目标方法。JIT 通过**类型推测(Type Speculation)**来解决这个问题——如果 Profiling 数据显示某个调用点 95% 以上的时间都调用同一个实现类的方法,JIT 就会乐观地将该方法内联,并在入口处插入一个类型守卫(type guard)检查。如果运行时发现类型不匹配,就触发逆优化(Deoptimization),回退到解释执行。
这也解释了为什么在 Java 中,final 方法和小方法更容易获得性能优势——它们更容易被内联。
逃逸分析(Escape Analysis)
逃逸分析就是考察一个对象是不是会被传递到方法或者线程之外。逃逸的程度可以分为三个层次:
- 不逃逸(NoEscape):对象只在方法内部使用,不会被传递到方法外部,也不会被其他线程访问。
- 方法逃逸(ArgEscape):对象被作为参数传递给其他方法,但不会被其他线程访问。
- 线程逃逸(GlobalEscape):对象被存储到静态字段、被其他线程访问,或者通过方法返回值逃逸到调用者。
如果对象没有逃逸(或只有方法逃逸),则有特别的优化措施:
-
栈上分配:将对象分配在栈上而不是堆上。HotSpot 虽然没有直接实现栈上分配,但通过标量替换间接达到了类似效果——将对象拆解为基本类型后直接在栈上分配。栈上分配的对象随方法返回自动销毁,完全不需要 GC 参与,这对于减轻 GC 压力有显著效果。
-
同步消除(Lock Elision):如果逃逸分析确定对象不会被其他线程访问,则可以安全地移除对该对象的
synchronized操作。例如以下代码中的同步是完全多余的:
1 | |
JIT 编译器通过逃逸分析发现 sb 不会逃逸,就会移除 StringBuffer 内部的 synchronized 操作,使其性能接近非线程安全的 StringBuilder。
- 标量替换(Scalar Replacement):对象是聚合量(Aggregate),基本的数据类型是标量(Scalar)。可以直接不生成对象而生成对象的成员变量,再配合栈上分配,可以极大提高性能。例如:
1 | |
需要注意的是,同步消除可能会暴露原本被不必要的同步所掩盖的并发问题——如果代码本身存在数据竞争,移除多余的同步后,这些问题就会显现出来。
逃逸分析的局限性在于它的分析精度受到方法内联深度的限制。如果一个对象被传递给一个没有被内联的方法,编译器就无法确定它是否会逃逸,只能保守地假设它会逃逸。这也是为什么方法内联是其他优化的基础——没有充分的内联,逃逸分析的效果会大打折扣。
C1 vs C2:两种编译策略的权衡
C1 和 C2 编译器代表了两种截然不同的编译哲学:
| 维度 | C1(Client Compiler) | C2(Server Compiler) |
|---|---|---|
| 编译速度 | 快(毫秒级) | 慢(可能数十毫秒) |
| 代码质量 | 中等 | 高(接近 C/C++ 编译器) |
| 优化深度 | 基本优化(内联、常量折叠) | 深度优化(逃逸分析、循环优化、向量化) |
| 适用场景 | 启动速度敏感的客户端应用 | 吞吐量敏感的服务端应用 |
| Profiling | 收集基本的调用计数 | 利用 C1 收集的 Profiling 数据做推测性优化 |
分层编译(Tiered Compilation,Java 7 默认开启)将两者结合:程序启动时先用 C1 快速编译热点方法,同时收集 Profiling 数据;当 Profiling 数据足够丰富后,再用 C2 对最热的方法进行深度优化。这样既保证了启动速度,又不牺牲峰值性能。
值得一提的是,Java 9 引入的 Graal 编译器是用 Java 编写的 JIT 编译器,它可以作为 C2 的替代品。Graal 的优势在于:用 Java 编写使得编译器本身更容易维护和扩展,且 Graal 支持更激进的推测性优化和部分逃逸分析(Partial Escape Analysis),在某些场景下能产生比 C2 更好的代码。





