07.1 - 解释执行与编译执行
定位: JVM 执行引擎的两种工作模式——解释器保证快速启动,编译器保证峰值性能,二者通过混合模式共存 面试高频度: ⭐⭐⭐⭐ 考查方式: 为什么需要两种执行方式、热点检测机制、OSR 原理、CompileThreshold
一、这是什么?为什么需要它?
执行引擎将字节码转换为机器码。问题在于:纯解释太慢,纯编译启动太慢。
纯解释模式 (只有解释器):
启动速度: ★★★★★ 几乎无延迟
峰值性能: ★ 每条字节码逐条翻译, 约 1/10 ~ 1/100 编译后速度
内存消耗: ★ 无需存储编译代码
→ 适合短生命周期应用 (一次性命令行工具)
纯编译模式 (只有编译器):
启动速度: ★ JVM 启动时编译所有方法, 等待时间不可接受
峰值性能: ★★★★★ 编译后接近 native 代码速度
内存消耗: ★★★★★ 所有方法编译代码存入 CodeCache
→ 适合长时间运行且不关心启动时间的应用
混合模式 (HotSpot 方案):
启动速度: ★★★★ 解释器立即执行, 编译器后台编译热点
峰值性能: ★★★★★ 热点代码达到 C2 优化级别后接近 native
内存消耗: ★★★ 仅编译热点, 冷代码继续解释执行
→ 鱼和熊掌兼得!核心理念:大多数代码只执行一次(冷代码),不值得编译。少数热点代码执行了 99% 的计算量,值得花时间深度优化。混合模式让"冷代码跑得慢但启动快,热代码跑得飞快"。
二、原理拆解
2.1 解释执行 (Interpretation)——逐门翻译
JVM 解释器(模板解释器)为每条字节码指令预先生成了一段对应的 native 代码模板:
┌─────────────────────────────────────────────────────────────────────┐
│ 模板解释器工作方式 │
│ │
│ 字节码流: │
│ ┌──────┬──────┬──────┬──────┬──────┬──────┬──────┐ │
│ │aload │iload │invoke│aretur│astore│iload │ifge │ │
│ │_0 │_1 │virt. │n │_2 │_1 │ │ │
│ └──┬───┴──┬───┴──┬───┴──┬───┴──┬───┴──┬───┴──┬───┘ │
│ │ │ │ │ │ │ │ │
│ ▼ ▼ ▼ ▼ ▼ ▼ ▼ │
│ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ │
│ │取模│ │取模│ │取模│ │取模│ │取模│ │取模│ │取模│ │
│ │板1 │ │板2 │ │板3 │ │板4 │ │板5 │ │板6 │ │板7 │ │
│ │→执 │→执 │→执 │→执 │→执 │→执 │→执 │ │
│ │行 │ │行 │ │行 │ │行 │ │行 │ │行 │ │行 │ │
│ └────┘ └────┘ └────┘ └────┘ └────┘ └────┘ └────┘ │
│ │
│ 每条字节码 → 跳转到对应 native 代码模板 → 执行 → 取下一条字节码 │
│ (类似 CPU 的取指 → 译码 → 执行 循环) │
└─────────────────────────────────────────────────────────────────────┘优势:
- 无编译延迟——字节码被读取即可执行
- 内存占用低——不需要存储编译结果
- 实现简单且稳定——模板解释器代码量小,bug 少
劣势:
- 性能差——每个 bytecode 都要经过"取指 → 分派 → 执行"循环
- 无法跨指令优化——逐个执行,看不到更全局的优化机会
2.2 编译执行 (JIT)——整章翻译
JIT(Just-In-Time) 编译器将"热点"方法整体翻译为优化后的机器码:
┌─────────────────────────────────────────────────────────────────────┐
│ JIT 编译执行工作方式 │
│ │
│ ┌──────────────────┐ │
│ │ 热点方法 foo() │ │
│ │ (字节码 ~200行) │ │
│ └────────┬─────────┘ │
│ │ │
│ ▼ 编译 (C1 或 C2) │
│ ┌────────────────────────────────────────────────┐ │
│ │ 优化后的机器码 (foo() 的 native 代码 ~2000 行) │ │
│ │ - 方法内联 │ │
│ │ - 循环展开 │ │
│ │ - 逃逸分析 │ │
│ │ - 边界检查消除 │ │
│ │ - 常量折叠 │ │
│ │ ... │ │
│ └────────────────────────────────────────────────┘ │
│ │ │
│ ▼ 执行 │
│ CPU 直接执行优化后的机器码 —— 无需再经过字节码解释层! │
└─────────────────────────────────────────────────────────────────────┘优势:
- 性能极高——C2 编译代码可达 native C++ 的 80%-100% 性能
- 跨方法优化——内联后可以看到更大范围的优化机会
- 基于 profiling 的投机优化——利用运行时数据做最优假设
代价:
- 编译本身消耗 CPU——C2 编译一个方法可能需要 1 秒以上
- CodeCache 消耗——编译后的机器码需要存储空间
- 去优化开销——投机假设不成立时需要回退
2.3 混合模式与热点检测
HotSpot 启动时默认使用混合模式 (-Xmixed),这是 JDK 8 的默认配置:
时间线: 方法 foo() 的"人生"
───────────────────────────────────────────────────────────────────────
T0: 方法首次调用
│
▼
解释执行阶段: foo() 在解释器中运行
│ 方法调用计数器: 0 → 1 → 2 → ... → 1000 → ...
│ 回边计数器: (循环每迭代一次 + 1)
│
▼ 计数器达到 -XX:CompileThreshold = 10000 (Server 模式)
│
编译请求入队: foo() 被加入编译队列
│ 解释器继续执行 foo() (编译在后台进行)
│
▼ C2 编译完成
│
OSR 替换: 如果 foo() 正在执行(如在循环中)→ 栈帧被替换为编译版本
│ 注意: 编译完成时 foo() 可能正在解释执行中!
│ OSR(On-Stack Replacement) 让正在执行的栈帧"热替换"成编译版本
│
▼
编译执行阶段: 后续调用 foo() 直接跳转到编译后的 native 代码
│ CodeCache 中存储编译结果
│ 方法入口被重定向(从解释入口 → 编译入口)
│
▼ 如果计数器衰减...
│
解释执行回归: 方法长时间不调用 → 计数器衰减 → 编译代码可能被卸载计数器机制详解:
方法调用计数器 (Method Invocation Counter):
- 每调用一次 + 1
- 阈值: -XX:CompileThreshold (Server 默认 10000, Client 默认 1500)
- 达到阈值 → 提交编译请求
回边计数器 (Back Edge Counter):
- 循环每迭代一次 + 1
- 阈值: -XX:CompileThreshold * OnStackReplacePercentage / 100
(Server: 10000 * 140 / 100 = 14000)
- 达到阈值 → 提交 OSR 编译请求
计数器衰减 (Counter Decay):
- 每次 GC 时, 计数器值减半
- 防止"瞬时热点"浪费编译资源
- 衰减阈值: -XX:CompileThreshold / 2 (低于此值的方法被视为冷方法)2.4 OSR (On-Stack Replacement)
OSR 是 JIT 编译中最精妙的技术之一。要解决的问题:一个长循环开始执行的时候还没被编译,循环体执行到一半时编译完成了——如何切换?
OSR 场景:
┌─────────────────────────────────────────────────┐
│ public void longLoop() { │
│ // 这个方法还在解释执行... │
│ for (int i = 0; i < 1_000_000; i++) { │
│ // ⚡ 回边计数器累积到触发 OSR 编译 │
│ // ⚡ C2 开始编译 longLoop() │
│ // 😱 编译完成了但我在循环中间! │
│ heavyComputation(i); │
│ } │
│ } │
└─────────────────────────────────────────────────┘OSR 如何解决:
- 编译时在循环开始处插入一个"OSR 入口点"
- 编译完成后,JVM 将当前解释执行栈帧的状态(局部变量、操作数栈)转换为编译代码需要的状态
- 线程跳转到 OSR 入口点,从当前循环迭代继续执行
关键理解:OSR 编译的代码通常不如正常编译的代码优化得好——因为 C2 需要在循环中间切入,能做优化的范围受限。OSR 主要用于快速提升长循环的性能,后续正常入口的调用会使用非 OSR 版本。
三、图解全景
┌──────────────────────────────────────────────────────────────────────────┐
│ 混合模式执行时间线 │
│ │
│ CPU 时间 │
│ │ 方法 A (冷, 执行一次) 方法 B (热, 频繁调用) │
│ │ ┌──────────────────┐ ┌────────────────────────┐ │
│ │ │ 解释执行 │ │ 解释执行 │ │
│ │ │ ~2ms │ │ ~50ms (调用了5000次) │ │
│ │ └──────────────────┘ └──────────┬─────────────┘ │
│ │ │ │
│ │ ▼ │
│ │ ┌────────────────┐ │
│ │ │ 编译请求入队 │ │
│ │ │ C2 编译(300ms) │ │
│ │ └───────┬────────┘ │
│ │ │ │
│ │ ▼ │
│ │ 方法 C (极热, 长循环) ┌────────────────┐ │
│ │ ┌─────────────────┐ │ 编译后执行 │ │
│ │ │ 解释执行(循环) │ │ 剩余 ~95000次 │ │
│ │ │ 回边计数器触发 │ │ 每次 ~0.02ms │ │
│ │ │ OSR 编译请求 │ │ 总 ~1.9ms │ │
│ │ └────────┬────────┘ └────────────────┘ │
│ │ ▼ │
│ │ ┌────────────────┐ │
│ │ │ OSR 编译(500ms) │ │
│ │ │ 编译完成后 │ │
│ │ │ 栈帧替换 │ │
│ │ └───────┬────────┘ │
│ │ ▼ │
│ │ ┌────────────────┐ │
│ │ │ 编译版本继续 │ │
│ │ │ 执行循环 │ │
│ │ │ 速度 ~10x │ │
│ │ └────────────────┘ │
│ └────────────────────────────────────────────────────────────────── │
│ │
│ 核心收益: 冷方法 (A) 只花 ~2ms 无编译代价; │
│ 热方法 (B) 花 300ms 编译但后续调用快了 ~50x; │
│ 极热方法 (C) OSR 编译后在循环后半段提速 ~10x │
│ 总体: 启动时间短, 长期吞吐高 │
└──────────────────────────────────────────────────────────────────────────┘四、实战验证
bash
# 查看 JVM 是否运行在混合模式
java -version
# 输出:
# java version "1.8.0_..."
# Java HotSpot(TM) 64-Bit Server VM (build 25.x-bxx, **mixed mode**)
# ^^^^^^^^bash
# 查看 JIT 编译活动
# -XX:+PrintCompilation 方法每被编译一次就输出一行
java -XX:+PrintCompilation -version
# 输出 (截取关键行):
# 59 1 3 java.lang.String::hashCode (55 bytes)
# 60 2 3 java.lang.String::equals (50 bytes)
# 61 3 3 java.lang.String::indexOf (70 bytes)
# 89 32 4 java.lang.Object::<init> (1 bytes)
# 105 48 4 java.lang.String::hashCode (55 bytes)
# ^ ^ ^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^
# | | | └── 方法签名
# | | └────────── 编译层级 (3 = C1 full, 4 = C2)
# | └────────────────── 编译 ID (全局递增)
# └─────────────────────── 时间戳 (ms 或编译队列开始后时间)bash
# 控制编译行为的关键参数
# 设置编译阈值
-XX:CompileThreshold=5000 # 方法调用 5000 次后编译 (Server 默认 10000)
-XX:+PrintCompilation # 打印编译日志
-XX:-UseCompiler # 完全禁用 JIT (仅解释执行 — 极慢!)
-Xint # 纯解释模式(等效于 -XX:-UseCompiler)
-Xcomp # 纯编译模式(所有方法都编译 — 启动慢!)五、面试视角
| 追问 | 答案要点 |
|---|---|
| 为什么需要解释器和编译器共存? | 纯解释启动快但性能差,纯编译性能好但启动慢。混合模式:启动时解释执行,识别热点后编译热点代码,兼顾启动速度和峰值性能。 |
| 什么代码会被 JIT 编译? | "热点代码"——频繁执行的方法(方法调用计数器达 CompileThreshold)或长循环(回边计数器达 OSR 阈值)。冷代码始终在解释器运行。 |
| 什么是 OSR?有什么用? | On-Stack Replacement。当一个方法在解释执行长循环时编译完成,OSR 允许正在执行的栈帧"热替换"为编译版本,从当前循环位置继续执行。 |
| CompileThreshold 怎么影响性能? | 默认 10000(Server)。设小值 → 更快编译 → 更快达到峰值性能,但可能编译冷代码浪费资源。设大值 → 更慢达到峰值性能,但节省编译资源和 CodeCache。 |
| 计数器衰减的作用是什么? | 防止"瞬时热点"浪费编译资源。GC 时计数器减半,持续不热的"假热点"会被衰减到阈值之下,不再触发编译。 |
| 解释执行一定比编译执行慢多少? | 约 10-100 倍不等。取决于字节码复杂度、JIT 优化程度。简单的 getter/setter 可能只慢 2-3 倍,复杂的多态调用链可能差 100 倍以上。 |
| -Xint 和 -Xcomp 有什么区别? | -Xint 纯解释,JIT 完全禁用,无编译开销但速度极慢。-Xcomp 所有方法都编译,启动极慢但运行快。生产环境永远不要用这两个参数。 |
📚 相关链接
- **字节码指令集概览** — 字节码是解释器执行的基本单位
- **C1与C2编译器与分层编译** — 编译器如何去编译热点代码
- **方法内联和逃逸分析** — C2 最重要的两项优化
- ← 返回 **执行引擎索引**