JVM编译体系
JVM编译体系
从计算机程序出现的第一天起,对效率的追逐就是程序员天生的坚定信仰,这个过程犹如一场没有终点、永不停歇的 F1 方程式竞赛,程序员是车手,技术平台则是在赛道上飞驰的赛车。
Java 程序到执行一般要经过两个步骤,一个步骤是指从 .java 变为 .class 文件的过程,另一个是运行期把字节码转变成本地机器码的过程。随着提前编译器(AOT,Ahead Of Time Compiler)的发展,也有直接把程序编译成与目标机器指令集相关的二进制代码的过程。
第一个步骤就是常说的“前端编译”,常见的前端编译器有:JDK 的 Javac、Eclipse JDT 中的增量式编译器(ECJ)。
前端编译
前端编译器对代码的运行效率几乎没有任何优化措施可言,哪怕是编译器真的采取了优化措施也不会产生什么实质的效果。
Java 虚拟机设计团队选择把对性能的优化全部集中到运行期的即时编译器中,这样可以让那些即使不是由 javac 编译出的 .class 文件也享受到优化所带来的红利。
如果把“优化”的定义放宽,包括开发人员进行开发时的便捷程度,那么编译器实现的“语法糖”带来的新语言特性,也算是一种优化吧。
随着 JDK 的发展,引入了越来越多的语法糖,这里介绍一下非常重要的泛型。
泛型
Java 开发者几乎都会接触到泛型的使用,首先可以引入一个结论:运行时的 List<String> 和 List<Integer> 其实是同一种类型。
1 | |
Java 选择的泛型实现方式叫作“类型擦除式泛型”(Type Erasure Generics),在编译后的字节码文件中,全部泛型都被替换为原来的裸类型(Raw Type),并且在相应的地方插入了强制转型代码。
“泛型擦除”的概念可以通过下面的代码示例来理解,这是编写的 Java 代码:
1 | |
对这段代码编译后生成的 class 文件反编译之后,生成的代码如下:
1 | |
由此我们也知道了为什么 int、long 这样的基础类型无法作为泛型,因为泛型擦除后统一为 Object,int、long 无法转型为 Object。
这样的实现方式带来了很多的缺点:
- 这个决定后面导致了无数构造包装类和装箱、拆箱的开销,成为 Java 泛型慢的重要原因;
- 由于泛型擦除,会导致在运行时无法取得泛型类型;
- 正常的方法重载只需要参数类型不一样即可,由于类型擦除,不同泛型对象的方法会被认为是相同的方法。
1 | |
Java5.0 和 C#2.0 都会语言增加了泛型的特性,不过实现的方式截然不同,在推出后被对比的结论是 Java 的泛型直到今天依然作为Java 语言不如 C# 语言好用的“铁证”被众人嘲讽。但 Java 选择这样的泛型实现,是出于当时语言现状的权衡,而不是语言先进性或者设计者水平不如 C# 之类的原因。
自动装箱、拆箱
就纯技术的角度而论,自动装箱、自动拆箱与遍历循环(for-each循环)这些语法糖,无论是实现复杂度上还是其中蕴含的思想上都不能和泛型相提并论,但这些在日常使用中是最多的。
以下面一段代码看这些语法糖的实现:
1 | |
下面是代码编译后又反编译后的结果:
1 | |
这段代码一共包含了泛型、自动装箱、自动拆箱、遍历循环与变长参数 5 种语法糖,泛型就不必说了,
- 自动装箱、拆箱在编译之后被转化成了对应的包装和还原方法,如本例中的
Integer.valueOf()与Integer.intValue()方法; - 而遍历循环则是把代码还原成了迭代器的实现,这也是为何遍历循环需要被遍历的类实现
Iterable接口的原因; - 变长参数,它在调用的时候变成了一个数组类型的参数,在变长参数出现之前,程序员的确也就是使用数组来完成类似功能的。
这些语法糖虽然看起来很简单,但出于 Integer.valueOf() 方法共享常见对象的机制,以及包装类 的“==”运算在不遇到算术运算的情况下不会自动拆箱,还有 equals() 方法不处理数据转型的关系, 建议在实际编码中尽量避免这样使用自动装箱与拆箱。
条件编译
条件编译是指:编译代码时,根据预设条件,选择性编译 / 丢弃部分代码,不满足条件的代码直接不会生成字节码,运行时不存在。Java 语言当然也可以进行条件编译,方法就是使用条件为常量的 if 语句。
1 | |
在上面的这个代码示例中,不会生成相应的字节码,因为判断条件为 false,不可能进入。
1 | |
如果是上面这种代码,语法上是合理的,但 javac 会直接被拒绝编译。
关于前端编译在处理语法糖时做的工作,可以通过 .class 反编译后的代码进行比对,包括 try with resources、switch 支持等。
后端编译
如果把字节码看作是程序语言的一种中间表示形式(Intermediate Representation,IR)的话, 那编译器无论在何时、在何种状态下把 Class 文件转换成与本地基础设施(硬件指令集、操作系统)相关的二进制机器码,它都可以视为整个编译过程的后端。
即时编译
主流的即时编译器有:HotSpot 虚拟机的 C1、C2 编译器,Graal 编译器。
目前主流的两款商用 Java 虚拟机(HotSpot、OpenJ9)里,Java 程序最初都是通过解释器(Interpreter)进行解释执行的,当虚拟机发现某个方法或代码块的运行特别频繁,就会把这些代码认定为“热点代码”(Hot Spot Code),为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成本地机器码,并以各种手段尽可能地进行代码优化,运行时完成这个任务的后端编译器被称为即时编译器。
即使编译器的作用就是:把运行频繁的代码译为本地机器码,并尽可能地进行代码优化。
主流的 Java 虚拟机中都是解释器和编译器共存的,结构如下:

