Skip to content

解释器与 JIT 编译器并存原因

为什么 JVM 需要两者共存

这是一个看似简单、实则深刻的问题。如果 JIT 编译器能编译出更快的机器码,为什么不干脆全部用 JIT?反之,如果解释器够用,为什么还要 JIT?

答案在于:它们各自有不同的优势,只有并存才能取长补短

解释器的优势:即时响应

解释器最大的优势是零启动延迟

启动阶段:解释执行说了算

程序刚启动时,JIT 编译器还没有收集到任何运行时信息,不知道哪些代码是热点。此时用解释器立即开始执行,不需要等待编译:

java
public class StartupDemo {
    public static void main(String[] args) {
        // main 方法开始执行时,JIT 还没有完成编译
        // 这段代码最初由解释器执行
        System.out.println("Application started in milliseconds");
        initialize();
    }
}

如果全部使用 JIT:

  1. JVM 需要先编译所有代码
  2. 编译完成后才能开始执行
  3. 一个 10MB 的 JAR 可能需要几十秒才能完成编译
  4. 用户体验极差

解释器的响应方式

时间 →
0ms ────────→ JIT 全部编译 ────────→ 开始执行  ← 全 JIT 方案
0ms ──→ 开始执行 ← 解释器方案

     无等待

JIT 编译器的优势:峰值性能

JIT 编译器能做出解释器做不到的优化。

运行时信息:JIT 的独门武器

解释器只能执行字节码,不知道任何运行时信息。JIT 编译器在编译时能看到:

  • 热点代码:哪些方法被调用了十万次?
  • 类型信息:这个字段真的是 String 还是 Object
  • 分支预测if 条件是 true 多还是 false 多?
  • 数据流x 在这个分支里一定是正数吗?

这些运行时信息,让 JIT 能够做出更激进的优化。

内联:最强大的优化

方法内联是 JIT 最重要的优化之一。

java
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 会内联这个方法
    }
}

内联后:

java
// 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 1C1 编译快速轻量级编译较快
Tier 2C1 编译 + profiling带方法调用和分支统计
Tier 3C1 编译 + 完整 profiling完整的运行时统计较慢较快
Tier 4C2 编译完全激进优化最慢最快
启动阶段 ──→ 预热阶段 ──→ 峰值阶段
   │              │               │
   │ Tier 0       │ Tier 1/2/3    │ Tier 4
   │ (纯解释)      │ (C1 编译)     │ (C2 编译)
   │ 立即开始       │ 快速响应       │ 最强优化
   │              │               │
   └──────────────┴───────────────┘

各层级的触发条件

java
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 和内存。在编译期间,程序可能出现短暂的停顿(编译停顿)。

bash
# 关闭分层编译,只用 C2(JDK 7 之前的方式)
java -Xint MyApp        # 纯解释执行

# 关闭 JIT,只用解释器
java -Xcomp MyApp       # 强制 JIT 编译(首次编译)
# 注意:-Xcomp 不是纯 JIT,而是强制首次编译

Code Cache 膨胀

JIT 编译后的机器码存储在 Code Cache 中。Code Cache 是有上限的:

bash
# 默认 Code Cache 大小(JDK 8)
# 初始:~4MB(裔编译器)或 ~48MB(C2 编译器)
# 上限:~48MB(C1)或 ~240MB(C2)

# 如果 Code Cache 满了,新的代码无法被 JIT 编译
# 抛出:Warning: codeCache is full; compilation disabled

何时不用 JIT

java
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)

基于 VitePress 构建