热点字节码编译成本地机器码
- 前言
- JIT编译器如何将热点字节码编译为本地机器码
-
- [一、学习目标:理解 JIT 编译的本质](#一、学习目标:理解 JIT 编译的本质)
- [二、JIT 编译器在 JVM 执行体系中的位置](#二、JIT 编译器在 JVM 执行体系中的位置)
- [三、JVM 如何识别热点方法](#三、JVM 如何识别热点方法)
-
- [3.1 方法调用计数](#3.1 方法调用计数)
- [3.2 循环回边计数与 OSR](#3.2 循环回边计数与 OSR)
- [3.3 对应 HotSpot 源码](#3.3 对应 HotSpot 源码)
- [四、JIT 如何启动编译任务](#四、JIT 如何启动编译任务)
-
- [4.1 CompileBroker 的职责](#4.1 CompileBroker 的职责)
- [4.2 源码分析:CompileTask](#4.2 源码分析:CompileTask)
- [4.3 编译线程如何执行任务](#4.3 编译线程如何执行任务)
- [五、JIT 如何将 JVM 字节码转换为中间表示 IR](#五、JIT 如何将 JVM 字节码转换为中间表示 IR)
-
- [5.1 为什么需要中间表示 IR](#5.1 为什么需要中间表示 IR)
- [5.2 C1 的 IR 处理过程](#5.2 C1 的 IR 处理过程)
- [5.3 C2 的 IR 处理过程](#5.3 C2 的 IR 处理过程)
- [六、JIT 编译器如何进行热点代码优化](#六、JIT 编译器如何进行热点代码优化)
-
- [6.1 方法内联(Method Inlining)](#6.1 方法内联(Method Inlining))
- [6.2 类型推测与去虚拟化(Devirtualization)](#6.2 类型推测与去虚拟化(Devirtualization))
- [6.3 逃逸分析(Escape Analysis)](#6.3 逃逸分析(Escape Analysis))
- [6.4 循环优化(Loop Optimization)](#6.4 循环优化(Loop Optimization))
- [6.5 常量传播和死代码消除](#6.5 常量传播和死代码消除)
- [6.6 C2 编译优化需要重点掌握的源码](#6.6 C2 编译优化需要重点掌握的源码)
- [七、JIT 如何将 IR 转换为 CPU 本地机器码](#七、JIT 如何将 IR 转换为 CPU 本地机器码)
-
- [7.1 指令选择(Instruction Selection)](#7.1 指令选择(Instruction Selection))
- [7.2 寄存器分配(Register Allocation)](#7.2 寄存器分配(Register Allocation))
- [7.3 指令调度(Instruction Scheduling)](#7.3 指令调度(Instruction Scheduling))
- [7.4 C1 后端源码分析](#7.4 C1 后端源码分析)
- [7.5 C2 后端源码分析](#7.5 C2 后端源码分析)
- [八、机器码如何安装到 Code Cache](#八、机器码如何安装到 Code Cache)
-
- [8.1 什么是 Code Cache](#8.1 什么是 Code Cache)
- [8.2 nmethod 是什么](#8.2 nmethod 是什么)
- [8.3 源码学习重点](#8.3 源码学习重点)
- [九、JIT 编译后的机器码如何被 CPU 执行](#九、JIT 编译后的机器码如何被 CPU 执行)
-
- [9.1 内联缓存(Inline Cache)](#9.1 内联缓存(Inline Cache))
- [十、为什么 JIT 需要反优化 Deoptimization](#十、为什么 JIT 需要反优化 Deoptimization)
-
- [10.1 反优化的基本过程](#10.1 反优化的基本过程)
- [10.2 源码分析](#10.2 源码分析)
- [十一、通过实验观察 JIT 编译过程](#十一、通过实验观察 JIT 编译过程)
-
- [11.1 编写测试程序](#11.1 编写测试程序)
- [11.2 查看 JIT 编译日志](#11.2 查看 JIT 编译日志)
- [11.3 查看 JIT 编译详细日志](#11.3 查看 JIT 编译详细日志)
- [11.4 查看机器码](#11.4 查看机器码)
- [11.5 进一步观察 Code Cache](#11.5 进一步观察 Code Cache)
- 十二、从系统工程师角度建立完整知识体系
-
- [12.1 JIT 与 JVM 内存管理](#12.1 JIT 与 JVM 内存管理)
- [12.2 JIT 与 CPU 架构](#12.2 JIT 与 CPU 架构)
- [12.3 JIT 与操作系统](#12.3 JIT 与操作系统)
- [十三、OpenJDK 源码学习路线](#十三、OpenJDK 源码学习路线)
- 十四、学习总结与核心知识点
- [JIT 编译完整流程](#JIT 编译完整流程)
-
- [必须掌握的 10 个知识点](#必须掌握的 10 个知识点)
- 最终学习心得
前言
本文旨在记录近期研读Java源码的学习心得与疑难问题。由于个人理解水平有限,文中内容难免存在疏漏,恳请读者不吝指正。
JIT编译器如何将热点字节码编译为本地机器码
一、学习目标:理解 JIT 编译的本质
从系统工程师角度学习 JVM 的 JIT(Just-In-Time)编译器,不能仅仅停留在"JIT 将 Java 字节码转换为机器码"这一概念上,还需要理解整个编译过程背后的运行机制。
重点需要回答以下问题:
- JVM 如何判断一个 Java 方法是热点方法?
- 热点方法如何从解释器执行切换到 JIT 编译?
- JIT 如何读取 Class 文件中的字节码?
- JIT 如何将字节码转换为中间表示(IR)?
- JIT 如何进行方法内联、逃逸分析、循环优化等优化?
- JIT 如何根据 x86-64 或 AArch64 架构生成本地机器码?
- 编译后的机器码如何安装到 Code Cache 并被 CPU 执行?
- 当运行时条件发生变化时,JVM 如何进行反优化(Deoptimization)?
核心认识:JIT 并不是简单地将每条 JVM 字节码翻译成一条 CPU 指令,而是通过运行时性能分析、编译优化、中间表示转换、机器码生成和代码安装,将频繁执行的 Java 代码转换为适合当前 JVM 和 CPU 架构执行的本地机器码。
从系统工程角度,可以将 JIT 理解为一个运行时动态优化编译系统。
二、JIT 编译器在 JVM 执行体系中的位置

Java 程序经过 javac 编译后生成 Class 文件,其中包含 JVM 字节码。
JVM 加载 Class 文件后,执行引擎可以通过两种主要方式执行字节码:
- 解释执行: 解释器逐条读取字节码,并执行相应操作。
- 编译执行: JIT 将热点方法编译为本地机器码,由 CPU 直接执行。
在 HotSpot JVM 中,典型的分层编译体系包括:
| 编译层级 | 编译器 | 主要特点 |
|---|---|---|
| Level 0 | 解释器 | 直接解释执行字节码 |
| Level 1 | C1 | 简单编译,主要关注执行效率 |
| Level 2 | C1 | 带有限制的性能分析 |
| Level 3 | C1 | 带完整性能分析 |
| Level 4 | C2 | 深度优化,生成高性能机器码 |
需要注意:
- C1 是 Client Compiler,强调较快的编译速度。
- C2 是 Server Compiler,强调更充分的运行时优化。
- 不同 JVM 构建、启动参数和编译策略可能导致实际编译层级不同。
- Tiered Compilation(分层编译)是 HotSpot 常见的编译模式。
学习总结 1: 解释器负责快速启动和收集运行时信息,C1 负责较快地生成优化代码,C2 则利用积累的运行时信息进行更深入的优化。
三、JVM 如何识别热点方法
这是 JIT 编译的第一个关键环节。
JVM 不会在程序启动时就把所有 Java 方法编译为本地机器码,而是先通过解释器执行,并持续收集方法运行情况。
HotSpot 主要通过以下计数信息识别热点:
| 计数器 | 作用 | 关注场景 |
|---|---|---|
| Invocation Counter | 方法调用计数 | 方法被调用的频率 |
| Backedge Counter | 循环回边计数 | 循环执行的频率 |
| MethodData(MDO) | 方法运行时分析数据 | 类型分布、分支概率、调用目标等 |
3.1 方法调用计数
例如:
public class JitDemo {
public static int calculate(int value) {
return value * 2 + 1;
}
public static void main(String[] args) {
int result = 0;
for (int i = 0; i < 1000000; i++) {
// calculate() 被反复调用
result += calculate(i);
}
System.out.println(result);
}
}
在程序执行过程中:
- JVM 首先通过解释器执行
calculate()。 - 每次调用方法,相关调用计数信息逐渐累积。
- 当计数达到编译策略设定的条件时,JVM 判断该方法具有较高的编译价值。
- JVM 创建编译任务。
- 编译线程获取任务并执行 JIT 编译。
- 编译完成后,方法可以通过编译后的入口执行。
需要注意:方法调用次数并不是唯一判断条件,编译策略还会考虑编译层级、历史运行信息、编译成本以及其他运行时状态。
3.2 循环回边计数与 OSR
除了热点方法,JVM 还需要识别热点循环。
例如:
java
public static int loopCalculate() {
int result = 0;
for (int i = 0; i < 100000000; i++) {
result += i;
}
return result;
}
即使一个方法只被调用一次,其内部循环也可能执行数千万次。
因此,HotSpot 会统计循环回边(Backedge)的执行情况。
当循环达到编译条件时,JVM 可以使用 OSR(On-Stack Replacement,栈上替换)。

OSR 的核心思想:
- 不必等待整个方法执行结束。
- 在循环运行期间触发编译。
- 编译器针对循环入口(OSR BCI)生成机器码。
- JVM 在适当的执行状态转换点,将解释器栈帧中的局部变量、操作数栈等状态映射到编译代码需要的执行状态。
- 后续循环迭代可以直接执行编译后的代码。
学习总结 2: 热点识别有两个重要维度:方法调用频率和循环执行频率。OSR 使 JVM 能够优化已经运行很久的方法中的热点循环,而不必等到下一次方法调用。
3.3 对应 HotSpot 源码
重点阅读 OpenJDK 源码:
src/hotspot/share/compiler/compilationPolicy.hppsrc/hotspot/share/compiler/compilationPolicy.cppsrc/hotspot/share/compiler/compileBroker.cpp
其中,compilationPolicy.hpp 对分层编译级别有明确说明。
OpenJDK 中的相关定义可以概括为:
// HotSpot 分层编译级别说明(对应 compilationPolicy.hpp)
// Level 0:解释器执行
// Level 1:C1 简单编译
// Level 2:C1 编译,带调用和循环回边计数信息
// Level 3:C1 编译,带完整运行时性能分析信息
// Level 4:C2 编译,使用完整 Profile 信息进行优化
这里需要特别理解 Level 3 和 Level 4 的关系:
- Level 3 的代码可以持续收集运行时 Profile。
- Level 4 的 C2 编译器可以利用这些 Profile 进行优化。
- 编译器并不需要在每次方法调用时重新分析全部信息,而是利用已经积累的 MethodData。
源码依据:OpenJDK 的 compilationPolicy.hpp 对编译层级、MethodData 和计数器通知机制有明确说明。
GitHub+1
四、JIT 如何启动编译任务
当 JVM 判断一个方法需要编译后,并不会直接在解释器执行线程中完成全部编译工作。
HotSpot 通过 CompileBroker 统一管理编译任务。
典型流程如下:

4.1 CompileBroker 的职责
CompileBroker 是 HotSpot 编译系统的核心调度组件,主要负责:
- 接收编译请求。
- 创建 CompileTask。
- 根据编译级别选择 C1 或 C2 编译器。
- 将任务放入相应编译队列。
- 唤醒 CompilerThread。
- 管理编译任务的完成状态和编译结果。
4.2 源码分析:CompileTask
OpenJDK 源码位置:
src/hotspot/share/compiler/compileBroker.hpp
核心数据结构如下(省略非关键字段):
cpp
// CompileTask:代表一个待处理或正在处理的编译任务
class CompileTask : public CHeapObj<mtCompiler> {
private:
uint _compile_id;
Method* _method;
int _osr_bci;
int _comp_level;
bool _is_complete;
bool _is_success;
public:
Method* method() const {
return _method;
}
int osr_bci() const {
return _osr_bci;
}
int comp_level() {
return _comp_level;
}
bool is_complete() const {
return _is_complete;
}
};
重点理解:
_method:需要编译的 Java 方法。_osr_bci:OSR 编译对应的字节码位置,普通编译使用特殊入口标识。_comp_level:编译层级。_is_complete:编译任务是否完成。_is_success:编译是否成功。
其中 OSR BCI 非常重要:普通编译通常针对方法入口,而 OSR 编译针对方法内部特定的循环位置。
4.3 编译线程如何执行任务
源码位置:
src/hotspot/share/compiler/compileBroker.cpp
其关键执行逻辑可以概括为:
cpp
// CompileBroker::invoke_compiler_on_method()
// 以下为执行流程的简化说明,不是完整原始函数。
void invoke_compiler_on_method(CompileTask* task) {
// 1. 取得当前编译任务对应的方法
Method* method = task->method();
// 2. 创建编译器接口环境
// ciEnv 为编译器提供 JVM 元数据访问能力
// 3. 根据编译层级获取 C1 或 C2 编译器
AbstractCompiler* compiler =
CompileBroker::compiler(task->comp_level());
// 4. 调用具体编译器
// 将 Java 方法及 OSR 入口信息交给编译器
compiler->compile_method(
/* ciEnv */,
/* ciMethod */,
task->osr_bci()
);
// 5. 编译结果由编译任务及编译环境管理
}
这里需要注意:实际 HotSpot 实现还涉及线程状态切换、异常处理、编译结果检查、任务完成通知等机制,上述代码是为了理解调用关系而简化的。
学习总结 3: CompileBroker 将 Java 方法执行与 JIT 编译解耦。Java 线程负责运行程序和触发编译请求,CompilerThread 在后台执行编译工作,从而减少编译过程对业务线程的直接影响。
源码参考:compileBroker.hpp、compileBroker.cpp。
GitHub+2
五、JIT 如何将 JVM 字节码转换为中间表示 IR
这是整个 JIT 编译过程最核心的技术环节。
JIT 并不是简单地读取一条字节码,就生成一条本地机器指令。
它通常需要经历:
- 读取字节码。
- 建立控制流图(CFG)。
- 建立中间表示(IR)。
- 进行数据流分析。
- 执行编译优化。
5.1 为什么需要中间表示 IR
假设 Java 程序如下:
java
public static int add(int a, int b) {
int result = a + b;
return result;
}
对应的 JVM 字节码可以使用以下形式表示:
iload_0 // 将第一个参数压入操作数栈
iload_1 // 将第二个参数压入操作数栈
iadd // 执行整数加法
ireturn // 返回计算结果
解释器执行时,主要按照 JVM 操作数栈模型处理这些指令。
但 JIT 编译器希望生成的机器码尽量减少不必要的内存访问。
例如,可以直接使用 CPU 寄存器:
; 伪汇编,仅用于说明寄存器运算
add eax, edx
ret
这里的关键变化是:
- JVM 字节码采用操作数栈模型。
- JIT 中间表示可以采用显式数据依赖关系。
- 后端根据目标 CPU 选择寄存器和机器指令。
所以,IR 是连接 JVM 字节码和 CPU 指令的重要桥梁。
5.2 C1 的 IR 处理过程
C1 编译器主要使用高级 IR 和低级 IR。
典型过程:
JVM Bytecode
|
v
Bytecode Parser
|
v
HIR(High-level IR)
|
v
HIR Optimization
|
v
LIR(Low-level IR)
|
v
LIR Assembler
|
v
Native Machine Code
HIR 主要保留较高层次的程序语义,便于执行控制流和数据流优化。
LIR 则更加接近机器指令,方便寄存器分配和指令生成。
5.3 C2 的 IR 处理过程
C2 使用 Sea-of-Nodes 风格的 Ideal Graph 作为核心中间表示。

C2 的重要特点是将程序表示为节点之间的控制依赖和数据依赖关系。
例如:
public static int calculate(int a, int b) {
int x = a + b;
int y = x * 2;
return y;
}
可以抽象成:
Start
|
v
Load a
|
v
Load b
|
v
AddNode
|
v
MulNode(2)
|
v
Return
这里是用于理解的简化数据依赖图,并非 C2 实际 Ideal Graph 的完整节点结构。
C2 可以利用图结构分析:
- 哪些计算可以合并?
- 哪些变量可以消除?
- 哪些表达式可以常量折叠?
- 哪些对象不需要真正分配?
- 哪些方法调用可以内联?
- 哪些控制流分支可以简化?
学习总结 4: IR 解决了 JVM 字节码与 CPU 指令之间的抽象差异。编译器先理解程序的语义和依赖关系,再决定如何生成机器指令,而不是进行简单的逐条翻译。
六、JIT 编译器如何进行热点代码优化
JIT 相比普通静态编译器的一个重要优势是:它能够利用程序运行期间收集到的信息进行优化。
这也是 HotSpot JIT 能够生成高性能机器码的重要原因。
6.1 方法内联(Method Inlining)
方法内联是 JIT 最重要的优化技术之一。
例如:
public class InlineDemo {
public static int add(int a, int b) {
return a + b;
}
public static int calculate(int x) {
// 频繁调用 add()
return add(x, 10);
}
}
如果 add() 方法足够简单,并且满足编译器的内联条件,JIT 可以将其直接展开。
原始代码:
calculate(x)
|
+---- 调用 add(x, 10)
|
+---- return x + 10
内联之后:
public static int calculate(int x) {
// 编译器将 add() 的计算逻辑展开
return x + 10;
}
内联带来的收益:
- 减少方法调用和返回的运行时开销。
- 让编译器看到更大的优化范围。
- 为常量传播、死代码消除等优化创造条件。
- 有机会消除部分参数传递和返回值处理开销。
需要注意,方法内联并不是无限制展开。编译器会考虑方法大小、调用频率、内联深度、代码膨胀等因素。
6.2 类型推测与去虚拟化(Devirtualization)
Java 的多态调用可能需要根据对象的实际类型确定调用目标。
例如:
interface Calculator {
int calculate(int value);
}
class AddCalculator implements Calculator {
public int calculate(int value) {
return value + 10;
}
}
public class DispatchDemo {
public static int execute(
Calculator calculator,
int value) {
return calculator.calculate(value);
}
}
正常情况下,calculator.calculate() 属于接口调用。
JVM 可以通过 MethodData 收集调用点的类型分布。
假设运行期间观察到:
AddCalculator:99.9%
其他类型:0.1%
C2 可能生成类似下面的优化逻辑:
检查 calculator 的实际类型
|
v
是否为 AddCalculator?
/ \
是 否
| |
内联执行 慢路径
| |
+------> 继续执行
这称为基于类型推测的优化。
其优势是将原本动态的虚方法调用转换为带类型检查的快速路径,甚至直接内联调用目标。
但这不是无条件成立的。JIT 必须保留正确的慢路径或依赖失效机制,保证其他类型出现时程序仍符合 Java 语义。
6.3 逃逸分析(Escape Analysis)
逃逸分析用于判断对象是否会逃逸当前方法或线程。
例如:
public static int calculate() {
Point point = new Point(10, 20);
return point.x + point.y;
}
如果 JIT 确认 point 不会逃逸,并且能够安全地消除其对象分配,那么可能进行标量替换。
原始代码:
Point point = new Point(10, 20);
return point.x + point.y;
优化后的逻辑可以近似理解为:
int x = 10;
int y = 20;
return x + y;
这样可能消除:
- 堆对象分配。
- 对象字段访问。
- 与该对象相关的部分 GC 压力。
注意:逃逸分析本身并不等于对象一定会被分配到栈上。HotSpot 常见的优化是标量替换,即消除对象分配并将字段拆解为独立值。
6.4 循环优化(Loop Optimization)
循环通常是 CPU 时间消耗较大的地方。
例如:
public static int sum(int[] values) {
int result = 0;
for (int i = 0; i < values.length; i++) {
result += values[i];
}
return result;
}
JIT 可能执行以下优化:
| 优化技术 | 主要目的 |
|---|---|
| 循环不变式外提 | 将循环中不变的计算移到循环外 |
| 范围检查消除 | 在能够证明安全时减少数组越界检查 |
| 循环展开 | 减少循环控制和分支开销 |
| 强度削弱 | 使用更低成本的运算代替昂贵运算 |
| 向量化 | 在满足条件时利用 SIMD 指令 |
例如,数组循环中可能涉及每次访问的边界检查。
如果编译器能够证明索引始终在合法范围内,就可能消除部分冗余检查。
但要注意,具体优化能否发生取决于循环结构、数据依赖、别名分析结果以及目标架构。
6.5 常量传播和死代码消除
例如:
public static int calculate() {
int a = 10;
int b = 20;
int c = a + b;
int d = c * 2;
return d;
}
编译器可以进行:
a = 10
b = 20
c = 30
d = 60
进一步生成等价于:
public static int calculate() {
return 60;
}
这些优化包括:
- 常量折叠(Constant Folding)。
- 常量传播(Constant Propagation)。
- 死代码消除(Dead Code Elimination)。
- 公共子表达式消除(Common Subexpression Elimination)。
6.6 C2 编译优化需要重点掌握的源码
建议重点阅读以下 OpenJDK 文件:
| 源码文件 | 学习重点 |
|---|---|
src/hotspot/share/opto/compile.cpp |
C2 编译主流程 |
src/hotspot/share/opto/parse1.cpp |
字节码解析及初始图构建 |
src/hotspot/share/opto/parse2.cpp |
控制流和字节码解析 |
src/hotspot/share/opto/loopnode.cpp |
循环优化 |
src/hotspot/share/opto/escape.cpp |
逃逸分析 |
src/hotspot/share/opto/phaseX.cpp |
图优化相关阶段 |
src/hotspot/share/opto/callGenerator.cpp |
调用生成及内联相关逻辑 |
建议重点建立这样的认识:
Java 字节码
|
v
C2 Parser
|
v
Ideal Graph
|
v
类型分析与数据流分析
|
v
方法内联
|
v
循环优化、逃逸分析等
|
v
Ideal Graph 优化
学习总结 5: JIT 的性能优势不只是来自机器码执行速度,更来自运行时 Profile 驱动的推测性优化。方法内联、去虚拟化、逃逸分析和循环优化共同决定了热点代码最终的执行效率。
七、JIT 如何将 IR 转换为 CPU 本地机器码
当 IR 优化完成后,就进入编译后端。
这一阶段的任务是:将平台无关或相对平台无关的中间表示,转换为目标 CPU 能够执行的机器指令。

7.1 指令选择(Instruction Selection)
编译器需要根据 IR 节点选择适合目标 CPU 的指令。
例如:
int result = a + b;
对于 x86-64,可能生成:
add eax, edx
对于 AArch64,可能生成:
add w0, w1, w2
这里展示的是示意性汇编,实际寄存器分配及指令形式由参数传递约定、数据类型、优化结果和上下文决定。
7.2 寄存器分配(Register Allocation)
CPU 寄存器的访问速度通常比内存快。
因此,JIT 需要决定:
- 哪些变量放入 CPU 寄存器?
- 哪些中间结果可以复用寄存器?
- 哪些变量需要暂时保存到栈中?
- 哪些寄存器必须在方法调用时保存?
如果寄存器不足,就可能发生 Spill(溢出),即将部分变量暂时保存到栈内存。
寄存器分配质量会直接影响生成机器码的性能。
7.3 指令调度(Instruction Scheduling)
编译器还需要考虑指令之间的依赖关系。
例如:
指令 A:读取数据
指令 B:执行加法
指令 C:执行乘法
如果 B 和 C 之间没有数据依赖,编译器可能调整其顺序,以便更好地利用 CPU 执行资源。
但指令调度不能违反 Java 程序语义、内存模型以及目标处理器的约束。
特别是涉及 volatile、锁和内存屏障时,必须保证对应的内存排序语义。
7.4 C1 后端源码分析
OpenJDK 源码:
src/hotspot/share/c1/c1_LIRGenerator.cpp
以及:
src/hotspot/share/c1/c1_LIRAssembler.cppsrc/hotspot/cpu/x86/c1_LIRAssembler_x86.cppsrc/hotspot/cpu/aarch64/c1_LIRAssembler_aarch64.cpp
C1 的典型编译后端流程:
HIR
|
v
LIRGenerator
|
v
LIR
|
v
LIRAssembler
|
v
目标 CPU 汇编指令
|
v
机器码
例如,LIRGenerator 将高级 IR 转换为 LIR 指令,而 LIRAssembler 根据目标架构将 LIR 转换为机器码。
OpenJDK 的 c1_LIRGenerator.cpp 可以作为理解这一流程的重要源码入口。
GitHub
7.5 C2 后端源码分析
C2 后端重点阅读:
src/hotspot/share/opto/
compile.cpp
matcher.cpp
output.cpp
src/hotspot/cpu/x86/
x86.ad
src/hotspot/cpu/aarch64/
aarch64.ad
需要理解几个核心概念:
| 概念 | 作用 |
|---|---|
| Ideal Graph | 优化后的中间表示 |
| Matcher | 将 Ideal 节点匹配为机器指令模式 |
| AD 文件 | 描述目标架构的指令模式和生成规则 |
| Register Allocator | 分配 CPU 寄存器 |
| Code Buffer | 保存最终生成的机器码及相关代码数据 |
C2 使用目标架构相关的指令描述规则完成指令匹配和生成。
例如:
Ideal Graph
|
v
Matcher
|
v
x86-64 / AArch64 指令匹配
|
v
寄存器分配与机器码生成
|
v
机器码及相关元数据
学习总结 6: JIT 编译后端是一个与硬件架构紧密相关的模块。同一份 Java 字节码,在 x86-64 和 AArch64 上可能生成完全不同的机器指令,但必须保持相同的 Java 语义。
八、机器码如何安装到 Code Cache
JIT 生成机器码之后,还不能简单地认为编译过程已经结束。
编译结果需要经过管理、分配、安装和发布,才能成为 JVM 可执行的编译代码。
8.1 什么是 Code Cache
Code Cache 是 HotSpot JVM 用来存放本地代码的专用内存区域。
其中包含:
- JIT 编译产生的 nmethod。
- JVM 内部使用的 Stub。
- 其他运行时本地代码。

在启用分层编译的典型 HotSpot 配置中,Code Cache 可以划分为不同区域:
| 区域 | 主要用途 |
|---|---|
| Non-profiled Code Heap | 非性能分析型编译代码 |
| Profiled Code Heap | 带性能分析信息的编译代码 |
| Non-nmethod Code Heap | JVM 内部 Stub 等非 nmethod 代码 |
具体布局取决于 JVM 版本和配置。
8.2 nmethod 是什么
nmethod 是 HotSpot 中表示已编译 Java 方法的核心数据结构。
它不只是机器码,还包含执行和运行时管理所需的信息。
例如:
- 机器码入口地址。
- 机器码范围。
- 异常处理信息。
- GC 所需的 OopMap。
- 调试信息。
- 内联方法对应的作用域信息。
- 反优化所需的相关元数据。
这意味着,JIT 编译结果不仅需要让 CPU 能执行,还需要让 JVM 能够继续管理 Java 程序的运行状态。
8.3 源码学习重点
建议阅读:
src/hotspot/share/code/nmethod.hpp
src/hotspot/share/code/nmethod.cpp
src/hotspot/share/code/codeCache.hpp
src/hotspot/share/code/codeCache.cpp
src/hotspot/share/compiler/compileBroker.cpp
理解以下关系:
C1 / C2 编译器
|
v
生成机器码和编译元数据
|
v
构建 nmethod
|
v
Code Cache 分配空间
|
v
完成安装和发布
|
v
更新方法入口及相关调用信息
|
v
后续执行进入编译代码
这里需要注意:方法入口切换不是简单地将 Java 方法对象替换成一段机器码。HotSpot 还需要处理入口状态、调用链接、编译层级、代码有效性以及并发执行等问题。
学习总结 7: Code Cache 是 JIT 生成代码的运行时存储基础设施,nmethod 则是 JVM 对编译后方法的管理载体。机器码、GC 信息、调试信息和反优化信息必须协同工作。
九、JIT 编译后的机器码如何被 CPU 执行
机器码安装完成后,JVM 需要保证后续方法调用能够进入编译后的代码。
其基本过程如下:
Java 方法调用
|
v
JVM 方法入口
|
v
检查当前方法的执行状态
|
v
选择解释器入口或编译代码入口
|
v
进入 nmethod
|
v
CPU 执行本地机器指令
|
v
返回执行结果
这里需要区分两个概念:
第一,解释器入口和编译代码入口。
方法可能存在解释执行入口、编译执行入口等不同入口形式。JVM 通过方法入口及相关调用机制,决定如何进入对应执行路径。
第二,Java 方法调用不一定直接跳转到 nmethod。
在 HotSpot 中,方法调用还可能经过调用 Stub、方法分派、内联缓存(Inline Cache)等机制。
如果一个方法已经被内联到调用者的机器码中,那么执行时甚至不需要发生独立的方法调用。
9.1 内联缓存(Inline Cache)
假设程序存在:
interface Service {
void execute();
}
如果某个调用点长期只调用一个实现类,JIT 可以针对这个调用点进行类型推测和内联优化。
后续执行时:
- 如果类型符合预测,走快速路径。
- 如果类型不符合预测,走其他调用路径。
- 如果之前的优化假设失效,可能触发反优化。
这也是为什么 JVM 的机器码并不是完全静态不变的:它会随着运行时信息变化而发生更新、失效和重新编译。
十、为什么 JIT 需要反优化 Deoptimization
这是理解 JIT 的另一个重要知识点。
JIT 为了提高性能,会利用运行时信息做出一些推测。
例如:
public static int calculate(Service service) {
service.execute();
return 100;
}
假设 JVM 观察到:
ServiceA:99.9%
ServiceB:0.1%
C2 可能假设当前调用点主要执行 ServiceA,并将 ServiceA 的方法内联。
但如果后续突然出现 ServiceB,之前的优化假设可能不再成立。
JVM 必须保证程序仍然按照 Java 语义正确执行。
这时可能触发 Deoptimization。

10.1 反优化的基本过程
执行 JIT 编译后的机器码
|
v
运行时检查优化假设
|
v
发现假设不成立
|
v
触发 Uncommon Trap
|
v
进入 Deoptimization
|
v
恢复 Java 执行状态
|
v
重建解释器栈帧
|
v
继续解释执行
|
v
必要时重新编译
这里最重要的是恢复执行状态。
JIT 编译时可能已经:
- 内联多个 Java 方法。
- 消除部分对象分配。
- 将局部变量放入 CPU 寄存器。
- 消除部分锁操作。
- 改变原始字节码对应的执行结构。
因此,反优化需要根据编译过程中记录的调试信息,恢复 Java 虚拟机栈帧、局部变量和操作数栈等执行状态。
10.2 源码分析
重点阅读:
src/hotspot/share/runtime/deoptimization.cpp
相关核心逻辑:
// Deoptimization::fetch_unroll_info_helper()
// 以下是根据源码逻辑整理的学习说明,不是完整原始函数。
// 1. 获取发生反优化的编译栈帧
frame deoptee = stub_frame.sender(&map);
// 2. 获取对应的 nmethod
nmethod* nm = deoptee.cb()->as_nmethod_or_null();
// 3. 根据编译时保存的 ScopeDesc 等信息
// 识别内联调用对应的 Java 虚拟栈帧
// 4. 创建 compiledVFrame 等结构
// 描述原始 Java 方法的执行状态
// 5. 创建 vframeArray
// 为恢复解释器栈帧准备数据
// 6. unpack_frames()
// 将恢复后的解释器栈帧重新放回线程栈
特别需要关注源码中的:
ScopeDesccompiledVFramevframeArrayUnrollBlockunpack_frames()
这些结构和过程共同完成从编译执行状态到解释执行状态的转换。
例如,HotSpot 在反优化过程中可以根据编译时保存的作用域信息,重新构建被内联的 Java 方法对应的逻辑栈帧。
源码参考:deoptimization.cpp 和 deoptimization.hpp。
GitHub+1
学习总结 8: 反优化是 JIT 推测性优化的重要保障。JIT 可以大胆利用运行时信息优化机器码,但必须保留能够恢复 Java 程序语义的机制。
十一、通过实验观察 JIT 编译过程
对于系统工程师而言,学习 JIT 不能只阅读源码,还应该通过 JVM 参数、编译日志和汇编输出观察实际执行过程。
11.1 编写测试程序
public class JitTest {
public static int calculate(int a, int b) {
return a * 2 + b;
}
public static void main(String[] args) {
long result = 0;
for (int i = 0; i < 10000000; i++) {
result += calculate(i, 10);
}
System.out.println(result);
}
}
编译:
javac JitTest.java
执行:
java JitTest
11.2 查看 JIT 编译日志
java -XX:+PrintCompilation JitTest
可以观察到类似信息:
45 1 3 JitTest::calculate
46 2 3 JitTest::main
52 3 4 JitTest::calculate
这只是示意输出,实际编译时间、编译 ID、层级和方法可能不同。
重点理解:
| 输出信息 | 含义 |
|---|---|
| 时间 | JVM 启动后的编译时间信息 |
| 编译 ID | 编译任务编号 |
| 编译级别 | 例如 3 表示 C1 完整 Profile 层级,4 表示 C2 |
| 方法名 | 被编译的 Java 方法 |
如果同一个方法先出现 Level 3,之后出现 Level 4,通常说明它经历了分层编译。
11.3 查看 JIT 编译详细日志
JDK 9 及之后版本可以使用统一日志:
java -Xlog:jit+compilation=debug JitTest
观察:
- 哪些方法触发编译。
- 哪些方法被重新编译。
- 编译级别发生了什么变化。
- 编译任务何时完成。
11.4 查看机器码
在具备 hsdis 等反汇编支持的 HotSpot 环境中,可以尝试:
java \
-XX:+UnlockDiagnosticVMOptions \
-XX:+PrintAssembly \
JitTest
也可以使用 JMH 配合 -prof perfasm 分析热点代码的汇编执行情况。
重点观察:
- Java 方法是否已经被 JIT 编译。
- 是否发生方法内联。
- 是否存在数组边界检查。
- 是否出现条件跳转。
- 是否存在寄存器溢出。
- 是否使用 SIMD 指令。
注意:PrintAssembly 需要相应的反汇编支持,而且不同 JDK 构建可能存在差异。
11.5 进一步观察 Code Cache
可以使用:
jcmd <pid> Compiler.codecache
查看 JVM Code Cache 的使用情况。
也可以结合:
jcmd <pid> Compiler.queue
观察编译队列。
通过这些工具,可以从运行时角度理解编译任务、编译结果以及代码缓存之间的关系。
十二、从系统工程师角度建立完整知识体系
前面分析的是 JIT 的内部执行过程。进一步学习时,应该将其与 JVM、操作系统和 CPU 结合起来。
12.1 JIT 与 JVM 内存管理
JIT 不仅生成机器码,还需要维护与 GC 相关的元数据。
例如:
- OopMap:描述特定机器码位置上的对象引用。
- Safepoint:支持线程在安全位置暂停。
- nmethod:维护编译方法及其运行时信息。
- Code Cache:管理编译代码的内存生命周期。
特别是 OopMap,它对于 GC 扫描编译代码执行时的线程栈和寄存器非常重要。
因此,JIT 生成的机器码必须能够与 JVM 的垃圾回收机制协同工作。
12.2 JIT 与 CPU 架构
同样的 Java 字节码,在不同 CPU 架构上生成的机器码可能完全不同。
| 比较维度 | x86-64 | AArch64 |
|---|---|---|
| 指令集 | CISC 风格 | RISC 风格 |
| 通用寄存器 | 16 个主要通用寄存器 | 31 个通用寄存器 |
| 内存排序 | 相对较强 | 相对较弱 |
| 内存屏障 | 根据语义选择相应指令 | 根据语义选择相应指令 |
| SIMD | SSE、AVX 等 | NEON、SVE 等 |
对于系统工程师,尤其需要理解:
- CPU 指令选择如何影响性能。
- 寄存器分配如何影响内存访问。
- 内存屏障如何实现 JMM 语义。
- CPU 缓存和内存访问延迟如何影响热点代码。
- 不同架构的指令吞吐量和执行延迟如何影响优化效果。
12.3 JIT 与操作系统
JIT 运行还涉及操作系统底层能力:
- 可执行内存的申请与管理。
- 内存页权限控制。
- 代码缓存映射。
- 线程调度和编译线程并发。
- CPU 缓存及指令缓存同步。
- JIT 代码生成期间的内存管理。
尤其值得关注的是 W^X(Write XOR Execute)安全机制:代码生成阶段需要写入机器码,执行阶段则需要具备执行权限。具体内存权限切换和代码发布策略与操作系统、CPU 架构及 HotSpot 实现有关。
十三、OpenJDK 源码学习路线
建议基于同一个 OpenJDK 版本阅读源码,避免将 JDK 8u、JDK 17、JDK 21 和当前主线的实现细节混在一起。
以下以当前 OpenJDK 主线的 src/hotspot 目录组织为学习参考。
| 学习阶段 | 源码路径 | 核心学习内容 |
|---|---|---|
| 1. 热点识别 | compiler/compilationPolicy.cpp |
热点判断和编译策略 |
| 2. 编译调度 | compiler/compileBroker.cpp |
编译请求及任务调度 |
| 3. 编译接口 | ci/ciEnv.cpp、ci/ciMethod.cpp |
JVM 元数据访问 |
| 4. C1 编译 | c1/c1_Compilation.cpp |
C1 编译流程 |
| 5. C1 后端 | c1/c1_LIRGenerator.cpp |
HIR 到 LIR |
| 6. C2 编译 | opto/compile.cpp |
C2 编译主流程 |
| 7. C2 解析 | opto/parse1.cpp、opto/parse2.cpp |
字节码与图构建 |
| 8. 优化 | opto/escape.cpp、opto/loopnode.cpp |
逃逸分析、循环优化 |
| 9. 指令生成 | opto/matcher.cpp、opto/output.cpp |
指令匹配及机器码生成 |
| 10. 代码安装 | code/nmethod.cpp、code/codeCache.cpp |
nmethod 和 Code Cache |
| 11. 反优化 | runtime/deoptimization.cpp |
恢复解释执行状态 |
源码可以从 OpenJDK 官方 GitHub 仓库 获取。
建议按以下顺序阅读:
compilationPolicy
|
v
compileBroker
|
v
c1_Compilation / C2Compiler
|
v
ciEnv / ciMethod
|
v
C1 HIR / C2 Ideal Graph
|
v
优化阶段
|
v
LIR / Matcher
|
v
机器码生成
|
v
nmethod / CodeCache
|
v
Deoptimization
十四、学习总结与核心知识点
JIT 编译完整流程
-
热点识别
Invocation Counter、Backedge Counter、MethodData。
-
编译请求
Compilation Policy 判断编译条件,CompileBroker 调度任务。
-
字节码解析
编译器读取字节码、方法元数据及运行时 Profile。
-
构建中间表示
C1 构建 HIR/LIR,C2 构建 Ideal Graph。
-
编译优化
方法内联、常量传播、逃逸分析、循环优化、去虚拟化。
-
机器码生成
指令选择、寄存器分配、指令调度、目标架构代码生成。
-
代码安装
生成 nmethod,安装到 Code Cache。
-
本地执行与反优化
CPU 执行机器码;优化假设失效时恢复 Java 执行状态。
必须掌握的 10 个知识点
| 序号 | 知识点 | 掌握要求 |
|---|---|---|
| 1 | 热点方法识别 | 理解方法计数和循环回边计数 |
| 2 | 分层编译 | 理解 C1、C2 和 Level 0~4 |
| 3 | OSR | 理解运行中循环如何切换到编译代码 |
| 4 | CompileBroker | 理解编译任务调度 |
| 5 | IR | 理解 HIR、LIR 和 Ideal Graph |
| 6 | 方法内联 | 理解调用展开及其优化价值 |
| 7 | 逃逸分析 | 理解标量替换和锁消除 |
| 8 | 机器码生成 | 理解指令选择和寄存器分配 |
| 9 | Code Cache | 理解 nmethod 的安装与生命周期 |
| 10 | Deoptimization | 理解反优化及解释器栈帧恢复 |
最终学习心得
从系统工程师角度看,JIT 编译器实际上是 JVM 中连接 Java 字节码、运行时性能分析、编译优化和 CPU 硬件执行的重要桥梁。
它通过热点识别发现高频执行代码,利用运行时 Profile 获取类型和调用信息,再通过 C1、C2 编译器构建中间表示并实施优化,最终生成适合目标 CPU 架构的本地机器码。
更重要的是,JIT 并不是一次性完成代码转换的静态过程,而是一个持续运行、动态优化和能够反优化的系统。
真正需要掌握的是三个层次:
- JVM 层: 热点识别、分层编译、编译调度、Code Cache 和反优化。
- 编译器层: 字节码解析、IR 构建、数据流分析、方法内联和机器码生成。
- 硬件层: CPU 指令集、寄存器分配、缓存、内存屏障和指令执行。
只有将这三个层次结合起来,才能真正理解 Java 程序为什么能够通过 JIT 编译获得接近本地代码的执行效率,以及为什么同一个 Java 程序在不同 JVM 和 CPU 架构上可能产生不同的性能表现。
建议下一阶段学习:JIT 编译优化与 CPU 执行机制的结合,重点研究 C2 Ideal Graph、Matcher、寄存器分配、CPU 指令生成,以及 JMM 内存语义如何落实到最终机器指令。