07.2 - C1 与 C2 编译器 & 分层编译
定位: HotSpot 的两大 JIT 编译器——C1 求快(快速编译),C2 求好(深度优化),分层编译在二者之间架设"进度阶梯" 面试高频度: ⭐⭐⭐⭐⭐ 考查方式: C1 vs C2 对比、分层编译各层级作用、去优化原因
一、这是什么?为什么需要它们?
JIT 编译器将热点字节码编译为机器码。但不同的代码对编译速度和质量有不同要求:
方法启动后不久就变成热点 (如 Servlet 的 doGet):
我需要快速出优化代码 → C1 编译即可, 够快了
方法启动很久后才变成热点 (如复杂的排序算法):
我值得花时间深度优化 → 等 C2 编译, 追求极致性能
方法一直在解释执行 (如不常用的错误处理):
我根本不需要编译 → 继续在解释器跑C1 (Client Compiler):以编译速度优先,适用于客户端应用或对启动时间敏感的场景。
- 编译快(亚毫秒到几十毫秒/方法)
- 优化适度(~100 种优化)
- 代码质量"够好"
C2 (Server Compiler):以代码质量优先,适用于长时间运行的服务器。
- 编译慢(秒级/方法)
- 优化激进(~1000 种优化)
- 代码质量"接近 hand-written assembly"
两者都不完美:C1 编译快但代码不够快,C2 代码快但编译等待太久。于是有了分层编译——先 C1 编译让你跑起来,再 C2 深度优化到极致。
二、原理拆解
2.1 C1 编译器——快速出活
C1 的编译管线设计围绕"快"展开:
C1 编译管线:
┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐
│ Bytecode │ → │ HIR │ → │ LIR │ → │ 寄存器分配 │ → │ 机器码生成 │
│ Input │ │ (High IR) │ │ (Low IR) │ │ (Register │ │ (CodeGen) │
│ │ │ │ │ │ │ Allocation) │ │ │
└────────────┘ └────────────┘ └────────────┘ └────────────┘ └────────────┘
│ │ │
▼ ▼ ▼
字节码→SSA图 HIR→线性IR 线性扫描分配寄存器
基本优化: 消除虚指令 (快于图着色算法)
- 常量折叠 - 内存操作优化
- 死代码消除 - 窥孔优化
- 通用子表达式消除C1 的优化清单(精简版~100 种中的代表性):
- 常量折叠:
int x = 2 * 3→int x = 6 - 死代码消除:未被使用的变量赋值 → 移除
- 无用代码消除:不可达分支 → 移除
- 方法内联:小方法(≤35 字节)直接内联
- 简单循环展开:小循环适度展开
- 窥孔优化:相邻的指令合并或替换为更高效的序列
2.2 C2 编译器——慢工出细活
C2 的编译管线建立在 Ideal Graph(Sea-of-Nodes IR)之上——这是 C2 区别于 C1 的核心:
C2 编译管线:
┌────────────┐ ┌──────────────────────────┐ ┌────────────┐ ┌────────────┐
│ Bytecode │ → │ 构建 Ideal Graph │ → │ 匹配/指令 │ → │ 寄存器分配 │
│ Input │ │ (Sea-of-Nodes) │ │ 选择 │ │ (Global │
│ │ │ │ │ (Matching) │ │ RA) │
└────────────┘ └──────────────────────────┘ └────────────┘ └────────────┘
│ ▲ │ │
▼ └─── 多轮迭代 ──────┘ ▼
┌──────────────────────────────────────┐ ┌────────────┐
│ 优化主循环 (反复迭代至收敛) │ │ 机器码生成 │
│ ┌────────────────────────────────┐ │ │ (CodeGen) │
│ │ 内联 → 逃逸分析 → 循环优化 → │ │ │ │
│ │ 锁消除 → 标量替换 → GVN → │ │ │ │
│ │ 类型分析 → 条件消除 → 空值检查消除│ │ │ │
│ └────────────────────────────────┘ │ │ │
└──────────────────────────────────────┘ └────────────┘Ideal Graph(Sea-of-Nodes) 的精髓:数据流和控制流融合在一个图中,每个 node 代表一个操作,边代表数据或控制依赖。设计目标是让优化 pass 能够全局重排操作,不受原始字节码顺序的限制。
C2 的关键优化(~1000 种中的重量级):
C2 优化层次 (从最重要到次重要):
┌───────────────────────────────────────────────────────────┐
│ ████████████████████████████████ 方法内联 │
│ 一切优化的基础——打破方法边界才能看到全局 │
├───────────────────────────────────────────────────────────┤
│ ████████████████████████ 逃逸分析 + 标量替换 │
│ 消除不必要的对象分配 —— 最有效的"零GC"方案 │
├───────────────────────────────────────────────────────────┤
│ ████████████████████ 循环优化 │
│ 循环展开、循环分离、循环翻转、循环逆转 │
├───────────────────────────────────────────────────────────┤
│ ████████████████ 全局值编号 (GVN) │
│ 发现并消除重复计算 │
├───────────────────────────────────────────────────────────┤
│ ████████████████ 锁消除 + 锁粗化 │
│ 消除不必要的同步开销 │
├───────────────────────────────────────────────────────────┤
│ ████████████ 边界检查消除 │
│ 已知范围内的数组访问跳过越界检查 │
├───────────────────────────────────────────────────────────┤
│ ████████████ 虚调用优化 (Devirtualization) │
│ 将多态调用转为直接调用 (基于类型 profiling) │
├───────────────────────────────────────────────────────────┤
│ ███████████ 自动装箱消除 │
│ 消除 int ↔ Integer 的隐式转换开销 │
├───────────────────────────────────────────────────────────┤
│ ████ 条件传播与常量折叠 │
│ 静态确定的分支直接消除 │
└───────────────────────────────────────────────────────────┘2.3 分层编译——5 级的精妙设计
为什么需要 5 级,而不是直接 C1 → C2 两级? 因为 profiling 的收集是逐步展开的——太早收集 low-quality profile,太晚收集浪费编译机会。
分层编译的 5 级在时间线上的演进:
方法首次调用
│
▼
Level 0 ─────────────────── 纯解释执行
│ - 无编译, 无 profiling 代码从冷到热的"检测阶段"
│ - 仅统计调用次数和回边次数 ──────────────
│ - 大多数方法停留在 L0
│ (冷代码, 永远不触发编译)
│ 调用次数达阈值 (或回边达 OSR 阈值)
▼
Level 1 ──┐ C1 编译 ──┐ 无 profiling
│ │ (只有少数"伪热点"会到 L1)
│ │
Level 2 ──┤ C1 编译 ──┤ 基础 profiling (调用/回边计数)
│ │ (达到基本热度)
│ │
Level 3 ──┤ C1 编译 ──┤ 完整 profiling
│ │ - 类型 profile (receiver type 分布) 关键层级!
│ │ - 分支 profile (branch taken/not) 为 C2 收集数据
│ │ - 方法调用计数
│ │
▼ ▼
Level 4 ──┘ C2 编译 ──┘ 使用 L3 收集的 profiling 进行极限优化关键规则:
| 转换 | 条件 | 说明 |
|---|---|---|
| L0 → L3 | 方法调用计数达阈值 | 大多数热点走 L0 → L3 → L4 路径 |
| L3 → L4 | C2 编译队列空闲(或 C2 抢占) | C2 用 L3 的 profile 做深度优化 |
| L0 → L2 | C1 队列忙且 C2 队列空闲 | 降级到 L2(少收集 profile) |
| L0 → L1 | 方法极小(可无 profile 优化) | 如 getter/setter、Object::<init> |
| L4 → L0 | 去优化触发 | 投机假设失效 → 回退解释执行 |
L3 为什么是关键层级:C2 无法凭空优化——它需要知道"这个接口调用 99% 都是 ArrayList"、"这个 if 分支 95% 走 true"。L3 的完整 profiling 就是 C2 的"眼睛",没有它 C2 只能做保守优化。
为何存在 L1 和 L2?
- L1:极小方法(如 getter/setter)无需 profile 就能优化——直接 C1 无 profiling 编译,甚至可能被直接内联到 L4 编译的方法中而不需要单独 L4
- L2:C1 队列拥塞时的降级方案——收基础 profile,后续可升到 L3 或直接到 L4
2.4 去优化 (Deoptimization)
去优化是 JIT 安全的保障——编译器基于 profiling 做"投机优化",但不保证假设永远成立。
去优化场景示例:
➊ 类型假设失败
C2 编译时基于 profiling: "mystery.toString() 99% 是 String.toString()"
→ 内联 String.toString() 并插入类型检查 guard
→ 运行时: obj 突然变成 StringBuilder! guard 失败!
→ 去优化: 回退解释执行, 重新收集 profile
➋ 类加载导致去优化
C2 编译时认为: "只有子类 A 和 B"
→ 运行时: 加载了子类 C, 之前的编译假设不再安全
→ 去优化: 回退, 重新收集包含 C 的 profile 后重新编译
去优化处理流程:
┌────────┐ ┌────────────┐ ┌──────────┐ ┌──────────┐
│ 去优化 │ → │ 回退解释执行 │ → │ 继续收集 │ → │ 可能重新 │
│ 触发 │ │ │ │ profile │ │ 编译 │
└────────┘ └────────────┘ └──────────┘ └──────────┘
│
▼
原因: 投机优化的假设不成立
受影响方法: 被去优化的方法及其调用者 (因内联传播)被去优化后怎么办?方法回到解释执行,继续收集 profile。如果 profile 稳定了(不再变化),JIT 可能再次编译——这次使用更完整的 profile,做出更准确的假设。
去优化不影响正确性:解释器始终持有字节码的完整语义。编译代码只是"加速器"——加速器坏了(去优化),退回步行走路(解释执行),程序逻辑不受影响。这是 JIT 安全和 C/C++ 未定义行为的本质区别。
三、图解全景
3.1 5 级编译流程图
┌─────────────────────────────────────────────────────────────────────────┐
│ 分层编译完整流程图 │
│ │
│ ┌──────────────────────┐ │
│ │ Level 0: 解释执行 │ │
│ │ ┌────────────────┐ │ │
│ │ │ 方法调用计数 │ │ │
│ │ │ 回边计数 │ │ │
│ │ └────────────────┘ │ │
│ └──────────┬───────────┘ │
│ │ │
│ 调用计数达阈值 │ 回边计数达阈值 │
│ (CompileThreshold) (OSR Threshold) │
│ │ │
│ ┌─────────┴──────────┐ │
│ ▼ ▼ │
│ ┌──────────────┐ ┌────────────────┐ │
│ │ 方法极小? │ │ Level 3: C1完整 │──→ 方法还在循环中运行时执行OSR│
│ │ (≤8字节) │ │ profiling │ ↓ │
│ └──┬───┬───────┘ └───────┬────────┘ OSR 编译版本(L4) │
│ 是 │ │ 否 │ │
│ ▼ │ │ │ ┌──────────────┐ │
│ ┌────────┐ │ │ │ ┌──────── │ C2 编译引擎 │ │
│ │Level 1 │ │ │ │ │ │ (Ideal Graph) │ │
│ │C1 无 │ │ │ │ │ └──────────────┘ │
│ │profile │ │ │ │ ▼ │
│ └────────┘ │ ▼ │ ┌─────────────────────┐ │
│ │ ┌────────────┐ │ │ Level 4: C2 完整 │ │
│ │ │ Level 2: │ │ │ 优化 │ │
│ │ │ C1 基础 │───────┘ │ 使用 L3 的 profile │ │
│ │ │ profiling │ └──────────┬──────────┘ │
│ │ └────────────┘ │ │
│ │ │ │
│ │ ┌────────────────────────┘ │
│ │ ▼ │
│ │ ┌──────────────────────┐ │
│ │ │ 去优化 (Deopt)? │────── 假设失败 → 回 L0 │
│ │ │ - 类加载 │ │
│ │ │ - 类型 profile 变化 │ │
│ │ │ - 分支 profile 变化 │ │
│ │ └──────────────────────┘ │
│ │ │ 假设依然成立 │
│ │ ▼ │
│ │ ┌──────────────────────┐ │
│ │ │ 编译版本持续使用 │ │
│ │ │ 直到新的事件触发 │ │
│ │ │ 重新编译或去优化 │ │
│ │ └──────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘3.2 C1 vs C2 优化管线对比
C1 编译管线 (~50ms/方法):
┌──────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────┐ ┌──────────┐
│ │ │ │ │ │ │ │ │ │
│ 字节码 │────│ HIR 构建+优化 │────│ LIR 生成+优化 │────│ 线性扫描 │────│ 机器码 │
│ │ │ (SSA, 常量 │ │ (内存操作, │ │ 寄存器分配 │ │ │
│ │ │ 折叠, DCE) │ │ 窥孔优化) │ │ (O(n)) │ │ │
│ │ │ │ │ │ │ │ │ │
└──────────┘ └──────────────┘ └──────────────┘ └──────────┘ └──────────┘
C2 编译管线 (~1000ms/方法):
┌──────────┐ ┌──────────────────────────────┐ ┌──────────┐ ┌──────────┐
│ │ │ │ │ │ │ │
│ 字节码 │────│ Ideal Graph + 多轮优化 │────│ 指令匹配 │────│ 图着色 │
│ │ │ ┌────────────────────────────┐│ │ (SSA→ │ │ 寄存器分配 │
│ │ │ │ 迭代 pass (每次 pass 遍历 ││ │ 机器码) │ │ (O(n²)) │
│ │ │ │ 整个图并转换) ││ │ │ │ │
│ │ │ └────────────────────────────┘│ │ │ │ │
│ │ │ │ 重新应用优化 │ │ │ │ │
│ │ │ ▼ │ │ │ │ │
│ │ │ ┌────────────────────────┐ │ │ │ │ │
│ │ │ │ 再次扫描图, 应用更多优化 │ │ │ │ │ │
│ │ │ └────────────────────────┘ │ │ │ │ │
│ │ │ │ 循环至收敛 │ │ │ │ │
│ │ │ ▼ │ │ │ │ │
│ │ │ 图不再变化 → 优化完成 │ │ │ │ │
│ │ └──────────────────────────────┘ └──────────┘ └──────────┘
│ │ │
│ │ ▼
│ │ ┌──────────────┐
│ │ │ CodeCache │
│ │ │ ┌───┐ ┌───┐ │
│ │ │ │A │ │B │ │
│ │ │ └───┘ └───┘ │
│ │ └──────────────┘
└──────────┘四、实战验证
# 查看当前使用的编译器
java -XX:+PrintCompilation -version
# 输出示例:
# 52 1 3 java.lang.String::hashCode (55 bytes)
# 52 2 3 java.util.HashMap::hash (20 bytes)
# 53 3 3 java.lang.String::equals (50 bytes)
# 55 4 1 java.lang.Object::<init> (1 bytes)
# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
# L1 表示 C1 无 profiling 编译
# 57 5 3 java.lang.String::indexOf (70 bytes)
# 73 12 4 java.lang.String::hashCode (55 bytes)
# ^^ L4 表示 C2 编译 → hashCode 从 L3 升级到 L4
# 关键观察:
# - Level 1: 极小方法 (Object::<init> 仅 1 字节)
# - Level 3: 大多数方法首次编译
# - Level 4: 热点方法的最终优化# 查看内联决策
-XX:+PrintInlining
# 输出示例:
# @ 15 java.lang.String::charAt (25 bytes) inline (hot)
# @ 8 java.lang.String::checkBounds (21 bytes) inline (hot)
# @ 5 java.lang.String::isLatin1 (15 bytes) inline
# @ 14 java.lang.StringUTF16::getChar (12 bytes) inline
# @ 7 java.lang.String::length (6 bytes) inline (intrinsic)
# @ 22 java.util.HashMap::hash (20 bytes) already compiled in a previous transaction
# @ 40 java.lang.String::equals (50 bytes) not inline (method too big)
# ^^^^^^^^^^^^^^^^
# 内联失败的原因分析# 分层编译参数
-XX:+TieredCompilation # 启用分层编译 (JDK 8 默认开启)
-XX:-TieredCompilation # 禁用分层编译 → 回退到解释 + C2 两级模式
-XX:Tier0ProfilingInterval=33 # L0 profile 采样间隔
-XX:Tier3MinInvocationThreshold=100 # L0 → L3 的调用阈值
-XX:Tier4MinInvocationThreshold=5000 # L3 → L4 的调用阈值
# 编译线程数
-XX:CICompilerCount=2 # 编译器线程数 (默认: CPU 核数 / 2)
# C1 使用更多线程, C2 使用更少线程
# 每个 C1/C2 线程独立编译不同的方法
# CodeCache 控制
-XX:InitialCodeCacheSize=240m # CodeCache 初始大小
-XX:ReservedCodeCacheSize=240m # CodeCache 最大大小 (JDK 8 默认 240m)
-XX:+PrintCodeCache # 打印 CodeCache 使用情况# 去优化演示: 观察类型 profiling 变化
# 运行一个使用接口的代码, 观察 -XX:+PrintCompilation 中的 made not entrant / zombie
java -XX:+PrintCompilation -XX:+UnlockDiagnosticVMOptions -XX:+PrintDeoptimizationDetails Demo
# 去优化日志中的关键标记:
# made not entrant ← 编译代码被标记为不可进入 (类加载等导致)
# made zombie ← 编译代码被标记为僵尸 (等待被卸载)五、面试视角
| 追问 | 答案要点 |
|---|---|
| C1 和 C2 编译器的核心区别? | C1 编译快 (~50ms/方法)、优化温和 (~100 种); C2 编译慢 (~1s/方法)、优化激进 (~1000 种)。C1 用线性扫描寄存器分配,C2 用图着色。C2 有 Ideal Graph 做全局优化。 |
| 分层编译的 5 个级别分别是什么? | L0 纯解释; L1 C1 无 profile; L2 C1 基础 profile; L3 C1 完整 profile (为 C2 收集关键数据); L4 C2 完整优化 (使用 L3 的 profile)。 |
| 为什么需要去优化 (Deoptimization)? | JIT 基于运行时 profiling 做"投机优化"(假设类型、分支模式等不变)。假设不成立时必须回退到解释执行以保证正确性。这是 JIT 安全性的核心保障。 |
| L3 为什么是关键层级? | L3 收集完整的类型 profile 和分支 profile,是 C2 做深度优化的"眼睛"。没有 profiling 数据,C2 无法做出精确的投机优化。 |
| C2 的 Ideal Graph (Sea-of-Nodes) 有什么优势? | 数据流和控制流融合在单一图中,优化 pass 可以全局重排操作。比 C1 的 HIR→LIR 两阶段 IR 更灵活,支持更多跨操作的全局优化。 |
| 什么是 made not entrant / zombie? | not entrant: 编译代码因新类加载或触发去优化而不再接受新调用。zombie: not entrant 后所有现存调用退出,代码可被 CodeCache 回收。 |
| 去优化会影响程序正确性吗? | 不会。解释器掌握完整语义,编译代码只是"加速器"。去优化后回到解释器执行,完全保证正确性(与 C/C++ 未定义行为有本质区别)。 |
📚 相关链接
- **解释执行与编译执行** — 解释执行是分层编译的 L0,也是去优化的回退目标
- **方法内联和逃逸分析** — C2 最重要的两个优化方法
- **锁消除与标量替换** — C2 的高级优化,依赖逃逸分析
- ← 返回 **执行引擎索引**