当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即运行。当程序启动后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码,这样可以减少解释器的中间损耗,获得更高的执行效率。
图中的 Client Compiler 即为 C1 编译器,Server Compiler 为 C2 编译器,有些资料中提到过的打算替代 C2 的 Graal 编译器,目前更多是作为实验性编译器。
分层编译
如果在终端执行这样的指令:
1 | |
注意最后的输出 mixed mode 和 interpreted mode,实际上就是指“解释加多层编译混合模式”还是“解释模式”,当然也可以调整为“编译模式”,但是解释器仍然要在编译无法进行的情况下介入执行过程。
即时编译器编译本地代码需要占用程序运行时间,通常要编译出优化程度越高的代码,所花费的时间便会越长;而且想要编译出优化程度更高的代码,解释器可能还要替编译器收集性能监控信息,这对解释执行阶段的速度也有所影响。
为了在程序启动响应速度与运行效率之间达到最佳平衡,HotSpot 虚拟机在编译子系统中加入了分层编译的功能,其核心概念就是根据编译器编译、优化的规模与耗时,划分出不同的编译层次。
- 程序纯解释执行,并且解释器不开启性能监控功能(Profiling)。
- 使用客户端编译器将字节码编译为本地代码来运行,进行简单可靠的稳定优化,不开启性能监控功能。
- 仍然使用客户端编译器执行,仅开启方法及回边次数统计等有限的性能监控功能。
- 仍然使用客户端编译器执行,开启全部性能监控,除了上层的统计信息外,还会收集如分支跳转、虚方法调用版本等全部的统计信息。
- 使用服务端编译器将字节码编译为本地代码,相比起客户端编译器,服务端编译器会启用更多编译耗时更长的优化,还会根据性能监控信息进行一些不可靠的激进优化。
以上层次并不是固定不变的,根据不同的运行参数和版本,虚拟机可以调整分层的数量。
实施分层编译后,解释器、客户端编译器和服务端编译器就会同时工作,热点代码可能会被多次编译,用客户端编译器获取更高的编译速度,用服务端编译器来获取更好的编译质量,在解释执行的时候也无须额外承担收集性能监控信息的任务,而在服务端编译器采用高复杂度的优化算法时,客 户端编译器可先采用简单优化来为它争取更多的编译时间。
触发条件
即时编译器会优化运行频繁的代码,那什么算频繁运行呢?目前主流的热点 探测判定方式有两种,分别是:
- 基于采样的热点探测(Sample Based Hot Spot Code Detection)。采用这种方法的虚拟机会周期性地检查各个线程的调用栈顶,如果发现某个(或某些)方法经常出现在栈顶,那这个方法就是“热点方法”。基于采样的热点探测的好处是实现简单高效,还可以很容易地获取方法调用关系(将调用堆栈展开即可),缺点是很难精确地确认一个方法的热度,容易因为受到线程阻塞或别的外界因素的影响而扰乱热点探测。
- 基于计数器的热点探测(Counter Based Hot Spot Code Detection)。采用这种方法的虚拟机会为每个方法(甚至是代码块)建立计数器,统计方法的执行次数,如果执行次数超过一定的阈值就认为它是“热点方法”。这种统计方法实现起来要麻烦一些,需要为每个方法建立并维护计数器,而且不能直接获取到方法的调用关系。但是它的统计结果相对来说更加精确严谨。
在 HotSpot 虚拟机中使用的是第二种基于计数器的热点探测方法,为了实现热点计数,HotSpot 为每个方法准备了两类计数器:方法调用计数器(Invocation Counter)和回边计数器(Back Edge Counter,“回边”的意思 就是指在循环边界往回跳转)。当虚拟机运行参数确定的前提下,这两个计数器都有一个明确的阈值,计数器阈值一旦溢出,就会触发即时编译。
方法调用计数器阈值在客户端模式下是 1500 次,在服务端模式下是 10000 次,可以通过虚拟机参数 -XX:CompileThreshold 来人为设定。它统计的是一段时间内的方法调用的逻辑次数,如果这段时间内未达到,则会缩减热度阈值(半衰期),可以通过参数 -XX: UseCounterDecay 来关闭热度衰减,也可以调整 -XX:CounterHalfLifeTime 修改半衰期时间(秒)。
回边计数器主要统计一个方法中循环体代码执行的次数,它没有半衰期,且计算逻辑相对复杂:
- 虚拟机运行在客户端模式下,回边计数器阈值计算公式为:方法调用计数器阈值(-XX:CompileThreshold)乘以 OSR 比率(-XX:OnStackReplacePercentage)除以100。其中-XX: OnStackReplacePercentage 默认值为 933,如果都取默认值,那客户端模式虚拟机的回边计数器的阈值为 13995。
- 虚拟机运行在服务端模式下,回边计数器阈值的计算公式为:方法调用计数器阈值(-XX: CompileThreshold)乘以(OSR比率(-XX:OnStackReplacePercentage)减去解释器监控比率(-XX: InterpreterProfilePercentage)的差值)除以 100。其中 -XX:OnStackReplacePercentage 默认值为 140, XX:InterpreterProfilePercentage 默认值为 33,如果都取默认值,那服务端模式虚拟机回边计数器的阈值为 10700。
当解释器遇到一条回边指令时,会先查找将要执行的代码片段是否有已经编译好的版本,如果有的话,它将会优先执行已编译的代码,否则就把回边计数器的值加一,然后判断方法调用计数器与回 边计数器值之和是否超过回边计数器的阈值。当超过阈值的时候,将会提交一个栈上替换编译请求, 并且把回边计数器的值稍微降低一些,以便继续在解释器中执行循环,等待编译器输出编译结果,流程如图:

