JIT 编译
服务启动初期可能仍在解释执行或较低编译层级,延迟和吞吐与预热完成后不同。性能测试若不区分解释、C1、C2 和去优化阶段,容易把运行时状态误判为业务代码问题。预热所需时长没有固定次数,应通过编译日志和目标工作负载测量。
1. 为什么需要 JIT
| 模式 | 启动与预热成本 | 稳态执行性能 | 适用场景 |
|---|---|---|---|
| 解释执行 | 通常较低 | 通常低于充分优化后的机器码 | 小程序、脚本 |
| AOT(提前编译) | 通常较低 | 取决于编译器优化及程序特征 | 启动敏感场景(GraalVM Native Image) |
| JIT(即时编译) | 存在预热与编译成本 | 在适合动态优化的长期运行负载中通常较高 | 长期运行的服务端 |
Java 的答案是混合模式:先用解释器快速启动,热点代码交给 JIT 编译优化。
这就是为什么 Java 服务启动后需要"预热"——刚开始是解释执行,性能较差;运行一段时间后,热点代码被 JIT 编译成机器码,性能大幅提升。
1.1 解释 vs 编译的性能差距
// 一个简单的方法调用循环
for (int i = 0; i < 100_000_000; i++) {
sum += compute(i);
}| 执行方式 | 耗时(相对值) | 原因 |
|---|---|---|
| 纯解释执行 | 显著较慢 | 执行字节码指令 |
| C1 编译 | 中间阶段 | 机器码,较保守优化 |
| C2 编译 | 通常为高性能阶段 | 机器码,激进优化(内联、逃逸分析、向量化) |
这些差距只能通过目标 JDK、目标 CPU 和实际负载测量,不能用固定倍数跨场景比较。
2. HotSpot 编译体系
解释执行(Interpreter)
↓ 热点探测
C1 编译(Client Compiler)—— 快速编译,保守优化
↓ 持续变热
C2 编译(Server Compiler)—— 深度编译,激进优化这是编译体系的概念示意,省略了分层编译的中间层级,完整的层级转换见 2.1 节。
2.1 分层编译(Tiered Compilation)
本节描述同时包含 C1 和 C2 的典型 OpenJDK HotSpot 构建。此类构建通常默认启用 -XX:+TieredCompilation,将编译分为 5 个层级;只包含 C2 的构建、非 HotSpot JVM 或启用了 -Xint 时不适用。目标 JVM 的实际默认值应以 java -XX:+PrintFlagsFinal -version 的输出为准。
| 层级 | 编译方式 | 特点 |
|---|---|---|
| Level 0 | 解释执行 | 收集基本 profiling 数据 |
| Level 1 | C1 编译,不收集 profiling | 编译快,优化保守 |
| Level 2 | C1,有限 profiling | 收集方法调用计数和循环回边计数等有限信息 |
| Level 3 | C1,完整 profiling | 收集完整的类型信息和分支概率 |
| Level 4 | C2,深度优化 | 基于 profiling 数据做激进优化 |
典型情况下,方法从解释执行开始,达到编译条件后进入 C1 完整 profiling(Level 3),再由 C2 编译为 Level 4。这个过程不是固定的两次升级:编译队列繁忙时,JVM 可能先让方法进入 Level 2;简单方法也可能由 C1 编译后就不再升级。层级转换由编译策略综合计数器、profiling 信息和编译器负载决定。
2.2 热点探测
JVM 使用方法调用计数器和回边计数器来判断代码是否"热":
- 方法调用计数器:累计方法调用热度。
- 回边计数器:累计循环回边热度。
HotSpot 根据计数器、执行层级和采样信息决定是否编译;开启分层编译时,各层级的触发条件并不等价于一个全局固定的“10000 次”阈值。
2.3 On-Stack Replacement(OSR)
OSR 解决的是方法已经运行起来、但还没有返回时的编译问题。例如,一个方法只调用一次,却在内部执行了很长的循环。循环执行期间,回边计数可能达到 OSR 编译条件,JVM 可以为该方法生成 OSR 编译版本,并在合适的循环入口处从解释执行转入编译代码,而不必等待整个方法结束。
方法正在解释执行
↓ 循环执行期间,回边计数达到 OSR 编译条件
JVM 生成该方法的 OSR 编译版本
↓ 在合适的循环入口处切换
从解释执行转入编译代码OSR 需要把当前执行状态映射到编译代码所需的状态,因此编译入口和普通方法入口并不相同。OSR 版本的优化程度取决于编译层级、可用的 profiling 信息和具体编译决策,不能简单地认为它的代码质量总是低于普通编译版本。
OSR 的触发依赖回边计数器(循环体执行次数),而非方法调用计数器。这就是为什么一个只调用一次但内部有大量循环的方法也能被 JIT 编译。
2.4 Profiling 收集的信息
| 信息类型 | 用途 | 示例 |
|---|---|---|
| 类型 profiling | 虚方法去虚化 | interface.method() 只有一个实现 → 直接调用 |
| 分支 profiling | 条件预测 | if (x > 0) 99% 为 true → 优先编译 true 分支 |
| 调用 profiling | 方法内联决策 | 被调用方法很小 → 内联 |
| 循环 profiling | 循环展开 | 循环次数为 4 的倍数 → 展开 4 次 |
这些是 profiling 信息可能支持的优化方向,不是信息一旦出现就必然触发对应优化;实际决策由编译策略综合多种信息作出。
3. 方法内联
方法内联将被调用方法的代码直接嵌入调用方,减少方法调用开销,包括栈帧创建与销毁、参数传递和返回值处理。它是后续多项优化的基础。
3.1 内联的效果
// 内联前
public int add(int a, int b) { return a + b; }
public int calculate() { return add(1, 2) + add(3, 4); }
// 内联后(JIT 优化)
public int calculate() { return 1 + 2 + 3 + 4; }
// 进一步优化(常量折叠)
public int calculate() { return 10; }3.2 内联是后续优化的基础
内联不仅消除了方法调用开销,还为后续优化打开了空间:
原始代码:
result = add(1, 2) + multiply(3, 4);
内联后:
result = 1 + 2 + 3 * 4;
常量折叠:
result = 15;
逃逸分析(如果 result 是局部变量):
→ 标量替换,消除 result 对象内联会把调用方和被调用方的中间表示放到一起,使常量折叠和逃逸分析更容易跨调用边界进行。具体是否应用这些优化,仍取决于 JVM 的分析结果和运行时配置。
3.3 内联阈值
下面是 JDK 21 HotSpot C2 常见的默认参考值。它们是内联策略中的限制参数,不是"达到条件就一定内联"的规则,具体默认值应以目标 JDK 构建的实际输出为准:
| 参数 | 常见默认参考值 | 作用 |
|---|---|---|
-XX:MaxInlineSize | 35 字节 | 冷方法可参与内联的字节码大小上限 |
-XX:FreqInlineSize | 325 字节(依平台而异) | 热方法可参与内联的字节码大小上限 |
-XX:MaxInlineLevel | 15 | 限制内联调用链的嵌套深度 |
方法的字节码大小只是一个筛选条件。最终是否内联,还取决于调用点热度、调用目标是否可确定、内联预算和其他编译策略。不要仅凭方法行数或字节码是否低于某个阈值,推断它一定会被内联。
// 小方法:更容易满足内联的大小条件,但不保证一定内联
public int add(int a, int b) { return a + b; }
// 大方法:可能因超过大小限制而不被内联,
// 但不能仅凭源代码行数判断,应检查实际字节码与编译结果
public void process() { /* ... */ }查看当前 JVM 参数默认值:
java -XX:+PrintFlagsFinal -version 2>&1 \
| grep -E 'MaxInlineSize|FreqInlineSize|MaxInlineLevel'3.4 内联与虚方法
虚方法(invokevirtual)的目标在编译期不确定——可能是子类的实现。JIT 通过 profiling 收集的信息做去虚化:
JIT 通过两种手段做去虚化:
1. 基于 Class Hierarchy Analysis(CHA)
如果一个虚方法或接口方法在当前类层次中只有一个实现,JVM 可以在编译时直接内联,不需要等 profiling:
// 类层次分析:当前只有 FinalClass 实现了 Interface
// Interface.method() → 直接内联 FinalClass.method()
// 因为没有其他实现,编译时就能确定目标
// 如果后来加载了新实现 → 去优化如果接收者的实际类型可以确定,JIT 就能把虚调用改写为直接调用,并可能继续内联目标方法。final 类和 final 实例方法能提供更强的类型约束,因为类不能被继承,方法不能被重写。对于依赖当前类层次作出的优化,JVM 仍会维护必要的依赖关系;如果优化依赖的假设失效,编译代码可能需要去优化。这是为什么将不会被继承的方法标记为 final 能帮助 JIT 优化。
2. 基于 Profiling 的去虚化
对于有多个实现的方法,JIT 通过 profiling 发现某个实现占绝对多数(如 99%),就可以做"推测性去虚化":
interface Parser {
String parse(String input);
}
// 运行时 99% 的调用是 JsonParser
Parser parser = getParser();
parser.parse(data); // invokeinterface
// JIT 发现 99% 是 JsonParser → 内联 JsonParser.parse()
// 在内联代码前插入类型检查(Guard)
// 如果遇到其他实现 → 去优化4. 逃逸分析与相关优化
逃逸分析判断对象或同步操作是否可能被方法外或当前线程外观察。C2 编译器可以利用分析结果消除部分对象分配、消除不必要的锁。
4.1 逃逸分析与标量替换
这里需要先分清一个容易混淆的概念:JDK 21 HotSpot 不会因为对象没有逃逸,就直接把它分配到栈上。逃逸分析是分析步骤,标量替换才是消除对象分配的优化手段。
如果对象不需要保留完整的对象身份,而且字段访问方式满足优化条件,C2 可能通过标量替换消除对象分配,将字段值作为独立的标量参与后续计算:
final class Point {
final int x;
final int y;
Point(int x, int y) {
this.x = x;
this.y = y;
}
}
int sum() {
Point p = new Point(1, 2);
return p.x + p.y;
}
// 如果这个方法被内联,且编译器能确定字段值和使用方式,
// 优化后可能只剩常量计算,Point 对象本身不再需要创建。标量替换不是把对象搬到栈上,而是让对象的分配不再发生。没有真实分配的对象自然也不需要被 GC 回收。实际优化结果仍取决于编译上下文和 C2 的分析能力。
4.2 锁消除
StringBuffer 的相关方法使用同步保护内部状态。如果 C2 能内联这些调用,并通过逃逸分析确认该缓冲区不会被其他线程观察到,就可能证明这些锁不会产生实际的线程间互斥需求,从而消除相关同步操作:
// 如果 sb 不逃逸,且相关同步能被编译器分析到
public String concat(String[] parts) {
StringBuffer sb = new StringBuffer(); // 局部变量,不逃逸
for (String part : parts) {
sb.append(part); // 同步操作可能被消除
}
return sb.toString();
}对象不逃逸是重要条件,但具体能否消除仍取决于编译器是否能够分析到相关代码。不能据此认为局部变量上的 synchronized 总会被删除。
4.3 逃逸分析的局限
逃逸分析证明对象没有逃逸,并不等于对象分配一定会被消除。C2 还需要判断对象是否适合标量替换,以及字段访问、控制流和调用关系是否满足当前优化实现的条件。
例如,一个对象在某条分支上被保存到静态字段,即使另一条分支完全在方法内部使用它,保守的分析也可能把它视为逃逸对象。对象身份必须保留、字段访问方式复杂,或关键调用无法内联时,标量替换也可能无法发生。
因此,不能根据"对象是局部变量"就断定分配会消失。-XX:+DoEscapeAnalysis 和 -XX:+EliminateAllocations 是 HotSpot 的相关参数,在 JDK 21 的典型配置中默认开启。要判断某个具体对象是否被消除,应结合编译结果和基准测试,而不是仅凭源码推断。
5. 循环优化
JIT 对循环有多种优化手段:
5.1 循环展开(Loop Unrolling)
减少循环判断次数,增加每次迭代的工作量:
// 原始
for (int i = 0; i < 4; i++) {
sum += arr[i];
}
// 展开 4 次
sum += arr[0];
sum += arr[1];
sum += arr[2];
sum += arr[3];循环展开可以减少循环控制开销,并增加编译器同时处理多次迭代的机会。但展开也会增加生成代码的体积,并可能增加寄存器压力。C2 会根据循环结构、可推导的信息和优化成本作出决策,因此不能仅凭循环次数是常量,就断定它一定会被展开。
5.2 循环不变量外提(Loop-Invariant Code Motion)
将循环内不变的计算移到循环外:
// 原始
for (int i = 0; i < n; i++) {
result += arr[i] * Math.sqrt(base);
}
// 优化后
double scale = Math.sqrt(base); // 循环不变量外提
for (int i = 0; i < n; i++) {
result += arr[i] * scale;
}这是优化后的概念形式。实际编译时是否进行外提,由编译器根据表达式的副作用、循环中的内存访问和其他语义约束决定;不需要仅为触发 JIT 优化而机械地改写源码。
5.3 向量化(SIMD)
JIT 可以将标量运算替换为 SIMD 指令(如 SSE/AVX),一次处理多个数据:
// 原始:逐个相加
for (int i = 0; i < arr.length; i++) {
result[i] = a[i] + b[i];
}
// SIMD 优化:一条指令处理 4 个 int(128 位 SSE)
// 一条指令处理 8 个 int(256 位 AVX)向量化需要满足条件:循环体简单、没有循环依赖等;具体条件取决于编译器实现和目标指令集,数据对齐并不是所有向量化路径都必须满足的前提。
5.4 如何验证是否被向量化
JIT 编译日志可以告诉你是否做了向量化:
# JDK 17 与 HotSpot 的示例;输出字段与日志选项可能随版本变化
java -XX:+UnlockDiagnosticVMOptions \
-XX:+PrintCompilation \
-XX:+LogCompilation \
-XX:LogFile=jit.log \
-jar app.jar
# 在 jit.log 中搜索 vector 或 SuperWord 相关标记更直观的方式是使用 JMH 的 perfasm 集成查看编译后的机器码:
# 在已配置好 perfasm 所需工具的环境中使用
java -jar benchmarks.jar -prof perfasm检查汇编时,不要只搜索某一条向量移动指令。应查看循环核心是否出现与数据类型和目标架构对应的向量算术指令(如整数或浮点向量加法),并确认这些指令确实处理了循环中的数组元素。标量指令可能用于循环边界、剩余元素处理或其他辅助逻辑,并不意味着整个循环没有向量化。判断是否向量化,需要结合循环主体的完整汇编、目标 CPU 支持的指令集,以及 JIT 编译日志来分析。
6. 去优化(Deoptimization)
JVM 有时会"倒退"回解释执行。这是自适应优化的核心机制。
6.1 为什么会去优化
去优化通常与编译代码所依赖的假设或运行时检查有关。常见情形包括:
| 情形 | 说明 |
|---|---|
| 优化假设失效 | 编译器依据当前类层次内联了某个实现,之后类层次发生变化,原先的优化不再成立 |
| 运行时检查失败 | 编译器依据 profiling 结果假设某个调用点主要接收一种类型,运行时遇到不满足该假设的类型 |
并不是所有路径变化都会导致去优化。只有当执行触及需要回退的检查或相关依赖失效时,JVM 才需要恢复到能够正确继续执行的状态。
6.2 去优化的过程
编译代码依据运行时信息作出优化
↓
相关假设失效,或运行时检查触发回退
↓
去优化:恢复到能够继续正确执行的状态
↓
继续执行,并根据需要积累新的运行时信息
↓
如果编译策略认为值得优化,再提交新的编译任务去优化不会改变程序应有的语义。它的代价是恢复执行状态以及可能失去一部分已经生成的优化代码;后续是否重新编译、编译到哪个层级,由 JVM 的编译策略决定。
6.3 示例:接口去虚化失败
interface Parser {
String parse(String input);
}
// 第一阶段:只有 JsonParser 一个实现
Parser parser = getParser(); // JIT 内联 JsonParser.parse()
parser.parse(data);
// 第二阶段:加载了 XmlParser
// 如果该编译代码依赖的假设失效,可能触发去优化;
// 后续是否重新编译取决于运行时策略6.4 去优化不是错误
去优化是 JVM 自适应优化的一部分——它不是错误,而是 JVM 根据运行时信息动态调整策略的机制。
生产环境中的 CodeCache、编译线程 CPU 和去优化抖动,以及编译日志、JITWatch 和 JFR 的观察方法,见 JIT 编译与 CodeCache 观察。
本章解释了热点代码如何随分层编译改变执行性能。下一步进入堆外内存,理解不属于 Java 堆、但会计入进程内存的本地内存区域。