解释器与 JIT 编译器并存原因
为什么 JVM 需要两者共存
这是一个看似简单、实则深刻的问题。如果 JIT 编译器能编译出更快的机器码,为什么不干脆全部用 JIT?反之,如果解释器够用,为什么还要 JIT?
答案在于:它们各自有不同的优势,只有并存才能取长补短。
解释器的优势:即时响应
解释器最大的优势是零启动延迟。
启动阶段:解释执行说了算
程序刚启动时,JIT 编译器还没有收集到任何运行时信息,不知道哪些代码是热点。此时用解释器立即开始执行,不需要等待编译:
public class StartupDemo {
public static void main(String[] args) {
// main 方法开始执行时,JIT 还没有完成编译
// 这段代码最初由解释器执行
System.out.println("Application started in milliseconds");
initialize();
}
}如果全部使用 JIT:
- JVM 需要先编译所有代码
- 编译完成后才能开始执行
- 一个 10MB 的 JAR 可能需要几十秒才能完成编译
- 用户体验极差
解释器的响应方式
时间 →
0ms ────────→ JIT 全部编译 ────────→ 开始执行 ← 全 JIT 方案
0ms ──→ 开始执行 ← 解释器方案
↑
无等待JIT 编译器的优势:峰值性能
JIT 编译器能做出解释器做不到的优化。
运行时信息:JIT 的独门武器
解释器只能执行字节码,不知道任何运行时信息。JIT 编译器在编译时能看到:
- 热点代码:哪些方法被调用了十万次?
- 类型信息:这个字段真的是
String还是Object? - 分支预测:
if条件是 true 多还是 false 多? - 数据流:
x在这个分支里一定是正数吗?
这些运行时信息,让 JIT 能够做出更激进的优化。
内联:最强大的优化
方法内联是 JIT 最重要的优化之一。
public class InliningDemo {
public static void main(String[] args) {
for (int i = 0; i < 1_000_000; i++) {
sum(i); // 被调用 100 万次 → 热点
}
}
static long sum(int n) {
return (long) n * (n + 1) / 2; // JIT 会内联这个方法
}
}内联后:
// JIT 优化后的等价代码(没有方法调用开销)
public static void main(String[] args) {
for (int i = 0; i < 1_000_000; i++) {
// sum 方法被展开,无调用开销
long result = (long) i * (i + 1) / 2;
}
}如果 sum 是一个虚方法(invokevirtual),JIT 编译器还能根据运行时类型信息,去虚化(devirtualization)——即如果每次调用时 n 都是 int,JIT 可以直接内联成 int 的加法和乘法,绕过方法表查找。
逃逸分析优化
JIT 编译器在解释执行阶段收集逃逸信息,然后在内联时执行优化(栈上分配、同步省略、标量替换)。这些在编译阶段完全无法实现。
分层编译:HotSpot 的解决方案
HotSpot 使用分层编译(Tiered Compilation),把解释器和 JIT 编译器的协作分为多个层级:
| 层级 | 名称 | 解释 | 编译速度 | 执行速度 |
|---|---|---|---|---|
| Tier 0 | 解释执行 | 纯解释 | - | 最慢 |
| Tier 1 | C1 编译 | 快速轻量级编译 | 快 | 较快 |
| Tier 2 | C1 编译 + profiling | 带方法调用和分支统计 | 中 | 中 |
| Tier 3 | C1 编译 + 完整 profiling | 完整的运行时统计 | 较慢 | 较快 |
| Tier 4 | C2 编译 | 完全激进优化 | 最慢 | 最快 |
启动阶段 ──→ 预热阶段 ──→ 峰值阶段
│ │ │
│ Tier 0 │ Tier 1/2/3 │ Tier 4
│ (纯解释) │ (C1 编译) │ (C2 编译)
│ 立即开始 │ 快速响应 │ 最强优化
│ │ │
└──────────────┴───────────────┘各层级的触发条件
public class TieredDemo {
public static void main(String[] args) {
for (int i = 0; i < 1_000_000; i++) {
hotMethod(i);
}
}
static void hotMethod(int n) {
// Tier 0: 解释执行
// ↓ 方法调用计数器超过阈值(如 1000 次)
// Tier 1: C1 快速编译
// ↓ 发现是热点 + profiling 数据足够
// Tier 2/3: C1 完整编译
// ↓ 调用次数持续增加(>10000 次)
// Tier 4: C2 激进优化编译
}
}并存的深层原因:互补性
| 维度 | 解释器 | JIT 编译器 |
|---|---|---|
| 启动时间 | 零延迟 | 有编译开销 |
| 峰值性能 | 慢(每次翻译) | 快(编译一次,永久使用) |
| 优化能力 | 弱(无运行时信息) | 强(profile-guided optimization) |
| 内存开销 | 小 | 大(Code Cache 占用) |
| 代码膨胀 | 无 | 有(编译后的机器码可能更大) |
JIT 编译的代价
JIT 编译不是免费的午餐:
编译开销
编译本身需要消耗 CPU 和内存。在编译期间,程序可能出现短暂的停顿(编译停顿)。
# 关闭分层编译,只用 C2(JDK 7 之前的方式)
java -Xint MyApp # 纯解释执行
# 关闭 JIT,只用解释器
java -Xcomp MyApp # 强制 JIT 编译(首次编译)
# 注意:-Xcomp 不是纯 JIT,而是强制首次编译Code Cache 膨胀
JIT 编译后的机器码存储在 Code Cache 中。Code Cache 是有上限的:
# 默认 Code Cache 大小(JDK 8)
# 初始:~4MB(裔编译器)或 ~48MB(C2 编译器)
# 上限:~48MB(C1)或 ~240MB(C2)
# 如果 Code Cache 满了,新的代码无法被 JIT 编译
# 抛出:Warning: codeCache is full; compilation disabled何时不用 JIT
public class ShortLivedApp {
public static void main(String[] args) {
// 这个程序只运行 1 秒
// JIT 还没来得及编译完,程序就结束了
// 解释执行反而更划算
process(args);
}
}本节小结
解释器与 JIT 编译器并存的根本原因:
| 原因 | 解释 | JIT |
|---|---|---|
| 启动速度 | ✅ 即刻开始,无延迟 | ❌ 需要编译 |
| 峰值性能 | ❌ 每次解释,慢 | ✅ 编译后快 |
| 运行时优化 | ❌ 无信息 | ✅ profile-guided |
| 内存效率 | ✅ 无缓存 | ❌ Code Cache 占用 |
HotSpot 的分层编译解决了这个问题:解释器负责快速启动,C1 负责快速响应,C2 负责峰值性能。三层配合,实现「启动即响应、运行达峰值」。
下一节,我们来看 热点代码探测(C1/C2/Graal/AOT)。