验证 JIT 即时编译可以参考:JVM的即时编译JIT的介绍 - 实践。
提前编译
提前编译器有:JDK 的 Jaotc。
因为 Java 的一个核心优势是平台中立性,其宣传口号是“一次编译,到处运行”,与平台相关的提前编译在理念上就是有冲突的,所以期间沉寂了很久。
在某些领域、某些人眼里,只要能获得更好的执行性能,什么平台中立性、 字节膨胀、动态扩展,一切皆可舍弃,唯一的问题就只有“提前编译真的会是获得更高性能的银弹吗?”
提前编译产品分为两大类:
- 像 C、C++ 那样,在程序运行之前把程序代码编译成机器码的静态翻译工作;
- 把原本即时编译器在运行时要做的编译工作提前做好并保存下来,下次运行到这些代码(譬如公共库代码在被同一台机器其他 Java 进程使用)时直接把它加载进来使用。
第一类直指即时编译的最大弱点,因为无论即时编译器如何优化,即时编译消耗的时间都是原本可用于程序运行的时间,消耗的运算资源都是原本可用于程序运行的资源,这个约束从未减弱,更不会消失。这样的提前编译可以充分的进行程序优化以获得更好的运行时性能,反正做镜像阶段慢一点并没有什么大影响。
第二类也可以叫动态提前编译或即时编译缓存,本质是给即时编译器做缓存加速,去改善 Java 程序的启动时间,以及需要一段时间预热后才能到达最高性能的问题,目前已被主流商用 JDK 支持。
Jaotc 提前编译器是一个基于Graal 编译器实现的新工具,目的是让用户可以针对目标机器,为应用程序进行提前编译。HotSpot 运行时可以直接加载这些编译的结果,实现加快程序启动速度,减少程序达到全速运行状态所需时间的目的。这里面确实有比较大的优化价值,试想一下,各种 Java 应用最起码会用到 Java 的标准类库,如 java.base 等模块,如果能够将这个类库提前编译好,并进行比较高质量的优化,显然能够节约不少应用运行时的编译成本。
提前编译的优点毋庸置疑,但实现起来很有难度,就算不提平台无关性、字节膨胀,编译不仅与目标机器相关,甚至还必须与虚拟机的运行时参数绑定,例如不同垃圾收集器生成的内存屏障代码,如果要提前编译就需要这些工作也平移过去,新版的 Graal AOT 已大幅弱化该限制。
尽管即时编译在时间和运算资源方面的劣势是无法忽视的,但其依然有自己的优势。
- 性能分析制导优化:即时编译会通过包括但不限于计数器的性能监控信息,集中优化和分配更好的资源(分支预测、寄存器、缓存等)。例如某个分支,只有运行时才能看出执行的偏好性;
- 激进预测性优化:如果性能监控信息能够支持它做出一些正确的可能性很大但无法保证绝对正确的预测判断,就已经可以大胆地按照高概率的假设进行优化,万一真的走到罕见分支上,大不了退回到低级编译器甚至解释器上去执行,并不会出现无法挽救的后果;
- 链接时优化:Java语言天生就是动态链接的,一个个 Class 文件在运行期被加载到虚拟机内存当中,然后在即时编译器里产生优化后的本地代码,而 C/C++ 主程序与动态链接库的代码在它们编译时是完全独立的,两者各自编译、优化自己的代码,跨越动态链接库的方法内联几乎不存在。
提前编译有它的应用场景,也有它的弱项与不足,相信未来很长一段时间内,即时编译和提前编译都会是 Java 后端编译技术的共同主角。
编译优化
[OpenJDK Wiki](PerformanceTacticIndex - HotSpot - OpenJDK Wiki) 里展示了相对比较全面的、即时编译器中采用的优化技术列表,这里介绍几个典型的优化方面:
- 方法内联;
- 逃逸分析;
- 公共表达式消除;
- 数组边界检查消除。
方法内联的优化行为理解起来是很简单的,不过就是把目标方法的代码原封不动地“复制”到发起调用的方法之中,避免发生真实的方法调用而已。但实现起来颇有难度,由于 Java 的多态特性,只有在运行时才知道某些方法真正的实现,编译器很难在编译时得出绝对准确的结论。
Java 虚拟机引入了一种名为类型继承关系分析(Class Hierarchy Analysis,CHA)的技术,这是整个应用程序范围内的类型分析技术,用于确定在目前已加载的类中,某个接口是否有多于一种的实现、某个类是否存在子类、某个子类是否覆盖了父类的某个虚方法等信息。
逃逸分析(Escape Analysis)是目前 Java 虚拟机中比较前沿的优化技术,它与类型继承关系分析一 样,并不是直接优化代码的手段,而是为其他优化措施提供依据的分析技术。逃逸分析的基本原理是:分析对象动态作用域,是否可能从方法、线程中传递(逃逸)出去。有了逃逸分析,编译器可以做出如下优化:
- 栈上分配(Stack Allocations):一方面,堆内存是线程共享的,涉及分配对象没有在栈上快,另一方面,堆内存的回收需要通过 GC,而栈内存随栈帧出栈而销毁。一般来讲,完全不会逃逸的局部对象的比例还是很大的。
- 标量替换(Scalar Replacement):如果把一个Java对象拆散,根据程序访问的情况,将其用到的成员变量恢复为原始类型来访问,这个过程就称为标量替换。假如逃逸分析能够证明一个对象不会被方法外部 访问,并且这个对象可以被拆散,那么程序真正执行的时候将可能不去创建这个对象,而改为直接创建它的若干个被这个方法使用的成员变量来代替。例如新建了一个对象 user,用到了 age 属性,那么可能会直接定义 int age 代替创建 user 对象使用。
- 同步消除(Synchronization Elimination):线程同步本身是一个相对耗时的过程,如果逃逸分析能够确定一个变量不会逃逸出线程,无法被其他线程访问,那么这个变量的读写肯定就不会有竞争, 对这个变量实施的同步措施也就可以安全地消除掉。例如有时候的
synchronized代码块锁。
公共子表达式消除是一项非常经典的、普遍应用于各种编译器的优化技术,它的含义是:如果一 个表达式之前已经被计算过了,并且从先前的计算到现在计算这个表达式涉及到的所有变量的值都没有发生变化,那么没有必要花时间再对它重新进行计算。在 IDE 中, .var 赋值时会进行提醒。
数组边界检查消除(Array Bounds Checking Elimination)是即时编译器中的一项语言相关的经典优化技术。Java 中数组下标不能越界,也就是每次访问都隐含着判断 i >= 0 && i < array.length(),对于拥有大量数组访问的程序代码,这必定是一种性能负担。如果访问使用常量下标或访问发生在循环之中,并且使用循环变量来进行数组的访问。假如编译器通过数据流分析就可以判定循环变量的取值范围永远在区间[0,array.length) 之内,那么在循环中就可以把整个数组的上下界检查消除掉,这可以节省很多次的条件判断操作。
配置参数
JIT 编译器基础参数:
| 参数名称 | 默认值 | 作用说明 |
|---|---|---|
-XX:+UseCompiler |
启用(默认) | 启用 JIT 编译器。禁用时完全由解释器执行。 |
-XX:+TieredCompilation |
JDK 8+ 启用 | 启用分层编译,结合 C1(快速编译)和 C2(深度优化)。 |
-XX:-TieredCompilation |
– | 关闭分层编译,回退到传统的 C2 模式。 |
-XX:TieredStopAtLevel=<n> |
4 |
分层编译最高级别:1=纯C1无优化,2=C1部分优化,3=C1含分析,4=C2优化。 |
-XX:+PrintCompilation |
关闭 | 打印方法编译日志(时间戳、编译层级、方法名等)。 |
-XX:+CITime |
关闭 | 打印 JIT 编译耗时统计。 |
-XX:+PrintInlining |
关闭 | 打印方法内联信息,诊断内联决策。 |
方法计数与触发阈值参数:
| 参数名称 | 默认值 | 作用说明 |
|---|---|---|
-XX:CompileThreshold=<N> |
10000 (C2) / 1500 (C1) |
方法被编译前所需调用次数(非分层编译模式下)。分层编译下此参数失效。 |
-XX:Tier2CompileThreshold=<N> |
0(自动计算) |
第2层编译阈值(从解释或第1层提升)。 |
-XX:Tier3CompileThreshold=<N> |
2000 |
第3层编译阈值(C1 带 profiling)。 |
-XX:Tier4CompileThreshold=<N> |
4000 |
第4层编译阈值(C2 优化编译)。 |
-XX:OnStackReplacePercentage=<N> |
140 (C2) / 933 (C1) |
控制 OSR(栈上替换)触发阈值,与循环回边次数相关。 |
-XX:CompileThresholdScaling=<float> |
1.0 |
缩放所有编译阈值,例如 0.5 使阈值减半。 |
-XX:-UseCounterDecay |
使用衰减 | 关闭方法调用计数器衰减,防止长期运行的方法热度降低。 |
-XX:CounterHalfLifeTime=<seconds> |
30 |
计数器半衰期(秒),用于热度衰减计算。 |
编译器选择(C1 / C2 / Graal JIT):
| 参数名称 | 默认值 | 作用说明 |
|---|---|---|
-XX:+UseC1 |
分层编译时启用 | 强制使用 C1 编译器(仅限客户端模式或分层编译组合)。 |
-XX:+UseC2 |
默认启用 | 强制使用 C2 编译器(服务端模式)。 |
-XX:+UseGraalJIT |
关闭(实验性) | 使用 Graal 编译器作为 JIT(需解锁实验性参数 -XX:+UnlockExperimentalVMOptions)。 |
-XX:+UseJVMCICompiler |
关闭 | 启用 JVMCI 接口,允许 Graal 等编译器替换 C2。 |
-XX:+EagerJVMCI |
关闭 | 提前初始化 JVMCI 编译器(减少首次编译延迟)。 |
提前编译(AOT)参数:
| 参数名称 | 默认值 | 作用说明 |
|---|---|---|
-XX:+UseAOT |
JDK 9-14 支持 | 启用 AOT 编译生成的共享库(如 jaotc 生成的 .so)。 |
-XX:AOTLibrary=<path> |
– | 指定 AOT 库路径(多个用冒号分隔)。 |
-XX:+PrintAOT |
关闭 | 打印 AOT 库加载和使用信息。 |
-XX:+UseAOTStrictLoading |
关闭 | 当 AOT 库版本不匹配时导致 JVM 退出(而非忽略该库)。 |
注意:Oracle JDK 15 起移除了内置的 AOT(
jaotc),GraalVM Native Image 是主流替代方案,其参数完全不同(通过native-image工具及配置文件)。
代码缓存(CodeCache)参数:
| 参数名称 | 默认值 | 作用说明 |
|---|---|---|
-XX:ReservedCodeCacheSize=<size> |
240MB (取决于 JVM 模式) | 代码缓存最大容量,存放 JIT 编译后的机器码。 |
-XX:InitialCodeCacheSize=<size> |
48MB | 代码缓存初始大小。 |
-XX:+PrintCodeCache |
关闭 | 打印代码缓存使用情况(通常在 -XX:+PrintCompilation 输出中)。 |
-XX:+UseCodeCacheFlushing |
启用 | 当代码缓存满时,允许清除非热点方法。 |
编译控制与内联参数:
| 参数名称 | 默认值 | 作用说明 |
|---|---|---|
-XX:MaxInlineLevel=<N> |
9 |
最大内联嵌套深度。 |
-XX:MaxInlineSize=<N> |
35 字节 |
可被内联的方法最大字节码大小。 |
-XX:FreqInlineSize=<N> |
325 字节 |
热点方法可被内联的最大字节码大小。 |
-XX:+InlineSynchronizedMethods |
关闭 | 允许内联同步方法(保守行为,默认不内联)。 |
-XX:CompileCommand=<command>,<method>[,...] |
– | 为特定方法指定编译指令,如 exclude、inline、dontinline、compileonly 等。 |
-XX:+LogCompilation |
关闭 | 将编译日志输出到 hotspot.log 文件(详细 XML 格式)。 |
调试与诊断参数:
| 参数名称 | 默认值 | 作用说明 |
|---|---|---|
-XX:+PrintAssembly |
关闭 | 打印编译后的汇编代码(需安装 hsdis 反汇编库)。 |
-XX:+TraceClassResolution |
关闭 | 跟踪类解析过程,辅助诊断链接问题。 |
-XX:+PrintInterpreter |
关闭 | 打印解释器信息(调试用)。 |
-XX:+CITime |
关闭 | 打印 JIT 编译时间概要。 |
-XX:+CompilationStatistics |
关闭 | 打印详细的编译统计数据(仅调试版本)。 |
使用建议:生产环境谨慎启用调试参数,因其会产生大量日志或影响性能。调优 JIT 时推荐组合使用 -XX:+PrintCompilation + -XX:+PrintInlining,配合 -XX:+LogCompilation 使用 JITWatch 分析。