JVM - 执行引擎
# 1. 执行引擎概述 (Execution Engine Overview)
Java 虚拟机的执行引擎是其核心组成部分之一,位于 JVM 架构的下层,与垃圾回收器等组件协同工作。
# 1.1 执行引擎的核心任务
JVM 的主要任务是加载 .class
文件(字节码)到其内部的运行时数据区。然而,字节码指令并非操作系统可以直接理解和执行的本地机器指令。它是一种平台无关的中间表示,包含了 JVM 可识别的指令、符号表和其他辅助信息。
执行引擎的核心任务就是将这些字节码指令翻译 (解释或编译) 成特定操作系统和硬件平台能够识别和执行的本地机器指令 (Native Code)。简单来说,执行引擎扮演着高级语言(编译后的字节码)到机器语言的“翻译官”角色。
# 1.2 物理机与虚拟机的执行引擎
- 物理机:执行引擎直接构建在硬件(处理器、缓存)、指令集和操作系统层面之上,直接执行硬件支持的机器指令。
- 虚拟机:执行引擎是软件实现的,它可以模拟硬件行为,执行自定义的指令集(如 Java 字节码)。这种软件实现赋予了虚拟机跨越物理硬件限制的能力。
# 1.3 执行引擎的工作流程
执行引擎在执行 Java 方法时,其工作流程大致如下:
- 读取指令:执行引擎依赖 PC 寄存器 来确定下一条需要执行的字节码指令的地址。
- 执行指令:获取指令后,执行引擎执行该指令对应的操作。这可能涉及:
- 数据操作:从局部变量表加载数据到操作数栈,或将操作数栈的数据存回局部变量表。
- 运算:对操作数栈上的数据执行算术、逻辑等运算。
- 对象操作:通过局部变量表中的对象引用,访问 Java 堆中的对象实例数据。
- 方法调用:可能需要访问 方法区 中的类元数据(通过对象头中的类型指针或直接来自符号引用解析)来确定要调用的方法。
- 更新 PC 寄存器:执行完当前指令后,PC 寄存器更新为下一条将要执行的指令的地址。
- 循环:重复以上步骤,直到方法执行完毕。
输入与输出: 尽管具体实现可能不同,但所有 Java 虚拟机的执行引擎在概念上都接受字节码二进制流作为输入,经过解释或编译的处理过程,最终产生程序的执行结果作为输出。
# 2. Java 代码编译和执行过程
理解 Java 代码从源码到最终执行的完整流程有助于我们认识执行引擎所处的位置和作用。
整体流程概览:
(橙色部分为编译期,蓝色和绿色部分为运行期)
前端编译 (javac):
- 由 Java 源码编译器 (javac) 完成。
- 将
.java
源文件编译成.class
字节码文件。 - 这个过程在运行 JVM 之前进行,主要进行词法分析、语法分析、语义分析、生成字节码等。
- 此阶段的优化有限,主要是语法层面的(如解语法糖)。
后端编译/解释执行 (JVM 执行引擎):
- 由 JVM 执行引擎 在程序运行时完成。
- 将
.class
文件中的字节码加载到内存后,执行引擎负责将其转换为本地机器指令并执行。 - 主要有两种方式:
- 解释执行 (Interpretation):解释器逐条将字节码指令翻译成机器码并立即执行。
- 即时编译 (Just-In-Time Compilation, JIT):JIT 编译器将热点代码(经常执行的方法或循环)一次性编译成高效的本地机器码,并缓存起来,后续执行直接运行编译后的代码。
解释器与 JIT 编译器在 JVM 中的角色:
(现代 HotSpot JVM 通常结合使用解释器和 JIT 编译器)
# 为什么 Java 是半编译半解释型语言?
- 编译:Java 源代码 (
.java
) 首先被javac
编译成平台无关的字节码 (.class
)。这是编译过程。 - 解释/JIT 编译:JVM 在运行时,通过解释器逐条解释执行字节码,或者通过 JIT 编译器将热点字节码编译成本地机器码再执行。
- 结论:Java 语言的执行结合了编译期(前端编译)和运行期(解释或 JIT 编译)的处理,因此被称为“半编译半解释型”语言。这种机制使得 Java 既能实现“一次编译,到处运行”的跨平台性,又能通过 JIT 获得接近本地代码的执行效率。
# 3. 机器码、指令、汇编语言与字节码辨析
理解这些底层概念有助于更好地认识执行引擎的工作原理。
- 机器码 (Machine Code):
- 计算机 CPU 能直接识别和执行的二进制编码指令。
- 由 0 和 1 组成,可读性极差。
- 执行速度最快。
- 平台相关:不同类型的 CPU(如 x86, ARM)有不同的机器码指令集。
- 指令 (Instruction):
- 为了提高可读性,用符号化的助记符(通常是英文缩写,如
mov
,add
,jmp
)来表示特定的机器码操作。 - 一条指令通常对应一条或多条机器码。
- 仍然是平台相关的。
- 为了提高可读性,用符号化的助记符(通常是英文缩写,如
- 指令集 (Instruction Set):
- 特定 CPU 架构所支持的所有指令的集合。例如 x86 指令集、ARM 指令集。
- 汇编语言 (Assembly Language):
- 使用指令助记符、地址符号、标号等来编写程序,比直接写机器码或指令更易读、易写。
- 需要通过汇编器 (Assembler) 将汇编代码翻译成目标平台的机器码。
- 本质上是机器指令的符号化表示,仍然高度平台相关。
高级语言 (High-Level Language):
- 如 Java, C, C++, Python 等,更接近自然语言,抽象层次高,平台无关。
- 需要通过编译器 (Compiler) 或解释器 (Interpreter) 转换成机器能够执行的代码(可能是机器码、汇编代码或中间代码)。
C/C++ 的编译执行过程:通常源码先被编译成汇编代码,再由汇编器转换成目标机器码,最后链接生成可执行文件。
字节码 (Bytecode):
- 一种中间状态的二进制代码,介于高级语言和机器码之间。
- 比机器码更抽象,平台无关。
- 不能直接被 CPU 执行,需要虚拟机 (VM) 或解释器将其翻译成目标平台的机器码。
- 优点:实现了“一次编译,到处运行”。Java 字节码是其典型的应用。
# 4. 解释器 (Interpreter)
JVM 最初的设计理念是为了实现跨平台性,因此选择了在运行时逐行解释执行字节码的方式。
# 4.1 解释器的角色
解释器在运行时扮演“翻译者”的角色,将字节码文件中的指令逐条翻译成当前平台的本地机器指令并立即执行。执行完一条后,根据 PC 寄存器的指示读取并翻译下一条。
# 4.2 解释器的类型 (HotSpot VM)
- 字节码解释器 (Bytecode Interpreter):
- 早期的实现方式。
- 完全通过软件代码来模拟字节码的执行过程。
- 效率非常低下。
- 模板解释器 (Template Interpreter):
- 目前普遍使用的方式。
- 为每一条字节码指令都预先编写好一个模板函数。
- 这个模板函数中包含了直接生成该字节码在当前平台对应的机器码的逻辑。
- 执行时,遇到某条字节码,就调用对应的模板函数生成并执行机器码。
- 效率相比字节码解释器大大提高。
在 HotSpot VM 中,解释器主要由 Interpreter
模块(实现核心功能)和 Code
模块(管理运行时生成的本地代码)构成。
# 4.3 解释器的现状与局限
- 优点:
- 启动速度快:无需编译等待,可以立即执行代码。
- 实现简单:相对于编译器,解释器的设计和实现更简单。
- 缺点:
- 执行效率低:逐条解释执行引入了额外的翻译开销,特别是对于频繁执行的热点代码,重复解释效率很低。这使得纯解释执行的语言(如早期的 Java, Python, Ruby 等)性能通常不如编译型语言(如 C/C++)。
为了克服解释器的性能瓶颈,JVM 引入了即时编译 (JIT) 技术。
# 5. 即时编译器 (JIT Compiler)
JIT 编译器是现代高性能 JVM 的关键技术,旨在提升 Java 程序的执行效率。
# 5.1 Java 代码的执行方式再议
- 纯解释执行:所有字节码都由解释器翻译执行。启动快,执行慢。
- 纯编译执行:启动时将所有字节码编译成本地代码再执行(如 AOT)。启动慢,执行快。
- 混合模式 (Mixed Mode):HotSpot 默认采用的方式。结合解释器和 JIT 编译器:
- 启动时:解释器先工作,让程序快速运行起来。
- 运行时:JIT 编译器在后台分析程序的运行情况,识别出热点代码 (Hot Code)。
- 编译热点代码:JIT 将热点代码编译成高度优化的本地机器码。
- 替换执行:当再次执行到这些代码时,直接运行编译后的本地代码,获得极高的执行效率。
这种混合模式兼顾了启动速度和执行效率。如今 Java 程序的性能已经可以与 C/C++ 程序相媲美。
# 5.2 为何需要解释器与 JIT 并存?
既然 JIT 编译后效率很高,为什么 HotSpot 还要保留解释器?(对比 JRockit VM,它没有解释器,完全依赖 JIT)
- 保证快速启动:解释器可以立即执行代码,避免了 JIT 编译所需的等待时间。对于需要快速响应的客户端应用或某些服务端场景,启动速度很重要。JRockit 主要面向服务端,启动时间相对不敏感。
- 节约编译开销:不是所有的代码都值得编译。对于只执行一次或几次的代码,解释执行的开销通常小于编译成本地代码的开销。JIT 只编译热点代码,避免了不必要的编译。
- 作为编译器的“逃生门” (Bailout):JIT 编译器可能会进行一些激进的优化(例如基于某些假设进行优化)。如果在运行时发现这些优化假设不成立(例如,原本以为只有一个实现类的接口突然加载了另一个实现类),JIT 需要能够退回到解释执行状态,以保证程序的正确性。解释器的存在为这种“去优化”或“逆优化”提供了保障。
# 5.3 HotSpot JVM 的执行方式总结
- 启动时,优先使用解释器执行,快速响应。
- 随着程序运行,热点探测功能开始工作,识别频繁执行的代码。
- JIT 编译器在后台将热点代码编译成优化过的本地机器码。
- 编译完成后,执行引擎切换到执行编译后的本地代码,提升性能。
案例:冷热机状态与发布风险
线上服务器刚启动时处于“冷机”状态,大部分代码由解释器执行,性能较低。运行一段时间后,热点代码被 JIT 编译,进入“热机”状态,性能较高。如果在发布时,一次性将大量流量切到刚启动的、处于冷机状态的服务器上,可能会因为性能不足以承载流量而导致服务器宕机。这体现了 JIT 编译对性能的显著影响以及分批发布的重要性。
# 5.4 Java 编译期概念区分
- 前端编译器 (Frontend Compiler):如
javac
, Eclipse JDT (ECJ)。将.java
编译成.class
。 - 后端/运行时编译器 (Backend/Runtime Compiler / JIT Compiler):如 HotSpot 的 C1, C2, Graal。在运行时将字节码编译成机器码。
- 静态提前编译器 (Ahead-of-Time Compiler, AOT Compiler):如
jaotc
(基于 Graal), GCJ, Excelsior JET。在运行前直接将.java
或.class
编译成本地机器码。
# 5.5 热点探测技术 (Hot Spot Detection)
JIT 编译器如何知道哪些代码是“热点代码”?主要依靠热点探测技术,目前 HotSpot 采用的是基于计数器的热点探测。
JVM 会为每个方法(或代码块)维护两个计数器:
方法调用计数器 (Invocation Counter):
- 作用:统计方法被调用的频率(非绝对次数,除非关闭热度衰减)。
- 阈值:有一个预设的阈值 (
-XX:CompileThreshold
)。当计数器超过阈值时,会触发 JIT 编译(通常是 C1 或 C2 编译)。- Client 模式默认:1500 次
- Server 模式默认:10000 次
- 工作流程:方法被调用时,先检查是否有已编译版本。若无,计数器+1,然后判断 计数器总和(方法调用计数器 + 回边计数器)是否超阈值。若超阈值,提交编译请求。若有已编译版本,优先执行编译后的代码。
- 热度衰减 (Counter Decay):为了统计“近期”热度,计数器会周期性地减半(通常在 GC 时进行)。这使得长时间不被调用的方法的热度会下降,避免所有方法最终都被编译。
- 可以通过
-XX:-UseCounterDecay
关闭衰减,统计绝对调用次数。 - 可以通过
-XX:CounterHalfLifeTime=<seconds>
设置半衰周期。
- 可以通过
回边计数器 (Back Edge Counter):
- 作用:统计方法内部循环体代码被执行的次数。“回边”指字节码中向后跳转的指令(构成循环)。
- 目的:识别热点循环,触发 OSR (On-Stack Replacement) 编译。OSR 允许在方法执行过程中(即使方法调用次数未达阈值),直接将正在执行的循环编译成本地代码,并在循环下一次迭代时切换到执行编译后的版本,以优化长时间运行的循环。
- 阈值:也有一个阈值 (
-XX:BackEdgeThreshold
),通常与CompileThreshold
联动计算。
# 5.6 HotSpot VM 执行模式设置
可以通过 JVM 参数控制执行引擎的行为:
-Xint
:强制纯解释器模式执行。所有代码都由解释器执行,禁用 JIT。用于调试或对比性能。-Xcomp
:强制纯编译器模式执行。程序启动时即尝试将所有字节码编译成本地代码(JIT 编译)。如果编译失败,解释器会介入作为“逃生门”。启动慢,理论上运行峰值性能高(但可能因无法解释执行而遇到问题)。-Xmixed
(默认):混合模式。解释器和 JIT 编译器协作执行,平衡启动速度和峰值性能。
代码性能测试示例:
// 文件名: IntCompTest.java
/**
* 测试不同执行模式下的性能差异
*/
public class IntCompTest {
public static void main(String[] args) {
long start = System.currentTimeMillis();
// 执行一个计算密集型任务:计算100万次100以内的质数
testPrimeNumber(1000000);
long end = System.currentTimeMillis();
System.out.println("花费的时间为:" + (end - start) + "ms");
}
/**
* 计算 100 以内的质数 count 次
* @param count 循环次数
*/
public static void testPrimeNumber(int count) {
for (int i = 0; i < count; i++) {
label: for (int j = 2; j <= 100; j++) {
for (int k = 2; k <= Math.sqrt(j); k++) {
if (j % k == 0) {
continue label; // 如果能被整除,则不是质数,跳到外层循环继续
}
}
// 如果内层循环正常结束,说明是质数
// System.out.println(j); // 打印质数(为测试性能通常注释掉)
}
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
在 VM Options 中分别设置 -Xint
, -Xcomp
, -Xmixed
运行,观察耗时:
-Xint
(纯解释):约 6520ms (示例值,非常慢)-Xcomp
(纯编译):约 950ms (示例值,快很多,但启动可能稍慢)-Xmixed
(混合模式,默认):约 936ms (示例值,通常最快或接近-Xcomp
)
实验结果清晰地表明 JIT 编译对性能的巨大提升。
# 5.7 HotSpot VM 内置的 JIT 编译器:C1 与 C2
HotSpot 虚拟机内置了两个 JIT 编译器,以适应不同的场景和优化需求:
- Client Compiler (C1 编译器):
- 目标:注重编译速度和资源消耗。
- 优化:进行简单、可靠的局部优化,如方法内联(有限)、去虚拟化、冗余消除。编译耗时短。
- 适用场景:对启动速度敏感的客户端应用,或作为分层编译的低层级编译器。
- 激活方式:早期通过
-client
参数(JDK 8 后-client
参数基本无效,主要由分层编译控制)。
- Server Compiler (C2 编译器):
- 目标:追求最高性能,生成高度优化的代码。
- 优化:进行复杂、激进的全局优化,编译耗时长,但生成的代码执行效率极高。典型优化包括基于逃逸分析 (Escape Analysis) 的:
- 标量替换 (Scalar Replacement):将不逃逸的对象拆解成标量(基本类型)字段,直接在栈上或寄存器中操作,避免堆分配。
- 栈上分配 (Stack Allocation):标量替换的直接结果,将对象字段分配在栈上。
- 同步消除 (Synchronization Elimination / Lock Elision):移除对不逃逸对象的不必要同步操作。
- 适用场景:对峰值性能要求高的服务端应用,或作为分层编译的高层级编译器。
- 实现:通常使用 C++ 实现,优化算法复杂。
- 激活方式:早期通过
-server
参数(JDK 8 后-server
参数也基本无效)。
C1 与 C2 总结:
- C1:编译快,优化少,启动快。
- C2:编译慢,优化多,运行快。
# 6. 分层编译策略 (Tiered Compilation)
为了结合 C1 的快速编译和 C2 的高效代码,HotSpot 在 Java 7 中引入了分层编译策略,并在 Java 8 中默认开启。
核心思想:根据代码的“热度”和执行情况,将其划分到不同的编译层级,逐步进行优化。
典型层级 (可能随版本变化):
- Level 0: 解释执行 (Interpreter):所有代码从此开始。收集基本的性能监控数据 (Profiling)。
- Level 1: C1 编译 (Simple C1 Compile):当方法调用次数或循环回边次数达到较低阈值时触发。进行快速的、无 Profiling 的基本编译。
- Level 2: C1 编译 (Limited Profile C1 Compile):如果 Level 1 代码执行仍然很频繁,可能进入此层级。C1 编译器会插入少量 Profiling 代码,收集更详细的类型等信息。
- Level 3: C1 编译 (Full Profile C1 Compile):收集完整的 Profiling 数据,为 C2 的优化做准备。这是 C1 能达到的最高优化级别。
- Level 4: C2 编译 (C2 Compile):当方法或循环被确定为非常热点,并且收集到了足够的 Profiling 信息后,触发 C2 编译器进行最大程度的优化编译。
优点:
- 平衡启动与性能:启动时快速进入 C1 编译的代码,获得初步性能提升;对真正热点的代码,最终升级到 C2 获得峰值性能。
- 动态适应:根据运行时收集的性能数据进行编译决策,更智能。
控制参数 (Java 8+):
-XX:+TieredCompilation
:默认开启。-XX:-TieredCompilation
:禁用分层编译。此时 JVM 会根据-client
或-server
(虽然已不推荐)或默认模式选择仅使用 C1 或 C2(通常 Server 模式默认 C2)。-XX:TieredStopAtLevel=<level>
:可以强制编译器停在某个层级,例如-XX:TieredStopAtLevel=1
只使用 C1 编译(类似早期-client
模式)。
# 7. 新兴编译器技术
# 7.1 Graal 编译器
背景:一个用 Java 编写的、可用于 JIT 或 AOT 的高性能动态编译器。从 JDK 10 开始作为实验性 JIT 编译器集成到 HotSpot 中。
特点:
- 性能优越:编译效果在短时间内已能媲美甚至超越 C2。
- 模块化、可扩展性好。
- 支持多语言:是 GraalVM 多语言平台的核心。
激活方式 (实验性):
-XX:+UnlockExperimentalVMOptions -XX:+UseJVMCICompiler
1未来趋势:有望成为 HotSpot 未来的默认 JIT 编译器。
# 7.2 AOT 编译器 (Ahead-of-Time Compiler)
- 概念:与 JIT(运行时编译)相对,AOT 是在程序运行之前将字节码直接编译成本地机器码。
- JDK 引入:JDK 9 引入了实验性的 AOT 编译工具
jaotc
,它利用了 Graal 编译器。 - 流程:
.java -> .class -> (jaotc) -> .so
(动态共享库) - 优点:
- 改善启动性能:JVM 加载预编译好的本地代码库,无需等待 JIT 预热,减少了 Java 应用“首次运行慢”的问题。
- 缺点:
- 牺牲跨平台性:需要为不同的目标平台(硬件、OS)单独编译生成本地代码库。
- 降低动态性:所有需要加载的代码在编译时必须已知,限制了 Java 的动态类加载、反射等特性。
- 编译效果限制:无法利用 JIT 在运行时收集到的 Profiling 信息进行激进优化,编译出的代码峰值性能可能不如 C2。
- 成熟度:目前仍处于发展阶段,支持和优化有待完善。
总结:AOT 适用于对启动性能要求极高、运行环境固定、动态性要求不强的场景,例如某些嵌入式系统或微服务实例。它与 JIT 并非完全替代关系,未来可能结合使用。
分层编译(Tiered Compilation)策略:程序解释执行(不开启性能监控)可以触发 C1 编译,将字节码编译成机器码,可以进行简单优化,也可以加上性能监控,C2 编译会根据性能监控信息进行激进优化。
不过在 Java7 版本之后,一旦开发人员在程序中显式指定命令 -XX:+TieredCompilation
时,将会开启分层编译策略,由 C1 编译器和 C2 编译器相互协作共同来执行编译任务。
在 Java8 中,默认开启分层编译,-client
和 -server
的设置已经是无效的了。如果只想开启 C2,可以关闭分层编译(XX:-TieredCompilation
),如果只想用 C1,可以在打开分层编译的同时,使用参数:-XX:TieredStopAtLevel=1
。
总结
- 一般来讲,JIT 编译出来的机器码性能比解释器高
- C2 编译器启动时长比 C1 慢,系统稳定执行以后,C2 编译器执行速度远快于 C1 编译器
# Graal编译器
- 自 JDK10 起,HotSpot 又加入了一个全新的及时编译器:Graal 编译器
- 编译效果短短几年时间就追评了 C2 编译器,未来可期
- 目前,带着实验状态标签,需要使用开关参数去激活才能使用
参数如下:
-XX:+UnlockExperimentalvMOptions -XX:+UseJVMCICompiler
# AOT编译器
JDK9 引入了 AOT 编译器(静态提前编译器,Ahead of Time Compiler)。
Java 9 引入了实验性 AOT 编译工具 jaotc。它借助了 Graal 编译器,将所输入的 Java 类文件转换为机器码,并存放至生成的动态共享库之中。
所谓 AOT 编译,是与即时编译相对立的一个概念。我们知道,即时编译指的是在程序的运行过程中,将字节码转换为可在硬件上直接运行的机器码,并部署至托管环境中的过程。而 AOT 编译指的则是,在程序运行之前,便将字节码转换为机器码的过程。
.java -> .class -> (使用 jaotc) -> .so
最大的好处:Java 虚拟机加载已经预编译成二进制库,可以直接执行。不必等待及时编译器的预热,减少 Java 应用给人带来「第一次运行慢」的不良体验。
缺点:
- 破坏了 Java「一次编译,到处运行」,必须为每个不同的硬件,OS 编译对应的发行包
- 降低了 Java 链接过程的动态性,加载的代码在编译器就必须全部已知
- 还需要继续优化中,最初只支持 Linux X64 java base